From 0eb6c15057c588db1f355f9496bb6a43bcb217f1 Mon Sep 17 00:00:00 2001 From: seth Date: Fri, 23 May 2025 00:42:45 -0400 Subject: [PATCH] v2 --- bun.lock | 54 +------------ index.ts | 89 ++++++++------------- package.json | 6 +- src/helpers/fetchStream.ts | 65 ++++++++++++++++ src/helpers/utils.ts | 154 +++++++++++++++++-------------------- 5 files changed, 170 insertions(+), 198 deletions(-) create mode 100644 src/helpers/fetchStream.ts diff --git a/bun.lock b/bun.lock index 0bfe757..a13d184 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], } } diff --git a/index.ts b/index.ts index 32095dc..a73c4df 100644 --- a/index.ts +++ b/index.ts @@ -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") \ No newline at end of file + }) +})) \ No newline at end of file diff --git a/package.json b/package.json index 2a20bf1..7f5ac36 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/helpers/fetchStream.ts b/src/helpers/fetchStream.ts new file mode 100644 index 0000000..8caca53 --- /dev/null +++ b/src/helpers/fetchStream.ts @@ -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; + } + } +} \ No newline at end of file diff --git a/src/helpers/utils.ts b/src/helpers/utils.ts index ce1fe52..f3f43e8 100644 --- a/src/helpers/utils.ts +++ b/src/helpers/utils.ts @@ -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) { - 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 + } } - } \ No newline at end of file