fix: aac metadata
This commit is contained in:
parent
47d64add72
commit
f9bd6a8fd7
1 changed files with 399 additions and 29 deletions
428
js/metadata.js
428
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue