diff --git a/.gitignore b/.gitignore index 2246233..1b3c88f 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,5 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json tmp localstorage.json +localStorage.sqlite downloaded \ No newline at end of file diff --git a/aiot-mass-dl.exe b/aiot-mass-dl.exe new file mode 100644 index 0000000..c92f6aa Binary files /dev/null and b/aiot-mass-dl.exe differ diff --git a/bun.lock b/bun.lock index d0914ef..0bfe757 100644 --- a/bun.lock +++ b/bun.lock @@ -7,10 +7,11 @@ "@tidal-music/auth": "^1.3.4", "@types/fluent-ffmpeg": "^2.1.27", "bun-storage": "^0.2.1", - "dasha": "3", + "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": { "@types/bun": "latest", @@ -28,7 +29,7 @@ "@tidal-music/true-time": ["@tidal-music/true-time@0.3.0", "", { "dependencies": { "@tidal-music/true-time": "0.3.0" } }, "sha512-nO9DfLKLu5sCI/v6Yp8DSseu8qis0RI1r6l7oIFCusOAedkIbQT/K+ELaaz7cxXR4cboopUZGW55v4RVwxHE/g=="], - "@types/bun": ["@types/bun@1.2.12", "", { "dependencies": { "bun-types": "1.2.12" } }, "sha512-lY/GQTXDGsolT/TiH72p1tuyUORuRrdV7VwOTOjDOt8uTBJQOJc5zz3ufwwDl0VBaoxotSk4LdP0hhjLJ6ypIQ=="], + "@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=="], @@ -44,13 +45,13 @@ "bun-storage": ["bun-storage@0.2.1", "", {}, "sha512-yEgiKZ38eI8v4KO7mQcsRR7suCv+ZVQmM1uETyWc0CRQgJ8vZqyY6AbQCqlLfQ41EBOHiDvHhTxy3EquZomyZg=="], - "bun-types": ["bun-types@1.2.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-tvWMx5vPqbRXgE8WUZI94iS1xAYs8bkqESR9cxBB1Wi+urvfTrF1uzuDgBHFAdO0+d2lmsbG3HmeKMvUyj6pWA=="], + "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.5", "", { "dependencies": { "m3u8-parser": "^7.2.0" } }, "sha512-Zz/oCfZBPa2g5PlZA7uwfH9B76D0QrX6DYsTFFqBfAuMtB9MBPGOkEkHAbXL9JX7wyU2OiMXYZbMlyUT7uQ9hQ=="], + "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=="], @@ -100,6 +101,8 @@ "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.exe b/index.exe new file mode 100644 index 0000000..a98b46b Binary files /dev/null and b/index.exe differ diff --git a/index.ts b/index.ts index 7b4dcf8..01fc1d2 100644 --- a/index.ts +++ b/index.ts @@ -13,13 +13,26 @@ Bun.serve({ "/api/track/:id": async req => { const trackId = parseInt(req.params.id) - const file = Bun.file(`downloaded/${trackId}.flac`) + const flac = Bun.file(`downloaded/${trackId}.flac`) - if (await file.exists()) { - return new Response(await file.arrayBuffer(), { + if (await flac.exists()) { + return new Response(flac, { headers: { "Content-Type": "audio/flac", - "Content-Disposition": `attachment; filename="${trackId}.flac"`, + //"Content-Disposition": `attachment; filename="${trackId}.flac"`, + "Cache-Control": "public, max-age=31536000", + "ETag": trackId.toString(), + } + }) + } + + const m4a = Bun.file(`downloaded/${trackId}.m4a`) + + if (await m4a.exists()) { + return new Response(m4a, { + headers: { + "Content-Type": "audio/m4a", + //"Content-Disposition": `attachment; filename="${trackId}.m4a"`, "Cache-Control": "public, max-age=31536000", "ETag": trackId.toString(), } @@ -31,13 +44,14 @@ Bun.serve({ 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) } - //await Bun.write(`downloaded/${trackId}.flac`, audio) return new Response(audio.buffer, { headers: { "Content-Type": audio.mimeType, - //"Content-Disposition": `attachment; filename="${trackId}.flac"`, "Cache-Control": "public, max-age=31536000", "ETag": trackId.toString(), } @@ -55,10 +69,169 @@ Bun.serve({ }) } }, - development: true + development: true, + idleTimeout: 255 }) + +const tracks = await utils.fetchTracks() as { + limit: number, + offset: number, + totalNumberOfItems: number, + items: { + created: string, + item: { + id: string + title: string + duration: number, // in seconds + replayGain: number, + peak: number, + allowStreaming: boolean, + streamReady: boolean, + payToStream: boolean, + adSupportedStreamReady: boolean, + djReady: boolean, + stemReady: boolean, + streamStartDate: string, + premiumStreamingOnly: boolean, + trackNumber: number, + volumeNumber: number, // discNumber + version: null, // ? + popularity: number, + copyright: string, + bpm: null, // think this is only provided to DJ users + url: string, + isrc: string, + editable: boolean, + explicit: boolean, + audioQuality: string, + audioModes: "STEREO"[], // todo find other modes + mediaMetadata: { + tags: "LOSSLESS"[], // todo find other tags + }, + upload: boolean, // was uploaded by a user + artist: { + id: number, + name: string, + handle: null, // ? + type: "MAIN", // todo find other types + picture: string, + }, + artists: [ + { + id: number, + name: string, + handle: null, + type: "MAIN", + picture: string, + } + ], + album: { + id: number, + title: string, + cover: string, + vibrantColor: string, + videoCover: null, + }, + mixes: { + TRACK_MIX: string, + }, + } + }[] +}; + +/* Remove all tracks that are unavailable +for (const track of tracks.items) { + if (track.item.streamReady) { + continue + } + + await utils.fetch("/users/199235629/favorites/tracks/" + track.item.id, { + method: "DELETE", + }) + console.log("Removed from favorites", track.item.id) +} +*/ /* -const tracks = await utils.fetchTracks(); +function findDuplicateTracksByName(tracks: typeof tracks.items) { + const seen = new Map(); + + for (const trackObj of tracks) { + const title = trackObj.item.title; + const normalized = title.trim().toLowerCase(); + + if (!seen.has(normalized)) { + seen.set(normalized, { count: 1, originals: [trackObj] }); + } else { + const entry = seen.get(normalized)!; + entry.count++; + entry.originals.push(trackObj); + } + } + + // Filter to only include titles with duplicates + return Array.from(seen.entries()) + .filter(([_, v]) => v.count > 1) + .map(([normalizedTitle, data]) => ({ + normalizedTitle, + count: data.count, + duplicates: data.originals, + })); +} + +function findDuplicateTracksByISRC(tracks: typeof tracks.items) { + const seen = new Map(); + + for (const trackObj of tracks) { + const isrc = trackObj.item.isrc; + + // Skip if ISRC is missing or empty + if (!isrc) continue; + + if (!seen.has(isrc)) { + seen.set(isrc, { count: 1, originals: [trackObj] }); + } else { + const entry = seen.get(isrc)!; + entry.count++; + entry.originals.push(trackObj); + } + } + + // Only return those with duplicates + return Array.from(seen.entries()) + .filter(([_, v]) => v.count > 1) + .map(([isrc, data]) => ({ + name: data.originals[0].item.title, + isrc, + count: data.count, + duplicates: data.originals, + })); +} + +*/ +/* +// Usage: +const duplicatesByISRC = findDuplicateTracksByISRC(tracks.items); +duplicatesByISRC.forEach(({ name, isrc, count, duplicates }) => { + console.log(`Name: ${name} ISRC: "${isrc}" - Count: ${count}`); + duplicates.forEach((trackObj) => { + //console.log(` - Track ID: ${trackObj.item.id}, Created: ${trackObj.created}`); + }); +}); +*/ +/* +// Usage: +const duplicates = findDuplicateTracksByName(tracks.items); + +console.log("Duplicate tracks:"); +duplicates.forEach(({ normalizedTitle, count, duplicates }) => { + console.log(`Title: "${normalizedTitle}" - Count: ${count}`); + duplicates.forEach((trackObj) => { + //console.log(` - Track ID: ${trackObj.item.id}, Created: ${trackObj.created}`); + }); +}); +*/ + + let i = 1; for await (const track of tracks.items) { @@ -99,11 +272,8 @@ for await (const track of tracks.items) { console.error(`Failed to download ${trackId}.flac`, e) } - // Downloaded track 1 of 10, etc - console.log(`Downloaded track ${i} of ${tracks.items.length}`) i++; } -console.log("Done") -*/ \ No newline at end of file +console.log("Done") \ No newline at end of file diff --git a/localStorage.sqlite b/localStorage.sqlite deleted file mode 100644 index 7d40255..0000000 Binary files a/localStorage.sqlite and /dev/null differ diff --git a/package.json b/package.json index c59c592..2a20bf1 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,11 @@ "@tidal-music/auth": "^1.3.4", "@types/fluent-ffmpeg": "^2.1.27", "bun-storage": "^0.2.1", - "dasha": "3", + "dasha": "^3.1.6", "ffmpeg-static": "^5.2.0", "flac-stream-tagger": "^1.0.10", - "fluent-ffmpeg": "^2.1.3" + "fluent-ffmpeg": "^2.1.3", + "webview-bun": "^2.4.0" }, "devDependencies": { "@types/bun": "latest" diff --git a/src/helpers/auth.ts b/src/helpers/auth.ts index a8cb7b9..1281e88 100644 --- a/src/helpers/auth.ts +++ b/src/helpers/auth.ts @@ -2,6 +2,8 @@ import "./localStorage"; import { init, initializeLogin, finalizeLogin, credentialsProvider } from "@tidal-music/auth"; +import { Webview } from "webview-bun"; + const clientId = "mhPVJJEBNRzVjr2p"; export default async () => { @@ -19,9 +21,29 @@ export default async () => { redirectUri: "https://desktop.tidal.com/login/auth" }); - console.log(`Please open ${response} to login.`) + const webview = new Webview(true); - await finalizeLogin(new URL(prompt("Enter the URL you were redirected to: ") || "").search) + webview.navigate(response); + + let didLogin = false; + webview.bind("getURL", async (url: string) => { + if (url.startsWith("https://desktop.tidal.com")) { + const code = new URL(url) + + await finalizeLogin(code.search); + didLogin = true; + } + }); + + webview.init("getURL(location.href)"); + + webview.run() + + while (!didLogin) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + webview.destroy() } return { diff --git a/src/helpers/utils.ts b/src/helpers/utils.ts index 274f5ec..ce54e17 100644 --- a/src/helpers/utils.ts +++ b/src/helpers/utils.ts @@ -24,7 +24,7 @@ export default class { "en_US").replaceAll("-", "_").split(".")[0]; } - async fetch(url: string) { + async fetch(url: string, opts: { [key: string]: any } = {}) { const parsedUrl = new URL(`https://desktop.tidal.com/v1${url}`); parsedUrl.searchParams.set("countryCode", this._userCountry); @@ -37,6 +37,7 @@ export default class { "x-tidal-token": "mhPVJJEBNRzVjr2p", }, referrer: "https://desktop.tidal.com/", + ...opts }) if (!response.ok) { @@ -150,6 +151,7 @@ export default class { status?: string, }; + const albumReq = await fetch(`https://desktop.tidal.com/v1/albums/${track.album.id}/?countryCode=US`, { headers: this._authHeaders }) @@ -170,6 +172,7 @@ export default class { 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 || "",