From 6c097932764d838d6f51812b176f422a64e2473b Mon Sep 17 00:00:00 2001 From: Daniel <790119+DanTheMan827@users.noreply.github.com> Date: Mon, 2 Mar 2026 19:13:18 +0000 Subject: [PATCH 1/3] feat(downloads): add XID ISRC tag to MP4 metadata - Added 'xid ' tag for ISRC in createMp4MetadataAtoms function --- js/metadata.js | 1 + 1 file changed, 1 insertion(+) diff --git a/js/metadata.js b/js/metadata.js index 398d9df..b8272c8 100644 --- a/js/metadata.js +++ b/js/metadata.js @@ -949,6 +949,7 @@ function createMp4MetadataAtoms(track) { if (track.isrc) { tags['ISRC'] = track.isrc; + tags['xid '] = ':isrc:' + track.isrc; } if (track.copyright) { From 82ca593894ff1fc292d5bdfdf6cad223cf716236 Mon Sep 17 00:00:00 2001 From: Daniel <790119+DanTheMan827@users.noreply.github.com> Date: Mon, 2 Mar 2026 21:14:15 +0000 Subject: [PATCH 2/3] fix(downloads): fix malformed m4a user atoms --- js/metadata.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/metadata.js b/js/metadata.js index b8272c8..d0cf66b 100644 --- a/js/metadata.js +++ b/js/metadata.js @@ -1295,7 +1295,7 @@ function createUserAtom(namespace, name, value) { offset += 12; buf.set(nameBytes, offset); offset += nameBytes.length; - writeAtomHeader(buf, offset, valueBytes.length + 12, 'data'); + writeAtomHeader(buf, offset, valueBytes.length + 8, 'data'); offset += 8; buf.set(valueBytes, offset); From 8035dd38736622cce541ff22ecad5e06e3e0eb65 Mon Sep 17 00:00:00 2001 From: Daniel <790119+DanTheMan827@users.noreply.github.com> Date: Mon, 2 Mar 2026 21:22:08 +0000 Subject: [PATCH 3/3] feat(downloads): add BPM tag to Vorbis comments and MP4 metadata - Added 'TEMPO' tag to Vorbis comments if BPM is available - Added 'tmpo' tag to MP4 metadata if BPM is available --- js/metadata.js | 73 +++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 64 insertions(+), 9 deletions(-) diff --git a/js/metadata.js b/js/metadata.js index d0cf66b..7466e64 100644 --- a/js/metadata.js +++ b/js/metadata.js @@ -587,6 +587,12 @@ function createVorbisCommentBlock(track) { if (track.album?.numberOfTracks) { comments.push(['TRACKTOTAL', String(track.album.numberOfTracks)]); } + if (track.bpm != null) { + const bpm = Number(track.bpm); + if (Number.isFinite(bpm)) { + comments.push(['TEMPO', String(Math.round(bpm))]); + } + } if (track.replayGain) { const { albumReplayGain, albumPeakAmplitude, trackReplayGain, trackPeakAmplitude } = track.replayGain; if (albumReplayGain) comments.push(['REPLAYGAIN_ALBUM_GAIN', String(albumReplayGain)]); @@ -974,6 +980,10 @@ function createMp4MetadataAtoms(track) { }; } + if (track.bpm) { + tags['tmpo'] = Math.round(track.bpm); + } + const releaseDateStr = track.album?.releaseDate || (track.streamStartDate ? track.streamStartDate.split('T')[0] : ''); if (releaseDateStr) { @@ -1127,7 +1137,9 @@ function createMetadataBlock(metadataAtoms) { if (key === 'trkn' || key === 'disk') { ilstChildren.push(createIntAtom(key, value)); } else if (key === 'rtng') { - ilstChildren.push(createRatingAtom(value)); + ilstChildren.push(createUintAtom(key, value, 1)); + } else if (key === 'tmpo') { + ilstChildren.push(createUintAtom(key, value, 2)); } else { ilstChildren.push(createStringAtom(key, value)); } @@ -1303,27 +1315,70 @@ function createUserAtom(namespace, name, value) { } /** - * Constructs an MP4 `rtng` metadata atom that encodes an explicit-content rating. + * Converts a number or BigInt value to a big-endian byte array. + * @param {number|BigInt|null} value - The value to convert to bytes. If null, returns null. + * @param {number|null} [byteLength=null] - Optional fixed byte length. If provided, the result will be padded or truncated to this length. If not provided, returns the minimal byte representation. + * @returns {Uint8Array} A Uint8Array representing the value in big-endian format, or null if value is null. + * @throws {Error} If the value is a negative number. + * @example + * // Variable length (minimal bytes) + * toBigEndianBytes(256); // Uint8Array [ 1, 0 ] + * toBigEndianBytes(0); // Uint8Array [ 0 ] * - * @param {number} value - The rating to embed (0 = Unrated, 1 = Explicit, 2 = Clean). - * @returns {Uint8Array} The serialized atom buffer ready to be inserted into metadata. + * // Fixed length with padding + * toBigEndianBytes(1, 4); // Uint8Array [ 0, 0, 0, 1 ] + * + * // With BigInt + * toBigEndianBytes(0xDEADBEEFn, 4); // Uint8Array [ 222, 173, 190, 239 ] */ -function createRatingAtom(value) { - const dataSize = 17; // 8 (data atom header) + 8 (flags/null) + Rating +function toBigEndianBytes(value, byteLength = null) { + if (value == null) return new Uint8Array(0); + + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error('Value must be a non-negative safe integer.'); + } + + // Fixed-length mode + if (byteLength != null) { + const bytes = new Uint8Array(byteLength); + for (let i = byteLength - 1; i >= 0; i--) { + bytes[i] = value & 0xff; + value = Math.floor(value / 256); + } + return bytes; + } + + // Variable (minimal) mode + if (value === 0) return new Uint8Array([0]); + + const result = []; + while (value > 0) { + result.push(value & 0xff); + value = Math.floor(value / 256); + } + + result.reverse(); + + return new Uint8Array(result); +} + +function createUintAtom(key, value, intByteLength = 1) { + const numberBytes = toBigEndianBytes(value, intByteLength); + const dataSize = 16 + intByteLength; // Atom header (8) + number bytes const atomSize = 8 + dataSize; const buf = new Uint8Array(atomSize); let offset = 0; // Wrapper atom (e.g., ©nam) - writeAtomHeader(buf, offset, atomSize, 'rtng'); + writeAtomHeader(buf, offset, atomSize, key); offset += 8; // Data atom writeAtomHeader(buf, offset, dataSize, 'data'); offset += 8; - // Data Type ((21 = Rating) + Locale (0)) + // Data Type ((Big Endian Unsigned Integer) + Locale (0)) buf[offset++] = 0; buf[offset++] = 0; buf[offset++] = 0; @@ -1332,7 +1387,7 @@ function createRatingAtom(value) { buf[offset++] = 0; buf[offset++] = 0; buf[offset++] = 0; - buf[offset++] = value; + buf.set(numberBytes, offset++); return buf; }