From 36af6bf4539721a6744b3b9c6b34f6bbfb14dddc Mon Sep 17 00:00:00 2001 From: djelvigilante Date: Mon, 13 Jul 2026 20:55:48 -0400 Subject: [PATCH 1/3] lib: remux and tag DASH (m4a) downloads FLAC downloads are tagged in-flight by the FlacStreamTagger, but DASH downloads are written to disk as a raw concatenation of the CMAF segments: a fragmented MP4 with no tags, which most taggers and some players cannot handle. After a DASH download finishes, remux it into a standard progressive MP4 using mediabunny (packets are copied bit-identical, no re-encode) and write the same tags makeTags already builds (title, artists, album, cover art, etc.) with node-taglib-sharp. Both are pure JS, no ffmpeg or native binaries. The remux preserves the AAC encoder delay: the source edit list is replicated in the output (media_time + presentation duration), so the decoded audio stays sample-identical to the original stream. An empty udta/meta/ilst skeleton is also written because node-taglib-sharp crashes saving MP4 files that have no Apple ilst box. Co-Authored-By: Claude Fable 5 --- plugins/lib/package.json | 2 + .../MediaItem/MediaItem.download.native.ts | 8 +- .../MediaItem.finalizeDash.native.ts | 284 ++++++++++++++++++ pnpm-lock.yaml | 82 ++++- 4 files changed, 371 insertions(+), 5 deletions(-) create mode 100644 plugins/lib/src/classes/MediaItem/MediaItem.finalizeDash.native.ts diff --git a/plugins/lib/package.json b/plugins/lib/package.json index 49b1dea6..1ae6b7ed 100644 --- a/plugins/lib/package.json +++ b/plugins/lib/package.json @@ -9,7 +9,9 @@ "exports": "./src/index.ts", "dependencies": { "dasha": "3.1.9", + "mediabunny": "^1.50.8", "music-metadata": "^11.12.3", + "node-taglib-sharp": "^6.0.3", "sanitize-filename": "^1.6.3" }, "devDependencies": { diff --git a/plugins/lib/src/classes/MediaItem/MediaItem.download.native.ts b/plugins/lib/src/classes/MediaItem/MediaItem.download.native.ts index 2a9cf8b9..fad98540 100644 --- a/plugins/lib/src/classes/MediaItem/MediaItem.download.native.ts +++ b/plugins/lib/src/classes/MediaItem/MediaItem.download.native.ts @@ -11,6 +11,7 @@ import type { redux } from "@luna/lib"; import { Semaphore } from "@inrixia/helpers"; import type { PlaybackInfo } from "../../helpers"; +import { finalizeDashDownload } from "./MediaItem.finalizeDash.native"; import type { MetaTags } from "./MediaItem.tags"; const fileExists = async (path: string): Promise => { @@ -34,7 +35,8 @@ export const download = async (playbackInfo: PlaybackInfo, path: string | string if (await fileExists(path)) return; const parsedPath = parse(path); await mkdir(parsedPath.dir, { recursive: true }); - const writeStream = createWriteStream(join(parsedPath.dir, sanitize(parsedPath.base))); + const filePath = join(parsedPath.dir, sanitize(parsedPath.base)); + const writeStream = createWriteStream(filePath); const progress = { total: 0, downloaded: 0 }; const stream = await fetchMediaItemStream(playbackInfo, { @@ -48,6 +50,10 @@ export const download = async (playbackInfo: PlaybackInfo, path: string | string stream.pipe(writeStream).on("finish", resolve).on("error", reject); await promise; + + // FLAC streams are tagged in-flight, but DASH streams are written as raw + // fragmented MP4: remux to a standard MP4 and write the tags post-download + if (playbackInfo.manifestMimeType === "application/dash+xml") await finalizeDashDownload(filePath, tags); } finally { delete downloads[playbackInfo.trackId]; } diff --git a/plugins/lib/src/classes/MediaItem/MediaItem.finalizeDash.native.ts b/plugins/lib/src/classes/MediaItem/MediaItem.finalizeDash.native.ts new file mode 100644 index 00000000..6c3140d8 --- /dev/null +++ b/plugins/lib/src/classes/MediaItem/MediaItem.finalizeDash.native.ts @@ -0,0 +1,284 @@ +import { ALL_FORMATS, BufferSource, BufferTarget, Conversion, Input, Mp4OutputFormat, Output } from "mediabunny"; +import { ByteVector, File as TagFile, Picture, PictureType } from "node-taglib-sharp"; + +import { readFile, rename, writeFile } from "fs/promises"; + +import type { MetaTags } from "./MediaItem.tags"; + +/** + * DASH streams are downloaded by concatenating the raw CMAF segments, which produces a + * fragmented MP4 that most taggers (and some players) can't handle. This module remuxes + * the download into a standard progressive MP4 - preserving the AAC encoder delay + * (edit list) so the audio stays sample-identical - and then writes the tags. + */ + +// #region MP4 box helpers +type Box = { type: string; start: number; size: number }; + +/** Iterate boxes in buf between [start, end) */ +const listBoxes = (buf: Buffer, start: number, end: number): Box[] => { + const boxes: Box[] = []; + let pos = start; + while (pos + 8 <= end) { + let size: number = buf.readUInt32BE(pos); + if (size === 1) size = Number(buf.readBigUInt64BE(pos + 8)); + else if (size === 0) size = end - pos; + boxes.push({ type: buf.toString("latin1", pos + 4, pos + 8), start: pos, size }); + pos += size; + } + return boxes; +}; + +const findBox = (buf: Buffer, start: number, end: number, type: string): Box | undefined => + listBoxes(buf, start, end).find((box) => box.type === type); + +/** Descend a path of container boxes, e.g. ["mdia", "minf", "stbl"] */ +const findPath = (buf: Buffer, start: number, end: number, path: string[]): Box | undefined => { + let box: Box | undefined = undefined; + for (const type of path) { + box = findBox(buf, start, end, type); + if (box === undefined) return undefined; + start = box.start + 8; + end = box.start + box.size; + } + return box; +}; + +/** Read the first elst entry's media_time (the AAC encoder delay) from the source, if any */ +const readMediaTime = (buf: Buffer): number | undefined => { + const moov = findBox(buf, 0, buf.length, "moov"); + if (moov === undefined) return undefined; + const elst = findPath(buf, moov.start + 8, moov.start + moov.size, ["trak", "edts", "elst"]); + if (elst === undefined) return undefined; + const version = buf.readUInt8(elst.start + 8); + const entryCount = buf.readUInt32BE(elst.start + 12); + if (entryCount < 1) return undefined; + const mediaTime = version === 1 ? Number(buf.readBigInt64BE(elst.start + 16 + 8)) : buf.readInt32BE(elst.start + 16 + 4); + return mediaTime > 0 ? mediaTime : undefined; +}; + +type FullBoxTimes = { version: number; timescale?: number; duration: number; durationOffset: number }; + +/** Read timescale+duration (and the duration field offset) from an mvhd/tkhd/mdhd fullbox */ +const readTimes = (buf: Buffer, box: Box): FullBoxTimes => { + const version = buf.readUInt8(box.start + 8); + const base = box.start + 12; + if (box.type === "mvhd" || box.type === "mdhd") { + const timescaleOffset = version === 1 ? base + 16 : base + 8; + const durationOffset = timescaleOffset + 4; + return { + version, + timescale: buf.readUInt32BE(timescaleOffset), + duration: version === 1 ? Number(buf.readBigUInt64BE(durationOffset)) : buf.readUInt32BE(durationOffset), + durationOffset, + }; + } + // tkhd: creation, modification, track_id, reserved, duration + const durationOffset = version === 1 ? base + 24 : base + 16; + return { + version, + duration: version === 1 ? Number(buf.readBigUInt64BE(durationOffset)) : buf.readUInt32BE(durationOffset), + durationOffset, + }; +}; + +const writeDuration = (buf: Buffer, times: FullBoxTimes, value: number): void => { + if (times.version === 1) buf.writeBigUInt64BE(BigInt(value), times.durationOffset); + else buf.writeUInt32BE(value, times.durationOffset); +}; + +/** edts { elst v0 [ segment_duration, media_time, rate 1.0 ] } */ +const buildEdts = (segmentDuration: number, mediaTime: number): Buffer => { + const edts = Buffer.alloc(36); + edts.writeUInt32BE(36, 0); + edts.write("edts", 4, "latin1"); + edts.writeUInt32BE(28, 8); + edts.write("elst", 12, "latin1"); + edts.writeUInt32BE(0, 16); // version 0, flags 0 + edts.writeUInt32BE(1, 20); // entry_count + edts.writeUInt32BE(segmentDuration, 24); + edts.writeInt32BE(mediaTime, 28); + edts.writeUInt16BE(1, 32); // media_rate_integer + edts.writeUInt16BE(0, 34); // media_rate_fraction + return edts; +}; + +/** + * Empty udta > meta > hdlr(mdir/appl) > ilst skeleton. + * node-taglib-sharp (up to at least 6.0.3) crashes saving a file with no Apple ilst box + * (Mpeg4File.save dereferences an empty IsoUserDataBox's parentTree), so give it one to edit in place. + */ +const buildUdta = (): Buffer => { + const udta = Buffer.alloc(61); + udta.writeUInt32BE(61, 0); + udta.write("udta", 4, "latin1"); + udta.writeUInt32BE(53, 8); + udta.write("meta", 12, "latin1"); + udta.writeUInt32BE(0, 16); // meta version/flags + udta.writeUInt32BE(33, 20); + udta.write("hdlr", 24, "latin1"); + udta.writeUInt32BE(0, 28); // hdlr version/flags + udta.writeUInt32BE(0, 32); // pre_defined + udta.write("mdir", 36, "latin1"); + udta.write("appl", 40, "latin1"); + // 8 reserved bytes + 1 empty name byte, already zeroed + udta.writeUInt32BE(8, 53); + udta.write("ilst", 57, "latin1"); + return udta; +}; + +/** + * Insert an edts/elst into the muxed output's trak replicating the source's encoder delay + * (shrinking mvhd/tkhd to the trimmed presentation duration), and append the empty udta. + * The muxer puts moov before mdat, so stco/co64 chunk offsets are shifted by the inserted bytes. + */ +const patchMoov = (buf: Buffer, mediaTime?: number): Buffer => { + const moov = findBox(buf, 0, buf.length, "moov"); + const mdat = findBox(buf, 0, buf.length, "mdat"); + if (moov === undefined || mdat === undefined) throw new Error("Muxed file is missing moov/mdat"); + + const trak = findBox(buf, moov.start + 8, moov.start + moov.size, "trak"); + const tkhd = trak && findBox(buf, trak.start + 8, trak.start + trak.size, "tkhd"); + const mvhd = findBox(buf, moov.start + 8, moov.start + moov.size, "mvhd"); + const mdhd = trak && findPath(buf, trak.start + 8, trak.start + trak.size, ["mdia", "mdhd"]); + if (!trak || !tkhd || !mvhd || !mdhd) throw new Error("Muxed file is missing moov children"); + + let edts: Buffer | undefined; + if (mediaTime !== undefined && findBox(buf, trak.start + 8, trak.start + trak.size, "edts") === undefined) { + const movie = readTimes(buf, mvhd); + const media = readTimes(buf, mdhd); + const track = readTimes(buf, tkhd); + // Presentation duration = media duration minus the encoder delay, in movie timescale + const presentation = Math.round(((media.duration - mediaTime) * movie.timescale!) / media.timescale!); + if (presentation > 0 && presentation <= 0xffffffff) { + writeDuration(buf, movie, presentation); + writeDuration(buf, track, presentation); + edts = buildEdts(presentation, mediaTime); + } + } + const udta = buildUdta(); + const inserted = (edts?.length ?? 0) + udta.length; + + // Growing moov shifts mdat: fix up chunk offsets + if (moov.start < mdat.start) { + const stbl = findPath(buf, trak.start + 8, trak.start + trak.size, ["mdia", "minf", "stbl"]); + const table = stbl && (findBox(buf, stbl.start + 8, stbl.start + stbl.size, "stco") ?? findBox(buf, stbl.start + 8, stbl.start + stbl.size, "co64")); + if (table === undefined) throw new Error("Muxed file is missing stco/co64"); + const entryCount = buf.readUInt32BE(table.start + 12); + for (let i = 0; i < entryCount; i++) { + if (table.type === "stco") { + const at = table.start + 16 + i * 4; + buf.writeUInt32BE(buf.readUInt32BE(at) + inserted, at); + } else { + const at = table.start + 16 + i * 8; + buf.writeBigUInt64BE(buf.readBigUInt64BE(at) + BigInt(inserted), at); + } + } + } + + // edts goes right after tkhd, udta at the end of moov; both grow moov + const edtsAt = tkhd.start + tkhd.size; + const udtaAt = moov.start + moov.size; + buf.writeUInt32BE(moov.size + inserted, moov.start); + if (edts !== undefined) buf.writeUInt32BE(trak.size + edts.length, trak.start); + return Buffer.concat(edts !== undefined ? [buf.subarray(0, edtsAt), edts, buf.subarray(edtsAt, udtaAt), udta, buf.subarray(udtaAt)] : [buf.subarray(0, udtaAt), udta, buf.subarray(udtaAt)]); +}; +// #endregion + +// #region Tagging +const first = (value: string | string[] | undefined): string | undefined => (Array.isArray(value) ? value[0] : value); +const asArray = (value: string | string[] | undefined): string[] => + value === undefined ? [] : (Array.isArray(value) ? value : [value]).filter((entry) => entry !== ""); +const asInt = (value: string | undefined): number | undefined => { + if (value === undefined) return undefined; + const parsed = parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : undefined; +}; +const yearFrom = (tags: MetaTags["tags"]): number | undefined => { + const year = asInt(first(tags.year)); + if (year !== undefined && year > 0 && year <= 9999) return year; + const date = first(tags.date); + if (date !== undefined && date.length >= 4) { + const dateYear = asInt(date.slice(0, 4)); + if (dateYear !== undefined && dateYear > 0 && dateYear <= 9999) return dateYear; + } + return undefined; +}; + +const writeTags = async (path: string, { tags, coverUrl }: MetaTags): Promise => { + // Fetch the cover before opening the file so a network failure can't interrupt the save + let cover: Picture | undefined; + if (coverUrl !== undefined) { + try { + const res = await fetch(coverUrl); + if (res.ok) { + cover = Picture.fromData(ByteVector.fromByteArray(Buffer.from(await res.arrayBuffer()))); + cover.type = PictureType.FrontCover; + cover.mimeType = res.headers.get("content-type") ?? "image/jpeg"; + } + } catch {} + } + + let file: TagFile | undefined; + try { + file = TagFile.createFromPath(path); + const tag = file.tag; + + const title = first(tags.title); + if (title !== undefined) tag.title = title; + const performers = asArray(tags.artist); + if (performers.length > 0) tag.performers = performers; + const albumArtists = asArray(tags.albumArtist); + if (albumArtists.length > 0) tag.albumArtists = albumArtists; + const album = first(tags.album); + if (album !== undefined) tag.album = album; + const year = yearFrom(tags); + if (year !== undefined) tag.year = year; + const copyright = first(tags.copyright); + if (copyright !== undefined) tag.copyright = copyright; + const comment = first(tags.comment); + if (comment !== undefined) tag.comment = comment; + const genres = asArray(tags.genres); + if (genres.length > 0) tag.genres = genres; + const trackNumber = asInt(first(tags.trackNumber)); + if (trackNumber !== undefined) tag.track = trackNumber; + const totalTracks = asInt(first(tags.totalTracks)); + if (totalTracks !== undefined) tag.trackCount = totalTracks; + const discNumber = asInt(first(tags.discNumber)); + if (discNumber !== undefined) tag.disc = discNumber; + const bpm = asInt(first(tags.bpm)); + if (bpm !== undefined) tag.beatsPerMinute = bpm; + const lyrics = first(tags.lyrics); + if (lyrics !== undefined) tag.lyrics = lyrics; + const isrc = first(tags.isrc); + if (isrc !== undefined) tag.isrc = isrc; + const musicBrainzTrackId = first(tags.musicbrainz_trackid); + if (musicBrainzTrackId !== undefined) tag.musicBrainzTrackId = musicBrainzTrackId; + const musicBrainzAlbumId = first(tags.musicbrainz_albumid); + if (musicBrainzAlbumId !== undefined) tag.musicBrainzReleaseId = musicBrainzAlbumId; + if (cover !== undefined) tag.pictures = [cover]; + + file.save(); + } finally { + file?.dispose(); + } +}; +// #endregion + +/** Remux a raw DASH (fragmented MP4) download into a standard progressive MP4 and tag it */ +export const finalizeDashDownload = async (path: string, tags?: MetaTags): Promise => { + const source = await readFile(path); + + const input = new Input({ formats: ALL_FORMATS, source: new BufferSource(source) }); + const output = new Output({ format: new Mp4OutputFormat(), target: new BufferTarget() }); + await (await Conversion.init({ input, output })).execute(); + + const muxed = patchMoov(Buffer.from(output.target.buffer!), readMediaTime(source)); + + // Replace the raw download only once the remux fully succeeded + const tmpPath = `${path}.tmp`; + await writeFile(tmpPath, muxed); + await rename(tmpPath, path); + + if (tags !== undefined) await writeTags(path, tags); +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d22b0f3b..0e1cb084 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -79,9 +79,15 @@ importers: dasha: specifier: 3.1.9 version: 3.1.9 + mediabunny: + specifier: ^1.50.8 + version: 1.50.8 music-metadata: specifier: ^11.12.3 version: 11.12.3 + node-taglib-sharp: + specifier: ^6.0.3 + version: 6.0.3 sanitize-filename: specifier: ^1.6.3 version: 1.6.3 @@ -226,28 +232,24 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [musl] '@biomejs/cli-linux-arm64@1.9.4': resolution: {integrity: sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [glibc] '@biomejs/cli-linux-x64-musl@1.9.4': resolution: {integrity: sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [musl] '@biomejs/cli-linux-x64@1.9.4': resolution: {integrity: sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [glibc] '@biomejs/cli-win32-arm64@1.9.4': resolution: {integrity: sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==} @@ -615,6 +617,12 @@ packages: '@types/clean-css@4.2.11': resolution: {integrity: sha512-Y8n81lQVTAfP2TOdtJJEsCoYl1AnOkqDqMvXb9/7pfgZZ7r8YrEyurrAvAoAjHOGXKRybay+5CsExqIH6liccw==} + '@types/dom-mediacapture-transform@0.1.12': + resolution: {integrity: sha512-d7/QsLRwF864A5mgIM/YrfiglHoYn7zgCcAoJgW404r+2DwnNr7EBbLnCWpmOMgH8y0te73L1AV6H1bmauaWFw==} + + '@types/dom-webcodecs@0.1.13': + resolution: {integrity: sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==} + '@types/html-minifier-terser@7.0.2': resolution: {integrity: sha512-mm2HqV22l8lFQh4r2oSsOEVea+m0qqxEmwpc9kC1p/XzmjLWrReR9D/GRs8Pex2NX/imyEH9c5IU/7tMBQCHOA==} @@ -923,6 +931,10 @@ packages: resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==} engines: {node: '>=10.19.0'} + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + idb-keyval@6.2.2: resolution: {integrity: sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==} @@ -946,6 +958,10 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + invert-kv@3.0.1: + resolution: {integrity: sha512-CYdFeFexxhv/Bcny+Q0BfOV+ltRlJcd4BBZBYFX/O0u4npJrgZtIcjokegtiSMAvlMTJ+Koq0GBCc//3bueQxw==} + engines: {node: '>=8'} + is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} @@ -986,6 +1002,10 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + lcid@3.1.1: + resolution: {integrity: sha512-M6T051+5QCGLBQb8id3hdvIW8+zeFV2FyBGFS9IEK5H9Wt4MueD4bW1eWikpHgZp+5xR3l5c8pZUkQsIA0BFZg==} + engines: {node: '>=8'} + lie@3.3.0: resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} @@ -1025,6 +1045,9 @@ packages: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} + mediabunny@1.50.8: + resolution: {integrity: sha512-LgykLyQzhdpo0V2yw3UXmOpj+b4JAGdpHBwsPE6kjSt8Za0d1VllD+FV7EGHBcdV4+oHUAo+yrqbVAWxNSDCPQ==} + mime@4.1.0: resolution: {integrity: sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==} engines: {node: '>=16'} @@ -1066,6 +1089,10 @@ packages: no-case@3.0.4: resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + node-taglib-sharp@6.0.3: + resolution: {integrity: sha512-dT5G3wbPtwCnG1j3Rrts7LlyX5r5Xo0A2G/omt7akrcbSm6iPGrNgjdwG/KlBKbiJjUj4umr7qak9jDZOBvFJg==} + engines: {node: '>=12.16.1'} + normalize-url@6.1.0: resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} engines: {node: '>=10'} @@ -1087,6 +1114,10 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + os-locale@6.0.2: + resolution: {integrity: sha512-qIb8bzRqaN/vVqEYZ7lTAg6PonskO7xOmM7OClD28F6eFa4s5XGe4bGpHUHMoCHbNNuR0pDYFeSLiW5bnjWXIA==} + engines: {node: '>=12.20'} + p-cancelable@2.1.1: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} engines: {node: '>=8'} @@ -1222,6 +1253,9 @@ packages: safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sanitize-filename@1.6.3: resolution: {integrity: sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==} @@ -1364,6 +1398,11 @@ packages: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + voby@0.54.0: resolution: {integrity: sha512-hH8LXcP0QR4J6nnzfSWGD13onq7hqdc/ya1r1Bb+TxibtECYKZRO1m4HdSU4RcE7MdAaQwR5pwgd+hqXCqk2OA==} @@ -1795,6 +1834,12 @@ snapshots: '@types/node': 24.10.1 source-map: 0.6.1 + '@types/dom-mediacapture-transform@0.1.12': + dependencies: + '@types/dom-webcodecs': 0.1.13 + + '@types/dom-webcodecs@0.1.13': {} + '@types/html-minifier-terser@7.0.2': {} '@types/http-cache-semantics@4.0.4': {} @@ -2154,6 +2199,10 @@ snapshots: quick-lru: 5.1.1 resolve-alpn: 1.2.1 + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + idb-keyval@6.2.2: {} ieee754@1.2.1: {} @@ -2171,6 +2220,8 @@ snapshots: inherits@2.0.4: {} + invert-kv@3.0.1: {} + is-arrayish@0.2.1: {} is-core-module@2.16.1: @@ -2206,6 +2257,10 @@ snapshots: dependencies: json-buffer: 3.0.1 + lcid@3.1.1: + dependencies: + invert-kv: 3.0.1 + lie@3.3.0: dependencies: immediate: 3.0.6 @@ -2243,6 +2298,11 @@ snapshots: media-typer@1.1.0: {} + mediabunny@1.50.8: + dependencies: + '@types/dom-mediacapture-transform': 0.1.12 + '@types/dom-webcodecs': 0.1.13 + mime@4.1.0: {} mimic-response@1.0.1: {} @@ -2304,6 +2364,12 @@ snapshots: lower-case: 2.0.2 tslib: 2.8.1 + node-taglib-sharp@6.0.3: + dependencies: + iconv-lite: 0.6.3 + os-locale: 6.0.2 + uuid: 8.3.2 + normalize-url@6.1.0: {} object-assign@4.1.1: {} @@ -2319,6 +2385,10 @@ snapshots: dependencies: wrappy: 1.0.2 + os-locale@6.0.2: + dependencies: + lcid: 3.1.1 + p-cancelable@2.1.1: {} package-json-from-dist@1.0.1: {} @@ -2455,6 +2525,8 @@ snapshots: safe-buffer@5.1.2: {} + safer-buffer@2.1.2: {} + sanitize-filename@1.6.3: dependencies: truncate-utf8-bytes: 1.0.2 @@ -2574,6 +2646,8 @@ snapshots: uuid@11.1.0: {} + uuid@8.3.2: {} + voby@0.54.0: dependencies: htm: 3.1.1 From 52f91f5a0f37644a504483ed0d9103fbdbc9b479 Mon Sep 17 00:00:00 2001 From: djelvigilante Date: Mon, 13 Jul 2026 22:26:19 -0400 Subject: [PATCH 2/3] Bump version to 1.14.2-beta Co-Authored-By: Claude Fable 5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3fc91a60..c8cbfe88 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "luna", - "version": "1.14.1-beta", + "version": "1.14.2-beta", "description": "A client mod for the Tidal music app for plugins", "author": { "name": "Inrixia", From adb70d1e7ce9969360ea43d1c6f22050ec619941 Mon Sep 17 00:00:00 2001 From: djelvigilante Date: Mon, 13 Jul 2026 22:29:59 -0400 Subject: [PATCH 3/3] Bump version to 1.15.0-beta Co-Authored-By: Claude Fable 5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c8cbfe88..6789297b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "luna", - "version": "1.14.2-beta", + "version": "1.15.0-beta", "description": "A client mod for the Tidal music app for plugins", "author": { "name": "Inrixia",