fix: aac metadata

This commit is contained in:
Julien Maille 2025-12-26 14:10:06 +01:00
parent 47d64add72
commit f9bd6a8fd7

View file

@ -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;
}
}