Refactor dependencies and implement audio decryption logic for enhanced track handling
This commit is contained in:
parent
5325bbc34a
commit
bd8287e935
5 changed files with 148 additions and 111 deletions
52
src/helpers/decrypt.ts
Normal file
52
src/helpers/decrypt.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
};
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue