Refactor dependencies and implement audio decryption logic for enhanced track handling

This commit is contained in:
Seth 2025-05-08 06:32:08 -04:00
parent 5325bbc34a
commit bd8287e935
5 changed files with 148 additions and 111 deletions

52
src/helpers/decrypt.ts Normal file
View file

@ -0,0 +1,52 @@
import { createDecipheriv } from "crypto";
import type { TidalManifest } from "../../Caches/PlaybackInfoTypes";
// Do not change this
const mastKey = "UIlTTEMmmLfGowo/UC60x2H45W6MdGgTRfo/umg4754=";
const mastKeyBuffer = Buffer.from(mastKey, "base64");
type DecryptedKey = {
key: Buffer;
nonce: Buffer;
};
const decryptKeyId = (keyId: string): DecryptedKey => {
// Decode the base64 strings to buffers
const keyIdBuffer = Buffer.from(keyId, "base64");
// Get the IV from the first 16 bytes of the securityToken
const iv = keyIdBuffer.subarray(0, 16);
const keyIdEnc = keyIdBuffer.subarray(16);
// Initialize decryptor
const decryptor = createDecipheriv("aes-256-cbc", Uint8Array.from(mastKeyBuffer), Uint8Array.from(iv));
// Decrypt the security token
const keyIdDec = decryptor.update(Uint8Array.from(keyIdEnc));
// Get the audio stream decryption key and nonce from the decrypted security token
const key = keyIdDec.subarray(0, 16);
const nonce = keyIdDec.subarray(16, 24);
return { key, nonce };
};
// Extend nonce to 16 bytes (nonce + counter)
const makeDecipheriv = ({ key, nonce }: DecryptedKey) => {
const iv = new Uint8Array([...nonce, ...new Uint8Array(8)]);
return createDecipheriv("aes-128-ctr", Uint8Array.from(key), iv);
};
export const makeDecipher = (manifest: { keyId: string; encryptionType: string }) => {
switch (manifest.encryptionType) {
case "OLD_AES": {
return makeDecipheriv(decryptKeyId(manifest.keyId));
}
case "NONE": {
return undefined;
}
default: {
throw new Error(`Unexpected manifest encryption type ${manifest.encryptionType}`);
}
}
};

View file

@ -1,63 +1,31 @@
import { credentialsProvider } from "@tidal-music/auth";
import { FlacStreamTagger } from "flac-stream-tagger";
import Ffmpeg from "fluent-ffmpeg";
import ffmpegPath from "ffmpeg-static";
import { PassThrough } from "stream";
import { DashMPD } from '@liveinstantly/dash-mpd-parser';
import { parse } from "dasha";
import { makeDecipher } from "./decrypt";
Ffmpeg.setFfmpegPath(ffmpegPath as string);
export default class {
authHeaders: { [key: string]: string }
private _authHeaders: { [key: string]: string }
private _userId: number;
constructor(authHeaders: { [key: string]: string }) {
this.authHeaders = authHeaders
this._authHeaders = authHeaders
this._userId = 0;
}
async convertAacToFlac(buffer: Buffer<ArrayBuffer> | ArrayBuffer) {
const inputStream = new PassThrough();
inputStream.end(Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer));
const outputChunks: Buffer[] = [];
await new Promise((resolve, reject) => {
Ffmpeg(inputStream)
.format("flac")
.on("error", reject)
.on("end", resolve)
.pipe()
.on("data", chunk => outputChunks.push(chunk));
});
return Buffer.concat(outputChunks);;
async init() {
const { token } = await credentialsProvider.getCredentials() as { token: string };
this._userId = JSON.parse(Buffer.from(token, "base64").toString("utf-8")).uid;
}
async convertMp4ToFlac(buffer: Buffer) {
const inputStream = new PassThrough();
inputStream.end(buffer);
const outputChunks: Buffer[] = [];
await new Promise((resolve, reject) => {
Ffmpeg(inputStream)
.audioCodec("flac")
.format("flac")
.on("error", reject)
.on("end", () => resolve(Buffer.concat(outputChunks)))
.pipe()
.on("data", chunk => outputChunks.push(chunk));
});
return Buffer.concat(outputChunks);
}
async fetchTrack(id: number) {
const audio = await fetch(`https://desktop.tidal.com/v1/tracks/${id}/playbackinfo?audioquality=HI_RES_LOSSLESS&playbackmode=STREAM&assetpresentation=FULL`, {
headers: this.authHeaders
headers: this._authHeaders
})
console.log(await audio.text())
return await audio.json() as {
trackId: number,
audioPresentation: string,
@ -77,8 +45,8 @@ export default class {
}
async fetchTracks() {
const tracks = await fetch("https://listen.tidal.com/v1/users/199235629/favorites/tracks?offset=0&limit=10000&order=DATE&orderDirection=DESC&countryCode=US", {
headers: this.authHeaders
const tracks = await fetch(`https://desktop.tidal.com/v1/users/${this._userId}/favorites/tracks?offset=0&limit=50&order=DATE&orderDirection=DESC&countryCode=US&locale=en_US&deviceType=DESKTOP`, {
headers: this._authHeaders
})
return await tracks.json();
@ -86,55 +54,46 @@ export default class {
async downloadFlac(manifestMimeType: string, manifest: string) {
if (manifestMimeType === "application/dash+xml") {
const mpd = new DashMPD();
mpd.parse(Buffer.from(manifest, "base64").toString("utf-8"))
const parsedManifest = await parse(Buffer.from(manifest, "base64").toString("utf-8"), "https://sp-ad-cf.audio.tidal.com")
const Representation = mpd.getJSON().MPD.Period[0].AdaptationSet[0].Representation[0];
const segmentTemplate = Representation.SegmentTemplate;
const segmentCount = segmentTemplate.SegmentTimeline.S[0]["@r"];
const segmentBuffers = [];
const initialization = await fetch(segmentTemplate["@initialization"])
const initializationBuffer = await initialization.arrayBuffer()
segmentBuffers.push(Buffer.from(initializationBuffer));
const segmentPromises = [];
for (let i = 0; i < 1 + segmentCount; i++) {
const segmentUrl = segmentTemplate["@media"].replace("$Number$", segmentTemplate["@startNumber"] + i);
segmentPromises.push(fetch(segmentUrl).then(segment => segment.arrayBuffer()).then(segmentBuffer => Buffer.from(segmentBuffer)));
for await (const track of parsedManifest.tracks.all) {
for await (const segment of track.segments) {
const req = await fetch(segment.url)
const content = await req.arrayBuffer()
const contentBuffer = Buffer.from(content)
segmentBuffers.push(contentBuffer)
}
}
const segmentBuffersArray = await Promise.all(segmentPromises);
segmentBuffers.push(...segmentBuffersArray);
const flac = await this.convertMp4ToFlac(Buffer.concat(segmentBuffers))
return { buffer: flac, mimeType: "audio/flac" }
return { buffer: Buffer.concat(segmentBuffers), mimeType: "audio/m4a" }
} else if (manifestMimeType === "application/vnd.tidal.bts") {
const data = JSON.parse(Buffer.from(manifest, "base64").toString("utf-8")) as {
"mimeType": string,
"codecs": string,
"encryptionType": string,
"keyId": string,
"urls": string[]
}
const decryptor = makeDecipher({ keyId: data.keyId, encryptionType: data.encryptionType });
const flac = await fetch(data.urls[0])
return { buffer: await flac.arrayBuffer(), mimeType: data.mimeType }
const flacBuffer = await flac.arrayBuffer()
const decryptedBuffer = decryptor ? Buffer.concat([decryptor.update(Buffer.from(flacBuffer)), decryptor.final()]) : Buffer.from(flacBuffer);
return { buffer: decryptedBuffer, mimeType: "audio/flac" };
}
return { buffer: new ArrayBuffer(0), mimeType: "" } // TODO: Handle other mime types
return { buffer: new ArrayBuffer(0), mimeType: "audio/unknown" } // TODO: Handle other mime types
}
async tagFlac(id: number, audioBuffer: { buffer: Buffer<ArrayBuffer> | ArrayBuffer, mimeType: string }) {
const fileId = Bun.nanoseconds().toString(36);
if (audioBuffer.mimeType === "audio/mp4") {
audioBuffer.buffer = await this.convertAacToFlac(audioBuffer.buffer);
}
const trackReq = await fetch(`https://api.tidal.com/v1/tracks/${id}/?countryCode=US`, {
headers: this.authHeaders
async tagFlac(id: number, audioBuffer: Buffer<ArrayBuffer> | ArrayBuffer) {
const trackReq = await fetch(`https://desktop.tidal.com/v1/tracks/${id}/?countryCode=US`, {
headers: this._authHeaders
})
const track = await trackReq.json() as {
@ -161,11 +120,11 @@ export default class {
status?: string,
};
const alubmReq = await fetch(`https://api.tidal.com/v1/albums/${track.album.id}/?countryCode=US`, {
headers: this.authHeaders
const albumReq = await fetch(`https://desktop.tidal.com/v1/albums/${track.album.id}/?countryCode=US`, {
headers: this._authHeaders
})
const album = await alubmReq.json() as {
const album = await albumReq.json() as {
title: string,
numberOfTracks: number,
releaseDate: string,
@ -178,28 +137,32 @@ export default class {
],
}
return FlacStreamTagger.fromBuffer(Buffer.from(Buffer.isBuffer(audioBuffer.buffer) ? audioBuffer.buffer : Buffer.from(audioBuffer.buffer)), {
const albumArtReq = await fetch(`https://resources.tidal.com/images/${album.cover.replaceAll("-", "/")}/1280x1280.jpg`)
const albumArt = albumArtReq.ok ? await albumArtReq.arrayBuffer() : new ArrayBuffer(0);
return FlacStreamTagger.fromBuffer(Buffer.from(Buffer.isBuffer(audioBuffer) ? audioBuffer : Buffer.from(audioBuffer)), {
tagMap: {
title: track.title,
trackNumber: track.trackNumber.toString(),
discNumber: track.volumeNumber.toString(),
bpm: (track.bpm || 0).toString(),
date: track.streamStartDate,
copyright: track.copyright,
REPLAYGAIN_TRACK_GAIN: track.replayGain.toString(),
REPLAYGAIN_TRACK_PEAK: track.peak.toString(),
comment: track.url,
isrc: track.isrc,
upc: album.upc,
artist: track.artists.map((a) => a.name),
album: album.title,
albumArtist: album.artists.map((a) => a.name),
totalTracks: album.numberOfTracks.toString(),
year: album.releaseDate.split("-")[0] || "",
title: track?.title || "",
trackNumber: (track?.trackNumber || 0).toString(),
discNumber: (track?.volumeNumber || 0).toString(),
bpm: (track?.bpm || 0).toString(),
date: track?.streamStartDate || "",
copyright: track?.copyright || "",
REPLAYGAIN_TRACK_GAIN: (track?.replayGain || 0).toString(),
REPLAYGAIN_TRACK_PEAK: (track?.peak || 0).toString(),
comment: track?.url || "",
isrc: track?.isrc || "",
upc: album?.upc || "",
artist: track?.artists.map((a) => a.name) || [],
album: album?.title || "",
albumArtist: album?.artists.map((a) => a.name) || [],
totalTracks: (album?.numberOfTracks || 0).toString(),
year: album?.releaseDate.split("-")[0] || "",
},
picture: {
buffer: Buffer.from(await (await fetch(`https://resources.tidal.com/images/${album.cover.replaceAll("-", "/")}/1280x1280.jpg`)).arrayBuffer()),
buffer: Buffer.from(albumArt),
}
}).toBuffer()
}
}