import { getCoverBlob } from './utils.js'; const VENDOR_STRING = 'Monochrome'; const DEFAULT_TITLE = 'Unknown Title'; const DEFAULT_ARTIST = 'Unknown Artist'; const DEFAULT_ALBUM = 'Unknown Album'; /** * Adds metadata tags to audio files (FLAC or M4A) * @param {Blob} audioBlob - The audio file blob * @param {Object} track - Track metadata * @param {Object} api - API instance for fetching album art * @param {string} quality - Audio quality * @returns {Promise} - Audio blob with embedded metadata */ export async function addMetadataToAudio(audioBlob, track, api, _quality) { // Always check actual file signature, not just quality setting // DASH Hi-Res streams may return fragmented MP4 instead of raw FLAC const buffer = await audioBlob.slice(0, 12).arrayBuffer(); const view = new DataView(buffer); // Check for FLAC signature: "fLaC" (0x66 0x4C 0x61 0x43) const isFlac = view.byteLength >= 4 && view.getUint8(0) === 0x66 && // f view.getUint8(1) === 0x4c && // L view.getUint8(2) === 0x61 && // a view.getUint8(3) === 0x43; // C if (isFlac) { return await addFlacMetadata(audioBlob, track, api); } // Check for MP4/M4A signature: "ftyp" at offset 4 const isMp4 = view.byteLength >= 8 && view.getUint8(4) === 0x66 && // f view.getUint8(5) === 0x74 && // t view.getUint8(6) === 0x79 && // y view.getUint8(7) === 0x70; // p if (isMp4) { return await addM4aMetadata(audioBlob, track, api); } // Fallback: check MIME type from blob const mime = audioBlob.type; if (mime === 'audio/flac') { return await addFlacMetadata(audioBlob, track, api); } if (mime === 'audio/mp4' || mime === 'audio/x-m4a') { return await addM4aMetadata(audioBlob, track, api); } // Unknown format - return original without modification console.warn(`Unknown audio format (mime: ${mime}), returning original blob`); return audioBlob; } /** * Reads metadata from a file * @param {File} file * @returns {Promise} Track metadata */ export async function readTrackMetadata(file, siblings = []) { const metadata = { title: file.name.replace(/\.[^/.]+$/, ''), artists: [], artist: { name: 'Unknown Artist' }, // For fallback/compatibility album: { title: 'Unknown Album', cover: 'assets/appicon.png', releaseDate: null }, duration: 0, isLocal: true, file: file, id: `local-${file.name}-${file.lastModified}`, }; try { if (file.type === 'audio/flac' || file.name.endsWith('.flac')) { await readFlacMetadata(file, metadata); } else if (file.type === 'audio/mp4' || file.name.endsWith('.m4a')) { await readM4aMetadata(file, metadata); } else if (file.type === 'audio/mpeg' || file.name.endsWith('.mp3')) { await readMp3Metadata(file, metadata); } } catch (e) { console.warn('Error reading metadata for', file.name, e); } if (metadata.artists.length > 0) { metadata.artist = metadata.artists[0]; } if (metadata.album.cover === 'assets/appicon.png' && siblings.length > 0) { const baseName = file.name.substring(0, file.name.lastIndexOf('.')); const imageExtensions = ['.jpg', '.jpeg', '.png', '.webp']; const coverFile = siblings.find((f) => { const fName = f.name; const lastDot = fName.lastIndexOf('.'); if (lastDot === -1) return false; const fBase = fName.substring(0, lastDot); const fExt = fName.substring(lastDot).toLowerCase(); return fBase === baseName && imageExtensions.includes(fExt); }); if (coverFile) { metadata.album.cover = URL.createObjectURL(coverFile); } } return metadata; } async function readFlacMetadata(file, metadata) { const arrayBuffer = await file.arrayBuffer(); const dataView = new DataView(arrayBuffer); if (!isFlacFile(dataView)) return; const blocks = parseFlacBlocks(dataView); const vorbisBlock = blocks.find((b) => b.type === 4); const pictureBlock = blocks.find((b) => b.type === 6); const artists = []; if (vorbisBlock) { const offset = vorbisBlock.offset; const vendorLen = dataView.getUint32(offset, true); let pos = offset + 4 + vendorLen; const commentListLen = dataView.getUint32(pos, true); pos += 4; for (let i = 0; i < commentListLen; i++) { const len = dataView.getUint32(pos, true); pos += 4; const comment = new TextDecoder().decode(new Uint8Array(arrayBuffer, pos, len)); pos += len; const eqIdx = comment.indexOf('='); if (eqIdx > -1) { const key = comment.substring(0, eqIdx); const value = comment.substring(eqIdx + 1); const upperKey = key.toUpperCase(); if (upperKey === 'TITLE') metadata.title = value; if (upperKey === 'ARTIST' || upperKey === 'ALBUMARTIST') { artists.push(value); } if (upperKey === 'ALBUM') metadata.album.title = value; } } } if (artists.length > 0) { metadata.artists = artists.flatMap((a) => a.split(/; |\/|\\/)).map((name) => ({ name: name.trim() })); } if (pictureBlock) { try { let pos = pictureBlock.offset; pos += 4; const mimeLen = dataView.getUint32(pos, false); pos += 4; const mime = new TextDecoder().decode(new Uint8Array(arrayBuffer, pos, mimeLen)); pos += mimeLen; const descLen = dataView.getUint32(pos, false); pos += 4; pos += descLen; pos += 16; const dataLen = dataView.getUint32(pos, false); pos += 4; const pictureData = new Uint8Array(arrayBuffer, pos, dataLen); const blob = new Blob([pictureData], { type: mime }); metadata.album.cover = URL.createObjectURL(blob); } catch (e) { console.warn('Error parsing FLAC picture:', e); } } } async function readM4aMetadata(file, metadata) { try { const chunkSize = Math.min(file.size, 5 * 1024 * 1024); const buffer = await file.slice(0, chunkSize).arrayBuffer(); const view = new DataView(buffer); const atoms = parseMp4Atoms(view); const moov = atoms.find((a) => a.type === 'moov'); if (!moov) return; const moovStart = moov.offset + 8; const moovLen = moov.size - 8; const moovData = new DataView(view.buffer, moovStart, moovLen); const moovAtoms = parseMp4Atoms(moovData); const udta = moovAtoms.find((a) => a.type === 'udta'); if (!udta) return; const udtaStart = moovStart + udta.offset + 8; const udtaLen = udta.size - 8; const udtaData = new DataView(view.buffer, udtaStart, udtaLen); const udtaAtoms = parseMp4Atoms(udtaData); const meta = udtaAtoms.find((a) => a.type === 'meta'); if (!meta) return; const metaStart = udtaStart + meta.offset + 12; const metaLen = meta.size - 12; const metaData = new DataView(view.buffer, metaStart, metaLen); const metaAtoms = parseMp4Atoms(metaData); const ilst = metaAtoms.find((a) => a.type === 'ilst'); if (!ilst) return; const ilstStart = metaStart + ilst.offset + 8; const ilstLen = ilst.size - 8; const ilstData = new DataView(view.buffer, ilstStart, ilstLen); const items = parseMp4Atoms(ilstData); let artistStr = null; for (const item of items) { const itemStart = ilstStart + item.offset + 8; const itemLen = item.size - 8; const itemData = new DataView(view.buffer, itemStart, itemLen); const dataAtom = parseMp4Atoms(itemData).find((a) => a.type === 'data'); if (dataAtom) { const contentLen = dataAtom.size - 16; const contentOffset = itemStart + dataAtom.offset + 16; if (item.type === '©nam') { metadata.title = new TextDecoder().decode(new Uint8Array(view.buffer, contentOffset, contentLen)); } else if (item.type === '©ART') { artistStr = new TextDecoder().decode(new Uint8Array(view.buffer, contentOffset, contentLen)); } else if (item.type === '©alb') { metadata.album.title = new TextDecoder().decode( new Uint8Array(view.buffer, contentOffset, contentLen) ); } else if (item.type === 'covr') { const pictureData = new Uint8Array(view.buffer, contentOffset, contentLen); const mime = getMimeType(pictureData); const blob = new Blob([pictureData], { type: mime }); metadata.album.cover = URL.createObjectURL(blob); } } } if (artistStr) { metadata.artists = artistStr.split(/; |\/|\\/).map((name) => ({ name: name.trim() })); } } catch (e) { console.warn('Error parsing M4A:', e); } } async function readMp3Metadata(file, metadata) { let buffer = await file.slice(0, 10).arrayBuffer(); let view = new DataView(buffer); if (view.getUint8(0) === 0x49 && view.getUint8(1) === 0x44 && view.getUint8(2) === 0x33) { const majorVer = view.getUint8(3); const size = readSynchsafeInteger32(view, 6); const tagSize = size + 10; buffer = await file.slice(0, tagSize).arrayBuffer(); view = new DataView(buffer); let offset = 10; if ((view.getUint8(5) & 0x40) !== 0) { const extSize = readSynchsafeInteger32(view, offset); offset += extSize; } let tpe1 = null; let tpe2 = null; while (offset < view.byteLength) { let frameId, frameSize; if (majorVer === 3) { frameId = new TextDecoder().decode(new Uint8Array(buffer, offset, 4)); frameSize = view.getUint32(offset + 4, false); offset += 10; } else if (majorVer === 4) { frameId = new TextDecoder().decode(new Uint8Array(buffer, offset, 4)); frameSize = readSynchsafeInteger32(view, offset + 4); offset += 10; } else { break; } if (frameId.charCodeAt(0) === 0) break; if (offset + frameSize > view.byteLength) break; const frameData = new DataView(buffer, offset, frameSize); if (frameId === 'TIT2') metadata.title = readID3Text(frameData); if (frameId === 'TPE1') tpe1 = readID3Text(frameData); if (frameId === 'TPE2') tpe2 = readID3Text(frameData); if (frameId === 'TALB') metadata.album.title = readID3Text(frameData); if (frameId === 'TYER' || frameId === 'TDRC') { const year = readID3Text(frameData); if (year) metadata.album.releaseDate = year; } if (frameId === 'APIC') { try { const encoding = frameData.getUint8(0); let mimeType = ''; let pos = 1; while (pos < frameData.byteLength && frameData.getUint8(pos) !== 0) { mimeType += String.fromCharCode(frameData.getUint8(pos)); pos++; } pos++; pos++; let terminator = encoding === 1 || encoding === 2 ? 2 : 1; while (pos < frameData.byteLength) { if (frameData.getUint8(pos) === 0) { if (terminator === 1) { pos++; break; } else if (pos + 1 < frameData.byteLength && frameData.getUint8(pos + 1) === 0) { pos += 2; break; } } pos++; } const pictureData = new Uint8Array(buffer, offset + pos, frameSize - pos); const blob = new Blob([pictureData], { type: mimeType || 'image/jpeg' }); metadata.album.cover = URL.createObjectURL(blob); } catch (e) { console.warn('Error parsing APIC:', e); } } offset += frameSize; } const artistStr = tpe1 || tpe2; if (artistStr) { metadata.artists = artistStr.split('/').map((name) => ({ name: name.trim() })); } } if (file.size > 128) { const tailBuffer = await file.slice(file.size - 128).arrayBuffer(); const tag = new TextDecoder().decode(new Uint8Array(tailBuffer, 0, 3)); if (tag === 'TAG') { const title = new TextDecoder() .decode(new Uint8Array(tailBuffer, 3, 30)) .replace(/\0/g, '') .trim(); const artist = new TextDecoder() .decode(new Uint8Array(tailBuffer, 33, 30)) .replace(/\0/g, '') .trim(); const album = new TextDecoder() .decode(new Uint8Array(tailBuffer, 63, 30)) .replace(/\0/g, '') .trim(); if (title) metadata.title = title; if (artist && metadata.artists.length === 0) { metadata.artists = [{ name: artist }]; } if (album) metadata.album.title = album; } } } function readSynchsafeInteger32(view, offset) { return ( ((view.getUint8(offset) & 0x7f) << 21) | ((view.getUint8(offset + 1) & 0x7f) << 14) | ((view.getUint8(offset + 2) & 0x7f) << 7) | (view.getUint8(offset + 3) & 0x7f) ); } function readID3Text(view) { const encoding = view.getUint8(0); const buffer = view.buffer.slice(view.byteOffset + 1, view.byteOffset + view.byteLength); let decoder; if (encoding === 0) decoder = new TextDecoder('iso-8859-1'); else if (encoding === 1) decoder = new TextDecoder('utf-16'); else if (encoding === 2) decoder = new TextDecoder('utf-16be'); else decoder = new TextDecoder('utf-8'); return decoder.decode(buffer).replace(/\0/g, ''); } function getMimeType(data) { if (data.length >= 2 && data[0] === 0xff && data[1] === 0xd8) return 'image/jpeg'; if (data.length >= 8 && data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47) return 'image/png'; return 'image/jpeg'; } /** * Adds Vorbis comment metadata to FLAC files */ async function addFlacMetadata(flacBlob, track, api) { try { const arrayBuffer = await flacBlob.arrayBuffer(); const dataView = new DataView(arrayBuffer); // Verify FLAC signature if (!isFlacFile(dataView)) { console.warn('Not a valid FLAC file, returning original'); return flacBlob; } // Parse FLAC structure const blocks = parseFlacBlocks(dataView); // If parsing failed or no audio data found, return original if (!blocks || blocks.length === 0 || blocks.audioDataOffset === undefined) { console.warn('Failed to parse FLAC blocks, returning original'); return flacBlob; } // Check for STREAMINFO block (must be first, type 0) if (blocks[0].type !== 0) { console.warn('FLAC file missing STREAMINFO block, returning original'); return flacBlob; } // Create or update Vorbis comment block const vorbisCommentBlock = createVorbisCommentBlock(track); // Fetch album artwork if available let pictureBlock = null; if (track.album?.cover) { try { pictureBlock = await createFlacPictureBlock(track.album.cover, api); } catch (error) { console.warn('Failed to embed album art:', error); } } // Rebuild FLAC file with new metadata let newFlacData; try { newFlacData = rebuildFlacWithMetadata(dataView, blocks, vorbisCommentBlock, pictureBlock); } catch (rebuildError) { console.error('Failed to rebuild FLAC structure:', rebuildError); return flacBlob; } // Validate the rebuilt file const validationView = new DataView(newFlacData.buffer); if (!isFlacFile(validationView)) { console.error('Rebuilt FLAC has invalid signature, returning original'); return flacBlob; } // Validate new file has proper block structure const newBlocks = parseFlacBlocks(validationView); if (!newBlocks || newBlocks.length === 0 || newBlocks.audioDataOffset === undefined) { console.error('Rebuilt FLAC has invalid block structure, returning original'); return flacBlob; } return new Blob([newFlacData], { type: 'audio/flac' }); } catch (error) { console.error('Failed to add FLAC metadata:', error); return flacBlob; } } function isFlacFile(dataView) { // Check for "fLaC" signature at the beginning return ( dataView.byteLength >= 4 && dataView.getUint8(0) === 0x66 && // 'f' dataView.getUint8(1) === 0x4c && // 'L' dataView.getUint8(2) === 0x61 && // 'a' dataView.getUint8(3) === 0x43 ); // 'C' } function parseFlacBlocks(dataView) { const blocks = []; let offset = 4; // Skip "fLaC" signature while (offset + 4 <= dataView.byteLength) { const header = dataView.getUint8(offset); const isLast = (header & 0x80) !== 0; const blockType = header & 0x7f; // Block type 127 is invalid, types > 6 are reserved (except 127) // Valid types: 0=STREAMINFO, 1=PADDING, 2=APPLICATION, 3=SEEKTABLE, 4=VORBIS_COMMENT, 5=CUESHEET, 6=PICTURE if (blockType === 127) { console.warn('Encountered invalid block type 127, stopping parse'); break; } const blockSize = (dataView.getUint8(offset + 1) << 16) | (dataView.getUint8(offset + 2) << 8) | dataView.getUint8(offset + 3); // Validate block size if (blockSize < 0 || offset + 4 + blockSize > dataView.byteLength) { console.warn(`Invalid block size ${blockSize} at offset ${offset}, stopping parse`); break; } blocks.push({ type: blockType, isLast: isLast, size: blockSize, offset: offset + 4, headerOffset: offset, }); offset += 4 + blockSize; if (isLast) { // Save the audio data offset blocks.audioDataOffset = offset; break; } } // If we didn't find the last block marker, estimate audio offset if (blocks.audioDataOffset === undefined && blocks.length > 0) { const lastBlock = blocks[blocks.length - 1]; blocks.audioDataOffset = lastBlock.headerOffset + 4 + lastBlock.size; console.warn('No last-block marker found, estimated audio offset:', blocks.audioDataOffset); } return blocks; } function createVorbisCommentBlock(track) { // Vorbis comment structure const comments = []; // Add standard tags if (track.title) { comments.push(['TITLE', track.title]); } if (track.artist?.name) { comments.push(['ARTIST', track.artist.name]); } if (track.album?.title) { comments.push(['ALBUM', track.album.title]); } const albumArtist = track.album?.artist?.name || track.artist?.name; if (albumArtist) { comments.push(['ALBUMARTIST', albumArtist]); } if (track.trackNumber) { comments.push(['TRACKNUMBER', String(track.trackNumber)]); } if (track.album?.numberOfTracks) { comments.push(['TRACKTOTAL', String(track.album.numberOfTracks)]); } const releaseDateStr = track.album?.releaseDate || (track.streamStartDate ? track.streamStartDate.split('T')[0] : ''); if (releaseDateStr) { try { const year = new Date(releaseDateStr).getFullYear(); if (!isNaN(year)) { comments.push(['DATE', String(year)]); } } catch { // Invalid date, skip } } if (track.copyright) { comments.push(['COPYRIGHT', track.copyright]); } if (track.isrc) { comments.push(['ISRC', track.isrc]); } // Calculate total size const vendor = VENDOR_STRING; const vendorBytes = new TextEncoder().encode(vendor); let totalSize = 4 + vendorBytes.length + 4; // vendor length + vendor + comment count const encodedComments = comments.map(([key, value]) => { const text = `${key}=${value}`; const bytes = new TextEncoder().encode(text); totalSize += 4 + bytes.length; return bytes; }); // Create buffer const buffer = new ArrayBuffer(totalSize); const view = new DataView(buffer); const uint8Array = new Uint8Array(buffer); let offset = 0; // Vendor length (little-endian) view.setUint32(offset, vendorBytes.length, true); offset += 4; // Vendor string uint8Array.set(vendorBytes, offset); offset += vendorBytes.length; // Comment count (little-endian) view.setUint32(offset, comments.length, true); offset += 4; // Comments for (const commentBytes of encodedComments) { view.setUint32(offset, commentBytes.length, true); offset += 4; uint8Array.set(commentBytes, offset); offset += commentBytes.length; } return uint8Array; } async function createFlacPictureBlock(coverId, api) { try { // Fetch album art const imageBlob = await getCoverBlob(api, coverId); if (!imageBlob) { throw new Error('Failed to fetch album art'); } const imageBytes = new Uint8Array(await imageBlob.arrayBuffer()); // Detect MIME type from blob or use default const mimeType = imageBlob.type || 'image/jpeg'; const mimeBytes = new TextEncoder().encode(mimeType); const description = ''; const descBytes = new TextEncoder().encode(description); // Calculate total size const totalSize = 4 + // picture type 4 + mimeBytes.length + // mime length + mime 4 + descBytes.length + // desc length + desc 4 + // width 4 + // height 4 + // color depth 4 + // indexed colors 4 + imageBytes.length; // image length + image const buffer = new ArrayBuffer(totalSize); const view = new DataView(buffer); const uint8Array = new Uint8Array(buffer); let offset = 0; // Picture type (3 = front cover) view.setUint32(offset, 3, false); offset += 4; // MIME type length view.setUint32(offset, mimeBytes.length, false); offset += 4; // MIME type uint8Array.set(mimeBytes, offset); offset += mimeBytes.length; // Description length view.setUint32(offset, descBytes.length, false); offset += 4; // Description (empty) if (descBytes.length > 0) { uint8Array.set(descBytes, offset); offset += descBytes.length; } // Width (0 = unknown) view.setUint32(offset, 0, false); offset += 4; // Height (0 = unknown) view.setUint32(offset, 0, false); offset += 4; // Color depth (0 = unknown) view.setUint32(offset, 0, false); offset += 4; // Indexed colors (0 = not indexed) view.setUint32(offset, 0, false); offset += 4; // Image data length view.setUint32(offset, imageBytes.length, false); offset += 4; // Image data uint8Array.set(imageBytes, offset); return uint8Array; } catch (error) { console.error('Failed to create FLAC picture block:', error); return null; } } function rebuildFlacWithMetadata(dataView, blocks, vorbisCommentBlock, pictureBlock) { const originalArray = new Uint8Array(dataView.buffer); // Remove old Vorbis comment and picture blocks const filteredBlocks = blocks.filter((b) => b.type !== 4 && b.type !== 6); // 4 = Vorbis, 6 = Picture // Calculate new file size let newSize = 4; // "fLaC" signature // Add STREAMINFO and other essential blocks for (const block of filteredBlocks) { newSize += 4 + block.size; // header + data } // Add new Vorbis comment block newSize += 4 + vorbisCommentBlock.length; // Add picture block if available if (pictureBlock) { newSize += 4 + pictureBlock.length; } // Add audio data const audioDataOffset = blocks.audioDataOffset; if (audioDataOffset === undefined) { throw new Error('Invalid FLAC file structure: unable to locate audio data stream'); } const audioDataSize = dataView.byteLength - audioDataOffset; newSize += audioDataSize; // Build new file const newFile = new Uint8Array(newSize); let offset = 0; // Write "fLaC" signature newFile[offset++] = 0x66; // 'f' newFile[offset++] = 0x4c; // 'L' newFile[offset++] = 0x61; // 'a' newFile[offset++] = 0x43; // 'C' // Write existing blocks (except Vorbis and Picture) for (let i = 0; i < filteredBlocks.length; i++) { const block = filteredBlocks[i]; const isLast = false; // We'll add more blocks // Write block header const header = (isLast ? 0x80 : 0x00) | block.type; newFile[offset++] = header; newFile[offset++] = (block.size >> 16) & 0xff; newFile[offset++] = (block.size >> 8) & 0xff; newFile[offset++] = block.size & 0xff; // Write block data newFile.set(originalArray.subarray(block.offset, block.offset + block.size), offset); offset += block.size; } // Write new Vorbis comment block const vorbisHeaderOffset = offset; const vorbisHeader = 0x04; // Vorbis comment type newFile[offset++] = vorbisHeader; newFile[offset++] = (vorbisCommentBlock.length >> 16) & 0xff; newFile[offset++] = (vorbisCommentBlock.length >> 8) & 0xff; newFile[offset++] = vorbisCommentBlock.length & 0xff; newFile.set(vorbisCommentBlock, offset); offset += vorbisCommentBlock.length; let lastBlockHeaderOffset = vorbisHeaderOffset; // Write picture block if available if (pictureBlock) { const pictureHeaderOffset = offset; const pictureHeader = 0x06; // Picture type newFile[offset++] = pictureHeader; newFile[offset++] = (pictureBlock.length >> 16) & 0xff; newFile[offset++] = (pictureBlock.length >> 8) & 0xff; newFile[offset++] = pictureBlock.length & 0xff; newFile.set(pictureBlock, offset); offset += pictureBlock.length; lastBlockHeaderOffset = pictureHeaderOffset; } // Mark the last metadata block with the 0x80 flag newFile[lastBlockHeaderOffset] |= 0x80; // Write audio data if (audioDataSize > 0) { newFile.set(originalArray.subarray(audioDataOffset, audioDataOffset + audioDataSize), offset); } return newFile; } /** * Adds metadata to M4A files using MP4 atoms */ async function addM4aMetadata(m4aBlob, track, api) { try { const arrayBuffer = await m4aBlob.arrayBuffer(); const dataView = new DataView(arrayBuffer); // Parse MP4 atoms const atoms = parseMp4Atoms(dataView); // Create metadata atoms const metadataAtoms = createMp4MetadataAtoms(track); // Fetch album artwork if available if (track.album?.cover) { try { 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); } } // Rebuild MP4 file with metadata const newMp4Data = rebuildMp4WithMetadata(dataView, atoms, metadataAtoms); return new Blob([newMp4Data], { type: 'audio/mp4' }); } catch (error) { console.error('Failed to add M4A metadata:', error); return m4aBlob; } } function parseMp4Atoms(dataView) { const atoms = []; let offset = 0; while (offset + 8 <= dataView.byteLength) { // MP4 atoms use big-endian byte order let size = dataView.getUint32(offset, false); // Handle special size values if (size === 0) { // Size 0 means the atom extends to the end of the file size = dataView.byteLength - offset; } else if (size === 1) { // Size 1 means 64-bit extended size follows (after the type field) if (offset + 16 > dataView.byteLength) { break; } // Read 64-bit size from offset+8 (big-endian) const sizeHigh = dataView.getUint32(offset + 8, false); const sizeLow = dataView.getUint32(offset + 12, false); if (sizeHigh !== 0) { console.warn('64-bit MP4 atoms larger than 4GB are not supported - file may be processed incompletely'); break; } size = sizeLow; } if (size < 8 || offset + size > dataView.byteLength) { break; } const type = String.fromCharCode( dataView.getUint8(offset + 4), dataView.getUint8(offset + 5), dataView.getUint8(offset + 6), dataView.getUint8(offset + 7) ); atoms.push({ type: type, offset: offset, size: size, }); offset += size; } return atoms; } function createMp4MetadataAtoms(track) { // MP4 metadata atoms are more complex than FLAC // We'll create basic iTunes-style metadata const tags = { '©nam': track.title || DEFAULT_TITLE, '©ART': track.artist?.name || DEFAULT_ARTIST, '©alb': track.album?.title || DEFAULT_ALBUM, aART: track.album?.artist?.name || track.artist?.name || DEFAULT_ARTIST, }; if (track.trackNumber) { tags['trkn'] = track.trackNumber; } const releaseDateStr = track.album?.releaseDate || (track.streamStartDate ? track.streamStartDate.split('T')[0] : ''); if (releaseDateStr) { try { const year = new Date(releaseDateStr).getFullYear(); if (!isNaN(year)) { tags['©day'] = String(year); } } catch { // Invalid date, skip } } return { tags }; } 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 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)); } } // 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 updateChunkOffsets(buffer, moovOffset, moovSize, shift) { const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); // 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; } }