From bb570387fd4e5510c268171d10186185293af266 Mon Sep 17 00:00:00 2001 From: seth Date: Sun, 20 Apr 2025 04:53:40 -0400 Subject: [PATCH] Add DASH MPD parsing and MP4 to FLAC conversion functionality --- bun.lock | 8 ++++++++ package.json | 4 +++- src/helpers/utils.ts | 49 ++++++++++++++++++++++++++++++++++++-------- tmp.ts | 12 +++++++++++ 4 files changed, 64 insertions(+), 9 deletions(-) create mode 100644 tmp.ts diff --git a/bun.lock b/bun.lock index f34dff3..c6adb79 100644 --- a/bun.lock +++ b/bun.lock @@ -4,11 +4,13 @@ "": { "name": "bun-react-template", "dependencies": { + "@liveinstantly/dash-mpd-parser": "^0.5.0", "@tidal-music/auth": "^1.3.4", "@types/fluent-ffmpeg": "^2.1.27", "ffmpeg-static": "^5.2.0", "flac-stream-tagger": "^1.0.10", "fluent-ffmpeg": "^2.1.3", + "mp4box": "^0.5.4", }, "devDependencies": { "@types/bun": "latest", @@ -18,6 +20,8 @@ "packages": { "@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=="], + "@liveinstantly/dash-mpd-parser": ["@liveinstantly/dash-mpd-parser@0.5.0", "", { "dependencies": { "xmldom": "^0.6.0" } }, "sha512-ritwVw23l0lh4jeXLOtRitusbbAfg99Ztdz4rBg0HyGTZKwYC46FO8zotQFnZd9Vd+k8u9s90kwhf6PK91msKw=="], + "@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=="], "@tidal-music/common": ["@tidal-music/common@0.1.5", "", { "dependencies": { "@tidal-music/common": "0.1.5" } }, "sha512-C71JfsJ5P1CwObGTOlRH9vDjo4mruV5WeE6C0JmR8eVoivIaf1Ou9QCI7pDa7aWuCUjOOm3Dd5p5MkJ3aCDXHA=="], @@ -62,6 +66,8 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "mp4box": ["mp4box@0.5.4", "", {}, "sha512-GcCH0fySxBurJtvr0dfhz0IxHZjc1RP+F+I8xw+LIwkU1a+7HJx8NCDiww1I5u4Hz6g4eR1JlGADEGJ9r4lSfA=="], + "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=="], @@ -82,6 +88,8 @@ "which": ["which@1.3.1", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "which": "./bin/which" } }, "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ=="], + "xmldom": ["xmldom@0.6.0", "", {}, "sha512-iAcin401y58LckRZ0TkI4k0VSM1Qg0KGSc3i8rU+xrxe19A/BN1zHyVSJY7uoutVlaTSzYyk/v5AmkewAP7jtg=="], + "http-response-object/@types/node": ["@types/node@10.17.60", "", {}, "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw=="], } } diff --git a/package.json b/package.json index c4dc324..1049c86 100644 --- a/package.json +++ b/package.json @@ -8,11 +8,13 @@ "start": "bun index.ts" }, "dependencies": { + "@liveinstantly/dash-mpd-parser": "^0.5.0", "@tidal-music/auth": "^1.3.4", "@types/fluent-ffmpeg": "^2.1.27", "ffmpeg-static": "^5.2.0", "flac-stream-tagger": "^1.0.10", - "fluent-ffmpeg": "^2.1.3" + "fluent-ffmpeg": "^2.1.3", + "mp4box": "^0.5.4" }, "devDependencies": { "@types/bun": "latest" diff --git a/src/helpers/utils.ts b/src/helpers/utils.ts index af4b1b6..fa483e2 100644 --- a/src/helpers/utils.ts +++ b/src/helpers/utils.ts @@ -2,6 +2,8 @@ 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'; + Ffmpeg.setFfmpegPath(ffmpegPath as string); @@ -20,13 +22,33 @@ export default class { await new Promise((resolve, reject) => { Ffmpeg(inputStream) - .audioCodec("flac") .format("flac") .on("error", reject) .on("end", resolve) .pipe() .on("data", chunk => outputChunks.push(chunk)); }); + + return Buffer.concat(outputChunks);; + } + + 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) { @@ -62,19 +84,30 @@ export default class { async downloadFlac(manifestMimeType: string, manifest: string) { const id = Bun.nanoseconds().toString(36); if (manifestMimeType === "application/dash+xml") { - await Bun.write(`tmp/${id}.mpd`, Buffer.from(manifest, "base64").toString("utf-8")) + const mpd = new DashMPD(); + mpd.parse(Buffer.from(manifest, "base64").toString("utf-8")) - await Bun.$`${ffmpegPath} -protocol_whitelist https,file,tls,tcp -i tmp/${id}.mpd -c:a copy tmp/${id}.flac`.quiet() + const Representation = mpd.getJSON().MPD.Period[0].AdaptationSet[0].Representation[0]; + const segmentTemplate = Representation.SegmentTemplate; + const segmentCount = segmentTemplate.SegmentTimeline.S[0]["@r"]; + const segmentBuffers = []; - await Bun.file(`tmp/${id}.mpd`).delete(); + const initialization = await fetch(segmentTemplate["@initialization"]) + const initializationBuffer = await initialization.arrayBuffer() + segmentBuffers.push(Buffer.from(initializationBuffer)); - const flac = Bun.file(`tmp/${id}.flac`) + 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))); + } - const audioBuffer = await flac.arrayBuffer(); + const segmentBuffersArray = await Promise.all(segmentPromises); + segmentBuffers.push(...segmentBuffersArray); - await flac.delete() + const flac = await this.convertMp4ToFlac(Buffer.concat(segmentBuffers)) - return { buffer: audioBuffer, mimeType: "audio/flac" } + return { buffer: flac, mimeType: "audio/flac" } } else if (manifestMimeType === "application/vnd.tidal.bts") { const data = JSON.parse(Buffer.from(manifest, "base64").toString("utf-8")) as { "mimeType": string, diff --git a/tmp.ts b/tmp.ts new file mode 100644 index 0000000..5220a86 --- /dev/null +++ b/tmp.ts @@ -0,0 +1,12 @@ +Bun.serve({ + routes: { + "/test.mpd": async () => { + return new Response(Bun.file("tmp/fpwp4o.mpd"), { + headers: { + // cors allow all + "Access-Control-Allow-Origin": "*", + } + }) + }, + } +}) \ No newline at end of file