v2
This commit is contained in:
parent
f9b116af51
commit
0eb6c15057
5 changed files with 170 additions and 198 deletions
54
bun.lock
54
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=="],
|
||||
}
|
||||
}
|
||||
|
|
89
index.ts
89
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")
|
||||
})
|
||||
}))
|
|
@ -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": {
|
||||
|
|
65
src/helpers/fetchStream.ts
Normal file
65
src/helpers/fetchStream.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue