This commit is contained in:
Seth 2025-05-23 00:42:45 -04:00
parent f9b116af51
commit 0eb6c15057
5 changed files with 170 additions and 198 deletions

View file

@ -4,13 +4,11 @@
"": {
"name": "bun-react-template",
"dependencies": {
"@inrixia/helpers": "^3.20.0",
"@tidal-music/auth": "^1.3.4",
"@types/fluent-ffmpeg": "^2.1.27",
"bun-storage": "^0.2.1",
"dasha": "^3.1.6",
"ffmpeg-static": "^5.2.0",
"flac-stream-tagger": "^1.0.10",
"fluent-ffmpeg": "^2.1.3",
"webview-bun": "^2.4.0",
},
"devDependencies": {
@ -21,7 +19,7 @@
"packages": {
"@babel/runtime": ["@babel/runtime@7.27.1", "", {}, "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog=="],
"@derhuerst/http-basic": ["@derhuerst/http-basic@8.2.4", "", { "dependencies": { "caseless": "^0.12.0", "concat-stream": "^2.0.0", "http-response-object": "^3.0.1", "parse-cache-control": "^1.0.1" } }, "sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw=="],
"@inrixia/helpers": ["@inrixia/helpers@3.20.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-RkWZ8ZIdB3cpQvTcLsY3A8ux+4Q8PNZU0uDKJT2xVMAP0FvCsLNSJemRz9vlDalWBDkBhI9v0O/br3cF9vPnWw=="],
"@tidal-music/auth": ["@tidal-music/auth@1.3.4", "", { "dependencies": { "@tidal-music/auth": "1.3.4", "@tidal-music/common": "^0.1.5", "@tidal-music/true-time": "^0.3.0" } }, "sha512-eN2tBqsj9L20RpNpxWSwfArIAk2rHK6LH2wtQK4OIN2u4pDJU1WbFdcK0owj3eoEzSgudYORErjPedbvsSrOgA=="],
@ -31,80 +29,34 @@
"@types/bun": ["@types/bun@1.2.13", "", { "dependencies": { "bun-types": "1.2.13" } }, "sha512-u6vXep/i9VBxoJl3GjZsl/BFIsvML8DfVDO0RYLEwtSZSp981kEO1V5NwRcO1CPJ7AmvpbnDCiMKo3JvbDEjAg=="],
"@types/fluent-ffmpeg": ["@types/fluent-ffmpeg@2.1.27", "", { "dependencies": { "@types/node": "*" } }, "sha512-QiDWjihpUhriISNoBi2hJBRUUmoj/BMTYcfz+F+ZM9hHWBYABFAE6hjP/TbCZC0GWwlpa3FzvHH9RzFeRusZ7A=="],
"@types/node": ["@types/node@22.14.0", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA=="],
"@videojs/vhs-utils": ["@videojs/vhs-utils@4.1.1", "", { "dependencies": { "@babel/runtime": "^7.12.5", "global": "^4.4.0" } }, "sha512-5iLX6sR2ownbv4Mtejw6Ax+naosGvoT9kY+gcuHzANyUZZ+4NpeNdKMUhb6ag0acYej1Y7cmr/F2+4PrggMiVA=="],
"agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
"async": ["async@0.2.10", "", {}, "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bun-storage": ["bun-storage@0.2.1", "", {}, "sha512-yEgiKZ38eI8v4KO7mQcsRR7suCv+ZVQmM1uETyWc0CRQgJ8vZqyY6AbQCqlLfQ41EBOHiDvHhTxy3EquZomyZg=="],
"bun-types": ["bun-types@1.2.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-rRjA1T6n7wto4gxhAO/ErZEtOXyEZEmnIHQfl0Dt1QQSB4QV0iP6BZ9/YB5fZaHFQ2dwHFrmPaRQ9GGMX01k9Q=="],
"caseless": ["caseless@0.12.0", "", {}, "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw=="],
"concat-stream": ["concat-stream@2.0.0", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A=="],
"dasha": ["dasha@3.1.6", "", { "dependencies": { "m3u8-parser": "^7.2.0" } }, "sha512-3wAxSibBWzEMRjHCBQoHEd7YyeVbmiaqhDHUbBR6pZ/axOz5fq2jAo9c+QCTrbEnazCAW3bjnbit13v35WiupA=="],
"debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"dom-walk": ["dom-walk@0.1.2", "", {}, "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="],
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"ffmpeg-static": ["ffmpeg-static@5.2.0", "", { "dependencies": { "@derhuerst/http-basic": "^8.2.0", "env-paths": "^2.2.0", "https-proxy-agent": "^5.0.0", "progress": "^2.0.3" } }, "sha512-WrM7kLW+do9HLr+H6tk7LzQ7kPqbAgLjdzNE32+u3Ff11gXt9Kkkd2nusGFrlWMIe+XaA97t+I8JS7sZIrvRgA=="],
"flac-stream-tagger": ["flac-stream-tagger@1.0.10", "", { "dependencies": { "imageinfo": "^1.0.4" } }, "sha512-RqXyWnwx5xGTUuTCxzCCKK085sVxZzVCPwe1LShj1bDAn8zglhYcAPBorL4R0pK6+IIJtJsUuief3AawpNwIjw=="],
"fluent-ffmpeg": ["fluent-ffmpeg@2.1.3", "", { "dependencies": { "async": "^0.2.9", "which": "^1.1.1" } }, "sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q=="],
"global": ["global@4.4.0", "", { "dependencies": { "min-document": "^2.19.0", "process": "^0.11.10" } }, "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w=="],
"http-response-object": ["http-response-object@3.0.2", "", { "dependencies": { "@types/node": "^10.0.3" } }, "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA=="],
"https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
"imageinfo": ["imageinfo@1.0.4", "", {}, "sha512-BJml4q/QCO2187F4UcO/b6hTYIhbq4nnd1XNs65jyCED9em4m6XmeGWDxjewjfJoC7VJABhOdmqb64KA24rLZw=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"m3u8-parser": ["m3u8-parser@7.2.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@videojs/vhs-utils": "^4.1.1", "global": "^4.4.0" } }, "sha512-CRatFqpjVtMiMaKXxNvuI3I++vUumIXVVT/JpCpdU/FynV/ceVw1qpPyyBNindL+JlPMSesx+WX1QJaZEJSaMQ=="],
"min-document": ["min-document@2.19.0", "", { "dependencies": { "dom-walk": "^0.1.0" } }, "sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"parse-cache-control": ["parse-cache-control@1.0.1", "", {}, "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg=="],
"process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="],
"progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="],
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"webview-bun": ["webview-bun@2.4.0", "", {}, "sha512-0+ugnQlcUHmuW+iLeb+Lzb8rGUJh7WEdXvNsuvaVEXT3EagK380XdD7heVJu0Ek/mNxMY3G2JM142YRQ1hDUGQ=="],
"which": ["which@1.3.1", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "which": "./bin/which" } }, "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ=="],
"http-response-object/@types/node": ["@types/node@10.17.60", "", {}, "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw=="],
}
}

View file

@ -1,6 +1,9 @@
import Auth from "./src/helpers/auth";
import Utils from "./src/helpers/utils";
import { createWriteStream } from "fs";
import { utimes } from "fs/promises";
import { Readable } from "stream";
import { Semaphore } from "@inrixia/helpers";
const auth = await Auth()
@ -39,19 +42,15 @@ Bun.serve({
})
}
const { manifestMimeType, manifest } = await utils.fetchTrack(trackId)
const trackData = await utils.fetchTrack(trackId);
const audio = await utils.downloadFlac(manifestMimeType, manifest);
if (audio.mimeType === "audio/flac") {
audio.buffer = Buffer.from(await utils.tagFlac(trackId, audio.buffer))
await Bun.write(`downloaded/${trackId}.flac`, audio.buffer)
} else if (audio.mimeType === "audio/m4a") {
await Bun.write(`downloaded/${trackId}.m4a`, audio.buffer)
}
const stream = await utils.downloadAudio(trackData);
return new Response(audio.buffer, {
const format = trackData.manifestMimeType === "application/vnd.tidal.bts" ? "flac" : "m4a";
return new Response(stream, {
headers: {
"Content-Type": audio.mimeType,
"Content-Type": `audio/${format}`,
"Cache-Control": "public, max-age=31536000",
"ETag": trackId.toString(),
}
@ -138,14 +137,8 @@ const tracks = await utils.fetchTracks() as {
}
}[]
};
/*
for (const track of tracks.items) {
// check if has more than just stereo
if (track.item.artist.handle) {
console.log(track.item.artist.handle);
}
}
*/
/* Remove all tracks that are unavailable*/
let unavailableTracks = tracks.items.filter(track => !track.item.streamReady);
@ -155,7 +148,7 @@ let unavailableTracks = tracks.items.filter(track => !track.item.streamReady);
})
console.log("Removed from favorites", track.item.id)
}*/
/*
Bun.write("unavailable.json", JSON.stringify(unavailableTracks, null, 2))
function findDuplicateTracksByName(tracks: typeof tracks.items) {
@ -235,50 +228,30 @@ duplicates.forEach(({ normalizedTitle, count, duplicates }) => {
});
await Bun.write("duplicates.name.json", JSON.stringify(duplicatesNamesText, null, 2));
*/
const dlSemaphore = new Semaphore(1);
await Promise.all(tracks.items.map(async track => {
dlSemaphore.with(async () => {
try {
const trackData = await utils.fetchTrack(parseInt(track.item.id));
let i = 1;
for await (const track of tracks.items) {
const { id } = track.item
const createdAt = new Date(track.created)
const stream = await utils.downloadAudio(trackData);
const trackId = parseInt(id)
const format = trackData.manifestMimeType === "application/vnd.tidal.bts" ? "flac" : "m4a";
if (await Bun.file(`downloaded/${trackId}.flac`).exists() || await Bun.file(`downloaded/${trackId}.m4a`).exists() || await Bun.file(`downloaded/${trackId}.unknown`).exists()) {
//console.log(`Already downloaded ${trackId}.flac`)
i++;
continue
}
const writeStream = createWriteStream(`downloaded/${track.item.id}.${format}`, {
flags: "w"
});
const trackData = await utils.fetchTrack(trackId)
if (trackData?.status === 404 || trackData === null) {
console.error(`track ${trackId} not available`)
i++;
continue
}
try {
const audio = await utils.downloadFlac(trackData.manifestMimeType, trackData.manifest)
stream.pipe(writeStream);
const format = audio.mimeType.split("/")[1];
const name = `downloaded/${trackId}.${format}`
if (audio.mimeType === "audio/flac") {
const taggedAudio = await utils.tagFlac(trackId, audio.buffer)
await Bun.write(name, taggedAudio)
} else {
await Bun.write(name, audio.buffer)
writeStream.on("finish", () => {
console.log(`Downloaded ${track.item.id}.${format}`);
});
} catch (e) {
console.error(`Failed to download ${track.item.id}`, e);
}
await utimes(name, createdAt, createdAt)
//console.log(`Downloaded ${trackId}.flac`)
} catch (e) {
console.error(`Failed to download ${trackId}.flac`, e)
}
console.log(`Downloaded track ${i} of ${tracks.items.length}`)
i++;
}
console.log("Done")
})
}))

View file

@ -1,6 +1,6 @@
{
"name": "bun-react-template",
"version": "0.1.0",
"version": "2.0.0",
"private": true,
"main": "index.ts",
"scripts": {
@ -8,13 +8,11 @@
"start": "bun index.ts"
},
"dependencies": {
"@inrixia/helpers": "^3.20.0",
"@tidal-music/auth": "^1.3.4",
"@types/fluent-ffmpeg": "^2.1.27",
"bun-storage": "^0.2.1",
"dasha": "^3.1.6",
"ffmpeg-static": "^5.2.0",
"flac-stream-tagger": "^1.0.10",
"fluent-ffmpeg": "^2.1.3",
"webview-bun": "^2.4.0"
},
"devDependencies": {

View file

@ -0,0 +1,65 @@
import { rejectNotOk } from "@inrixia/helpers";
import type { Decipher } from "crypto";
export type FetchProgress = { readonly total: number; readonly downloaded: number };
export type TrackStreamOptions = {
progress?: FetchProgress;
bytesWanted?: number;
decipher?: Decipher;
};
const parseTotal = (headers: Response["headers"]) => {
const contentRange = headers.get("content-range");
if (contentRange !== null) {
// Server supports byte range, parse total file size from header
const match = /\/(\d+)$/.exec(contentRange);
if (match) return parseInt(match[1], 10);
} else {
const contentLength = headers.get("content-length");
if (contentLength !== null) return parseInt(contentLength, 10);
}
return 0;
};
export async function* fetchStream(urls: string[], options: TrackStreamOptions = {}) {
options.progress ??= {
total: 0,
downloaded: 0,
};
// Clear these just to be safe
(options.progress.total as number) = 0;
(options.progress.downloaded as number) = 0;
let { progress, bytesWanted, decipher } = options;
const headers = new Headers();
const partialRequest = bytesWanted !== undefined;
if (urls.length === 1 && partialRequest) headers.set("Range", `bytes=0-${bytesWanted}`);
try {
for (const url of urls) {
const res = await fetch(url, { headers }).then(rejectNotOk);
(progress.total as number) += parseTotal(res.headers);
const reader = res.body!.getReader();
while (true) {
let { done, value } = await reader.read();
if (done) break;
if (decipher) value = decipher.update(value!);
(progress.downloaded as number) += value!.length;
yield value;
if (partialRequest && progress.downloaded >= bytesWanted) return reader.cancel();
}
if (partialRequest && progress.downloaded! >= bytesWanted) return;
}
} finally {
if (decipher) {
const decipherEnd = decipher.final();
(progress.downloaded as number) += decipherEnd.length;
yield decipherEnd;
}
}
}

View file

@ -1,12 +1,15 @@
import { credentialsProvider } from "@tidal-music/auth";
import { FlacStreamTagger } from "flac-stream-tagger";
import Ffmpeg from "fluent-ffmpeg";
import ffmpegPath from "ffmpeg-static";
import { FlacStreamTagger, PictureType } from "flac-stream-tagger";
import { parse } from "dasha";
import { makeDecipher } from "./decrypt";
import { Readable } from "stream";
import { rejectNotOk } from "@inrixia/helpers";
import { makeDecipher } from "./decrypt";
import { fetchStream } from "./fetchStream";
Ffmpeg.setFfmpegPath(ffmpegPath as string);
export default class {
private _authHeaders: { [key: string]: string }
@ -31,24 +34,14 @@ export default class {
parsedUrl.searchParams.set("locale", this._userLocale);
parsedUrl.searchParams.set("deviceType", "DESKTOP");
const response = await fetch(parsedUrl, {
const response = rejectNotOk(await fetch(parsedUrl, {
headers: {
...this._authHeaders,
"x-tidal-token": "mhPVJJEBNRzVjr2p",
},
referrer: "https://desktop.tidal.com/",
...opts
})
if (!response.ok) {
//throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}\n${await response.text()}`);
return {
status: response.status,
statusText: response.statusText,
url: parsedUrl.toString(),
error: await response.text(),
}
}
}))
return await response.json() as Object;
}
@ -89,51 +82,47 @@ export default class {
return tracks;
}
async downloadFlac(manifestMimeType: string, manifest: string) {
if (manifestMimeType === "application/dash+xml") {
const parsedManifest = await parse(Buffer.from(manifest, "base64").toString("utf-8"), "https://sp-ad-cf.audio.tidal.com")
async downloadAudio(trackData: {
manifestMimeType: string,
manifest: string,
trackId: number,
}) {
const segmentBuffers = [];
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)
switch (trackData.manifestMimeType) {
case "application/vnd.tidal.bts": {
const parsedManifest = JSON.parse(Buffer.from(trackData.manifest, "base64").toString("utf-8")) as {
"mimeType": string,
"codecs": string,
"encryptionType": string,
"keyId": string,
"urls": string[]
}
const stream = Readable.from(fetchStream(parsedManifest.urls, {
decipher: makeDecipher(parsedManifest)
}))
if (parsedManifest.mimeType !== "audio/flac") return stream;
return stream.pipe(
new FlacStreamTagger(await this.makeTags(trackData.trackId))
);
}
case "application/dash+xml": {
const parsedManifest = await parse(Buffer.from(trackData.manifest, "base64").toString("utf-8"), "https://sp-ad-cf.audio.tidal.com")
return Readable.from(fetchStream(parsedManifest.tracks.all[0].segments.map((s) => s.url)))
}
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[]
default: {
throw new Error(`Unsupported Stream Info manifest mime type: ${trackData.manifestMimeType}`);
}
const decryptor = makeDecipher({ keyId: data.keyId, encryptionType: data.encryptionType });
const flac = await fetch(data.urls[0])
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: "audio/unknown" } // TODO: Handle other mime types
}
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 {
async makeTags(id: number) {
const trackData = await this.fetch(`/tracks/${id}`) as {
title: string,
replayGain: number,
peak: number,
@ -157,12 +146,7 @@ export default class {
status?: string,
};
const albumReq = await fetch(`https://desktop.tidal.com/v1/albums/${track.album.id}/?countryCode=US`, {
headers: this._authHeaders
})
const album = await albumReq.json() as {
const albumData = await this.fetch(`/albums/${trackData.album.id}`) as {
title: string,
numberOfTracks: number,
releaseDate: string,
@ -173,35 +157,35 @@ export default class {
name: string,
}
],
};
const albumArtReq = await fetch(`https://resources.tidal.com/images/${albumData.cover.replaceAll("-", "/") || ""}/1280x1280.jpg`)
const picture = {
buffer: Buffer.from(await albumArtReq.arrayBuffer()),
type: PictureType.FrontCover,
}
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)), {
return {
tagMap: {
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] || "",
title: trackData?.title || "",
trackNumber: (trackData?.trackNumber || 0).toString(),
discNumber: (trackData?.volumeNumber || 0).toString(),
bpm: (trackData?.bpm || 0).toString(),
date: trackData?.streamStartDate || "",
copyright: trackData?.copyright || "",
REPLAYGAIN_TRACK_GAIN: (trackData?.replayGain || 0).toString(),
REPLAYGAIN_TRACK_PEAK: (trackData?.peak || 0).toString(),
comment: trackData?.url || "",
isrc: trackData?.isrc || "",
upc: albumData?.upc || "",
artist: trackData?.artists.map((a) => a.name) || [],
album: albumData?.title || "",
albumArtist: albumData?.artists.map((a) => a.name) || [],
totalTracks: (albumData?.numberOfTracks || 0).toString(),
year: albumData?.releaseDate.split("-")[0] || "",
},
picture: {
buffer: Buffer.from(albumArt),
}
}).toBuffer()
picture
}
}
}