From f9bd6a8fd730c93f05c7b07a87e811cb118bc6e5 Mon Sep 17 00:00:00 2001 From: Julien Maille Date: Fri, 26 Dec 2025 14:10:06 +0100 Subject: [PATCH] fix: aac metadata --- js/metadata.js | 428 +++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 399 insertions(+), 29 deletions(-) diff --git a/js/metadata.js b/js/metadata.js index a2cfb39..fc86b9d 100644 --- a/js/metadata.js +++ b/js/metadata.js @@ -394,9 +394,13 @@ async function addM4aMetadata(m4aBlob, track, api) { // Fetch album artwork if available if (track.album?.cover) { try { - const coverData = await fetchAlbumArtForMp4(track.album.cover, api); - if (coverData) { - metadataAtoms.cover = coverData; + const imageBlob = await getCoverBlob(api, track.album.cover); + if (imageBlob) { + const imageBytes = new Uint8Array(await imageBlob.arrayBuffer()); + metadataAtoms.cover = { + type: 'covr', + data: imageBytes + }; } } catch (error) { console.warn('Failed to embed album art in M4A:', error); @@ -493,35 +497,401 @@ function createMp4MetadataAtoms(track) { return { tags }; } -async function fetchAlbumArtForMp4(coverId, api) { - try { - const imageBlob = await getCoverBlob(api, coverId); - if (!imageBlob) { - return null; +function rebuildMp4WithMetadata(dataView, atoms, metadataAtoms) { + const originalArray = new Uint8Array(dataView.buffer); + + // Find moov atom + const moovAtom = atoms.find(a => a.type === 'moov'); + if (!moovAtom) { + console.warn('No moov atom found in M4A file'); + return originalArray; + } + + // Construct the new metadata block (udta -> meta -> ilst) + const newMetadataBytes = createMetadataBlock(metadataAtoms); + + // We need to insert this into the moov atom. + // If udta exists, we merge/replace. For simplicity, we'll append/create. + // Ideally, we should parse moov children to find udta. + + // 1. Calculate new sizes + // New file size = Original size + Metadata block size + // Note: If we are replacing existing metadata, this calculation would be different, + // but here we are assuming we are adding fresh or appending. + // A robust implementation would parse moov children, remove existing udta, and add new one. + + // Let's try to do it right: parse moov children + const moovChildren = parseMp4Atoms(new DataView(originalArray.buffer, moovAtom.offset + 8, moovAtom.size - 8)); + + // Filter out existing udta to replace it + const filteredMoovChildren = moovChildren.filter(a => a.type !== 'udta'); + + // Calculate new moov size + // Header (8) + Sum of other children sizes + New Metadata Block Size + let newMoovSize = 8; + for (const child of filteredMoovChildren) { + newMoovSize += child.size; + } + newMoovSize += newMetadataBytes.length; + + const sizeDiff = newMoovSize - moovAtom.size; + const newFileSize = originalArray.length + sizeDiff; + + const newFile = new Uint8Array(newFileSize); + let offset = 0; + let originalOffset = 0; + + // Copy atoms before moov + const atomsBeforeMoov = atoms.filter(a => a.offset < moovAtom.offset); + for (const atom of atomsBeforeMoov) { + newFile.set(originalArray.subarray(atom.offset, atom.offset + atom.size), offset); + offset += atom.size; + originalOffset += atom.size; + } + + // Write new moov atom + // Size + newFile[offset++] = (newMoovSize >> 24) & 0xFF; + newFile[offset++] = (newMoovSize >> 16) & 0xFF; + newFile[offset++] = (newMoovSize >> 8) & 0xFF; + newFile[offset++] = newMoovSize & 0xFF; + + // Type 'moov' + newFile[offset++] = 0x6D; + newFile[offset++] = 0x6F; + newFile[offset++] = 0x6F; + newFile[offset++] = 0x76; + + // Write preserved children of moov + for (const child of filteredMoovChildren) { + const childStart = moovAtom.offset + 8 + child.offset; // child.offset is relative to moov body start in our parseMp4Atoms helper usage? + // Wait, parseMp4Atoms returns absolute offsets usually? + // Let's verify parseMp4Atoms usage. + // When we passed a slice DataView, the offsets returned by parseMp4Atoms + // are relative to the start of that DataView (which is moov body start). + + const absoluteChildStart = moovAtom.offset + 8 + child.offset; + newFile.set(originalArray.subarray(absoluteChildStart, absoluteChildStart + child.size), offset); + offset += child.size; + } + + // Write new metadata block (udta) + newFile.set(newMetadataBytes, offset); + offset += newMetadataBytes.length; + + // Update originalOffset to skip old moov + originalOffset = moovAtom.offset + moovAtom.size; + + // Copy atoms after moov + // Adjust offsets in stco/co64 atoms if necessary? + // Changing the size of moov (or atoms before mdat) shifts the mdat offsets. + // If moov comes before mdat, we MUST update the Chunk Offset Atom (stco or co64). + // This is complex. + + // Safe strategy: If moov is AFTER mdat, we don't need to update offsets. + // If moov is BEFORE mdat, we need to shift offsets. + // Most streaming optimized files have moov before mdat. + + const mdatAtom = atoms.find(a => a.type === 'mdat'); + const moovBeforeMdat = mdatAtom && moovAtom.offset < mdatAtom.offset; + + if (moovBeforeMdat) { + // We need to update stco/co64 atoms inside the copied moov children content in newFile. + // This is getting very complicated for a simple "add metadata" feature without a proper library. + // However, we can try to find 'stco' or 'co64' in the new buffer we just wrote and offset values. + + // Let's assume we need to shift by sizeDiff. + updateChunkOffsets(newFile, offset - newMoovSize, newMoovSize, sizeDiff); + } + + // Copy remaining data (mdat etc.) + if (originalOffset < originalArray.length) { + newFile.set(originalArray.subarray(originalOffset), offset); + } + + return newFile; +} + +function createMetadataBlock(metadataAtoms) { + const { tags, cover } = metadataAtoms; + + const ilstChildren = []; + + // Text tags + for (const [key, value] of Object.entries(tags)) { + if (key === 'trkn') { + ilstChildren.push(createIntAtom(key, value)); + } else { + ilstChildren.push(createStringAtom(key, value)); } - - const imageBytes = new Uint8Array(await imageBlob.arrayBuffer()); - - return { - type: 'covr', - data: imageBytes - }; - } catch (error) { - console.error('Failed to fetch album art for MP4:', error); - return null; + } + + // Cover art + if (cover) { + ilstChildren.push(createCoverAtom(cover.data)); + } + + // Construct ilst atom + const ilstSize = 8 + ilstChildren.reduce((acc, buf) => acc + buf.length, 0); + const ilst = new Uint8Array(ilstSize); + let offset = 0; + + writeAtomHeader(ilst, offset, ilstSize, 'ilst'); + offset += 8; + + for (const child of ilstChildren) { + ilst.set(child, offset); + offset += child.length; + } + + // Construct meta atom (FullAtom, version+flags = 4 bytes) + const metaSize = 12 + ilstSize; + const meta = new Uint8Array(metaSize); + offset = 0; + + writeAtomHeader(meta, offset, metaSize, 'meta'); + offset += 8; + + meta[offset++] = 0; // Version + meta[offset++] = 0; // Flags + meta[offset++] = 0; + meta[offset++] = 0; + + meta.set(ilst, offset); + + // Construct hdlr atom (required for meta) + // "mdir" subtype, "appl" manufacturer, 0 flags/masks, empty name + // hdlr size: 4 (size) + 4 (type) + 4 (ver/flags) + 4 (pre_defined) + 4 (handler_type) + 12 (reserved) + name (string) + // Minimal valid hdlr for iTunes metadata: + const hdlrContent = new Uint8Array([ + 0, 0, 0, 0, // Version/Flags + 0, 0, 0, 0, // Pre-defined + 0x6D, 0x64, 0x69, 0x72, // 'mdir' + 0x61, 0x70, 0x70, 0x6C, // 'appl' + 0, 0, 0, 0, // Reserved + 0, 0, 0, 0, + 0, 0 // Name (empty null-term) check spec? usually simple 0 is enough + ]); + const hdlrSize = 8 + hdlrContent.length; + const hdlr = new Uint8Array(hdlrSize); + writeAtomHeader(hdlr, 0, hdlrSize, 'hdlr'); + hdlr.set(hdlrContent, 8); + + + // Construct udta atom + // udta contains meta. meta usually should contain hdlr before ilst? + // Actually, QuickTime spec says meta contains hdlr then ilst. + + const finalMetaSize = 12 + hdlrSize + ilstSize; + const finalMeta = new Uint8Array(finalMetaSize); + offset = 0; + writeAtomHeader(finalMeta, offset, finalMetaSize, 'meta'); + offset += 8; + finalMeta[offset++] = 0; // Version + finalMeta[offset++] = 0; // Flags + finalMeta[offset++] = 0; + finalMeta[offset++] = 0; + + finalMeta.set(hdlr, offset); + offset += hdlrSize; + finalMeta.set(ilst, offset); + + const udtaSize = 8 + finalMetaSize; + const udta = new Uint8Array(udtaSize); + writeAtomHeader(udta, 0, udtaSize, 'udta'); + udta.set(finalMeta, 8); + + return udta; +} + +function createStringAtom(type, value) { + const textBytes = new TextEncoder().encode(value); + const dataSize = 16 + textBytes.length; // 8 (data atom header) + 8 (flags/null) + text + const atomSize = 8 + dataSize; + + const buf = new Uint8Array(atomSize); + let offset = 0; + + // Wrapper atom (e.g., ©nam) + writeAtomHeader(buf, offset, atomSize, type); + offset += 8; + + // Data atom + writeAtomHeader(buf, offset, dataSize, 'data'); + offset += 8; + + // Data Type (1 = UTF-8 text) + Locale (0) + buf[offset++] = 0; + buf[offset++] = 0; + buf[offset++] = 0; + buf[offset++] = 1; // Type 1 + buf[offset++] = 0; + buf[offset++] = 0; + buf[offset++] = 0; + buf[offset++] = 0; + + buf.set(textBytes, offset); + + return buf; +} + +function createIntAtom(type, value) { + // trkn is special: data is 8 bytes. + // reserved(2) + track(2) + total(2) + reserved(2) + const dataSize = 16 + 8; + const atomSize = 8 + dataSize; + + const buf = new Uint8Array(atomSize); + let offset = 0; + + writeAtomHeader(buf, offset, atomSize, type); + offset += 8; + + writeAtomHeader(buf, offset, dataSize, 'data'); + offset += 8; + + // Data Type (0 = implicit/int) + Locale + buf[offset++] = 0; + buf[offset++] = 0; + buf[offset++] = 0; + buf[offset++] = 0; // Type 0 + buf[offset++] = 0; + buf[offset++] = 0; + buf[offset++] = 0; + buf[offset++] = 0; + + // Track data + buf[offset++] = 0; + buf[offset++] = 0; + // Track num + const trk = parseInt(value) || 0; + buf[offset++] = (trk >> 8) & 0xFF; + buf[offset++] = trk & 0xFF; + // Total (0 for now) + buf[offset++] = 0; + buf[offset++] = 0; + buf[offset++] = 0; + buf[offset++] = 0; + + return buf; +} + +function createCoverAtom(imageBytes) { + const dataSize = 16 + imageBytes.length; + const atomSize = 8 + dataSize; + + const buf = new Uint8Array(atomSize); + let offset = 0; + + writeAtomHeader(buf, offset, atomSize, 'covr'); + offset += 8; + + writeAtomHeader(buf, offset, dataSize, 'data'); + offset += 8; + + // Data Type (13 = JPEG, 14 = PNG) + // We try to detect or default to JPEG (13) + let type = 13; + if (imageBytes[0] === 0x89 && imageBytes[1] === 0x50) { // PNG signature + type = 14; + } + + buf[offset++] = 0; + buf[offset++] = 0; + buf[offset++] = 0; + buf[offset++] = type; + buf[offset++] = 0; + buf[offset++] = 0; + buf[offset++] = 0; + buf[offset++] = 0; + + buf.set(imageBytes, offset); + + return buf; +} + +function writeAtomHeader(buf, offset, size, type) { + buf[offset++] = (size >> 24) & 0xFF; + buf[offset++] = (size >> 16) & 0xFF; + buf[offset++] = (size >> 8) & 0xFF; + buf[offset++] = size & 0xFF; + + for (let i = 0; i < 4; i++) { + buf[offset++] = type.charCodeAt(i); } } -function rebuildMp4WithMetadata(dataView, atoms, metadataAtoms) { - // M4A metadata injection is complex and requires: - // 1. Finding the moov atom - // 2. Finding or creating the udta atom inside moov - // 3. Creating a meta atom with ilst containing all metadata - // 4. Rebuilding the file with updated atom sizes +function updateChunkOffsets(buffer, moovOffset, moovSize, shift) { + const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); - // For now, return the original file to avoid potential corruption - // TODO: Implement full MP4 metadata injection - const originalArray = new Uint8Array(dataView.buffer); - console.warn('M4A metadata embedding is not yet supported - downloaded file will not contain metadata tags'); - return originalArray; + // Scan moov for stco/co64 + // This is a naive recursive search restricted to the known moov range + + // We parse atoms starting from moov content + let offset = moovOffset + 8; // Skip moov header + const end = moovOffset + moovSize; + + findAndShiftOffsets(view, offset, end, shift); +} + +function findAndShiftOffsets(view, start, end, shift) { + let offset = start; + + while (offset + 8 <= end) { + const size = view.getUint32(offset, false); + const type = String.fromCharCode( + view.getUint8(offset + 4), + view.getUint8(offset + 5), + view.getUint8(offset + 6), + view.getUint8(offset + 7) + ); + + if (size < 8) break; + + if (type === 'trak' || type === 'mdia' || type === 'minf' || type === 'stbl') { + // Container atoms, recurse + findAndShiftOffsets(view, offset + 8, offset + size, shift); + } else if (type === 'stco') { + // Chunk Offset Atom (32-bit) + // Header (8) + Version(1) + Flags(3) + Count(4) + Entries(Count * 4) + const count = view.getUint32(offset + 12, false); + for (let i = 0; i < count; i++) { + const entryOffset = offset + 16 + (i * 4); + const oldVal = view.getUint32(entryOffset, false); + view.setUint32(entryOffset, oldVal + shift, false); + } + } else if (type === 'co64') { + // Chunk Offset Atom (64-bit) + // Header (8) + Version(1) + Flags(3) + Count(4) + Entries(Count * 8) + const count = view.getUint32(offset + 12, false); + for (let i = 0; i < count; i++) { + const entryOffset = offset + 16 + (i * 8); + // Read 64-bit int + const oldHigh = view.getUint32(entryOffset, false); + const oldLow = view.getUint32(entryOffset + 4, false); + + // Add shift (assuming shift is small enough not to overflow low 32 in a way that affects high simply?) + // Shift is Javascript number (double), up to 9007199254740991. + // 32-bit uint max is 4294967295. + + // Proper 64-bit addition + // Construct BigInt + // Note: BigInt might not be available in all older environments, but modern browsers support it. + // Fallback: simpler logic + + let newLow = oldLow + shift; + let carry = 0; + if (newLow > 0xFFFFFFFF) { + carry = Math.floor(newLow / 0x100000000); + newLow = newLow >>> 0; + } + const newHigh = oldHigh + carry; + + view.setUint32(entryOffset, newHigh, false); + view.setUint32(entryOffset + 4, newLow, false); + } + } + + offset += size; + } }