From 42101353aba57adcc6d92a1736a6c4c6aa711990 Mon Sep 17 00:00:00 2001 From: Daniel <790119+DanTheMan827@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:53:54 +0000 Subject: [PATCH] feat(metadata): re-add flac and m4a metadata code as separate files --- js/api.js | 21 +- js/downloads.js | 21 +- js/id3-writer.js | 156 -------- js/metadata.flac.js | 588 ++++++++++++++++++++++++++++++ js/metadata.js | 566 +---------------------------- js/metadata.mp3.js | 346 ++++++++++++++++++ js/metadata.mp4.js | 846 ++++++++++++++++++++++++++++++++++++++++++++ js/taglib.worker.ts | 29 +- js/utils.js | 41 +++ 9 files changed, 1877 insertions(+), 737 deletions(-) delete mode 100644 js/id3-writer.js create mode 100644 js/metadata.flac.js create mode 100644 js/metadata.mp3.js create mode 100644 js/metadata.mp4.js diff --git a/js/api.js b/js/api.js index cc8e0e3..dd0f551 100644 --- a/js/api.js +++ b/js/api.js @@ -13,6 +13,7 @@ import { DashDownloader } from './dash-downloader.js'; import { HlsDownloader } from './hls-downloader.js'; import { encodeToMp3, MP3EncodingError } from './mp3-encoder.js'; import { ffmpeg, loadFfmpeg } from './ffmpeg.js'; +import { rebuildFlacWithoutMetadata } from './metadata.flac.js'; export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE'; const TIDAL_V2_TOKEN = 'txNoH4kkV41MfH25'; @@ -1399,14 +1400,18 @@ export class LosslessAPI { try { switch (losslessContainerSettings.getContainer()) { case 'flac': - blob = await ffmpeg( - blob, - { args: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac'] }, - 'output.flac', - 'audio/flac', - onProgress, - options.signal - ); + if ((await getExtensionFromBlob(blob)) != 'flac') { + blob = await ffmpeg( + blob, + { args: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac'] }, + 'output.flac', + 'audio/flac', + onProgress, + options.signal + ); + } else { + blob = await rebuildFlacWithoutMetadata(blob); + } break; case 'alac': blob = await ffmpeg( diff --git a/js/downloads.js b/js/downloads.js index cb437ce..b984c4c 100644 --- a/js/downloads.js +++ b/js/downloads.js @@ -13,6 +13,7 @@ import { } from './utils.js'; import { lyricsSettings, bulkDownloadSettings, losslessContainerSettings, playlistSettings } from './storage.js'; import { addMetadataToAudio, prefetchMetadataObjects } from './metadata.js'; +import { rebuildFlacWithoutMetadata } from './metadata.flac.js'; import { DashDownloader } from './dash-downloader.js'; import { generateM3U, generateM3U8, generateCUE, generateNFO, generateJSON } from './playlist-generator.js'; import { encodeToMp3 } from './mp3-encoder.js'; @@ -379,14 +380,18 @@ async function downloadTrackBlob(track, quality, api, lyricsManager = null, sign try { switch (losslessContainerSettings.getContainer()) { case 'flac': - blob = await ffmpeg( - blob, - { args: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac'] }, - 'output.flac', - 'audio/flac', - onProgress, - signal - ); + if ((await getExtensionFromBlob(blob)) != 'flac') { + blob = await ffmpeg( + blob, + { args: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac'] }, + 'output.flac', + 'audio/flac', + onProgress, + signal + ); + } else { + blob = await rebuildFlacWithoutMetadata(blob); + } break; case 'alac': blob = await ffmpeg( diff --git a/js/id3-writer.js b/js/id3-writer.js deleted file mode 100644 index 47a841b..0000000 --- a/js/id3-writer.js +++ /dev/null @@ -1,156 +0,0 @@ -import { getCoverBlob, getTrackTitle } from './utils.js'; - -async function writeID3v2Tag(mp3Blob, metadata, coverBlob = null) { - const frames = []; - - if (metadata.title) { - frames.push(createTextFrame('TIT2', getTrackTitle(metadata))); - } - - const artistName = metadata.artist?.name || metadata.artists?.[0]?.name; - if (artistName) { - frames.push(createTextFrame('TPE1', artistName)); - } - - if (metadata.album?.title) { - frames.push(createTextFrame('TALB', metadata.album.title)); - } - - const albumArtistName = metadata.album?.artist?.name || metadata.artist?.name || metadata.artists?.[0]?.name; - if (albumArtistName) { - frames.push(createTextFrame('TPE2', albumArtistName)); - } - - if (metadata.trackNumber) { - frames.push(createTextFrame('TRCK', metadata.trackNumber.toString())); - } - - if (metadata.album?.releaseDate) { - const year = new Date(metadata.album.releaseDate).getFullYear(); - if (!Number.isNaN(year) && Number.isFinite(year)) { - frames.push(createTextFrame('TYER', year.toString())); - } - } - - if (metadata.isrc) { - frames.push(createTextFrame('TSRC', metadata.isrc)); - } - - if (metadata.copyright) { - frames.push(createTextFrame('TCOP', metadata.copyright)); - } - - frames.push(createTextFrame('TENC', 'Monochrome')); - - if (coverBlob) { - frames.push(await createAPICFrame(coverBlob)); - } - - return buildID3v2Tag(mp3Blob, frames); -} - -function createTextFrame(frameId, text) { - // ID3v2.3 UTF-16 encoding with BOM - const bom = new Uint8Array([0xff, 0xfe]); // UTF-16LE BOM - const utf16Bytes = new Uint8Array(text.length * 2); - - for (let i = 0; i < text.length; i++) { - const charCode = text.charCodeAt(i); - utf16Bytes[i * 2] = charCode & 0xff; - utf16Bytes[i * 2 + 1] = (charCode >> 8) & 0xff; - } - - const frameSize = 1 + bom.length + utf16Bytes.length; - const frame = new Uint8Array(10 + frameSize); - const view = new DataView(frame.buffer); - - for (let i = 0; i < 4; i++) { - frame[i] = frameId.charCodeAt(i); - } - - view.setUint32(4, frameSize, false); - - frame[10] = 0x01; // UTF-16 with BOM - - frame.set(bom, 11); - frame.set(utf16Bytes, 11 + bom.length); - - return frame; -} - -async function createAPICFrame(coverBlob) { - const imageBytes = new Uint8Array(await coverBlob.arrayBuffer()); - const mimeType = coverBlob.type || 'image/jpeg'; - const mimeBytes = new TextEncoder().encode(mimeType); - - const frameSize = 1 + mimeBytes.length + 1 + 1 + 1 + imageBytes.length; - - const frame = new Uint8Array(10 + frameSize); - const view = new DataView(frame.buffer); - - for (let i = 0; i < 4; i++) { - frame[i] = 'APIC'.charCodeAt(i); - } - - view.setUint32(4, frameSize, false); - - let offset = 10; - frame[offset++] = 0x00; - - frame.set(mimeBytes, offset); - offset += mimeBytes.length; - frame[offset++] = 0x00; - - frame[offset++] = 0x03; - - frame[offset++] = 0x00; - - frame.set(imageBytes, offset); - - return frame; -} - -function buildID3v2Tag(mp3Blob, frames) { - const framesData = new Uint8Array(frames.reduce((acc, f) => acc + f.length, 0)); - let offset = 0; - for (const frame of frames) { - framesData.set(frame, offset); - offset += frame.length; - } - - const tagSize = framesData.length; - - const header = new Uint8Array(10); - header[0] = 0x49; - header[1] = 0x44; - header[2] = 0x33; - header[3] = 0x03; - header[4] = 0x00; - header[5] = 0x00; - - header[6] = (tagSize >> 21) & 0x7f; - header[7] = (tagSize >> 14) & 0x7f; - header[8] = (tagSize >> 7) & 0x7f; - header[9] = tagSize & 0x7f; - - return new Blob([header, framesData, mp3Blob], { type: 'audio/mpeg' }); -} - -export async function addMp3Metadata(mp3Blob, track, api) { - try { - let coverBlob = null; - - if (track.album?.cover) { - try { - coverBlob = await getCoverBlob(api, track.album.cover); - } catch (error) { - console.warn('Failed to fetch album art for MP3:', error); - } - } - - return await writeID3v2Tag(mp3Blob, track, coverBlob); - } catch (error) { - console.error('Failed to add MP3 metadata:', error); - return mp3Blob; - } -} diff --git a/js/metadata.flac.js b/js/metadata.flac.js new file mode 100644 index 0000000..35e4271 --- /dev/null +++ b/js/metadata.flac.js @@ -0,0 +1,588 @@ +import { getCoverBlob, getTrackTitle } from './utils.js'; +import { getFullArtistString } from './utils.js'; +import { METADATA_STRINGS } from './metadata.js'; + +export const FLAC_MIME_TYPE = 'audio/flac'; + +export 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 streamInfo = blocks.find((b) => b.type === 0); + + 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 (upperKey === 'ISRC') metadata.isrc = value; + if (upperKey === 'COPYRIGHT') metadata.copyright = value; + if (upperKey === 'ITUNESADVISORY') metadata.explicit = value === '1'; + } + } + } + + if (streamInfo) { + const offset = streamInfo.offset; + + // Sample Rate is 20 bits spanning bytes 10, 11, and the first 4 bits of 12 + const byte10 = dataView.getUint8(offset + 10); + const byte11 = dataView.getUint8(offset + 11); + const byte12 = dataView.getUint8(offset + 12); + + // since data for some reason spans across multiple bytes, we need to combine them into one int + const sampleRate = (byte10 << 12) | (byte11 << 4) | (byte12 >> 4); + + const byte13 = dataView.getUint8(offset + 13); + const tsHigh = byte13 & 0x0f; + const tsLow = dataView.getUint32(offset + 14, false); + + // same thing for total samples + const totalSamples = tsHigh * 0x100000000 + tsLow; + + if (sampleRate > 0) { + // beatiful + metadata.duration = totalSamples / sampleRate; + } + } + + 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); + } + } +} + +/** + * Adds Vorbis comment metadata to FLAC files + */ +export 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; + } +} + +export 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' +} + +export 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; +} + +export function createVorbisComments(track) { + // Vorbis comment structure + const comments = []; + const discNumber = track.volumeNumber ?? track.discNumber; + + // Add standard tags + if (track.title) { + comments.push(['TITLE', getTrackTitle(track)]); + } + const artistStr = getFullArtistString(track); + if (artistStr) { + comments.push(['ARTIST', artistStr]); + } + 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 (discNumber) { + comments.push(['DISCNUMBER', String(discNumber)]); + } + 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)]); + if (albumPeakAmplitude) comments.push(['REPLAYGAIN_ALBUM_PEAK', String(albumPeakAmplitude)]); + if (trackReplayGain) comments.push(['REPLAYGAIN_TRACK_GAIN', String(trackReplayGain)]); + if (trackPeakAmplitude) comments.push(['REPLAYGAIN_TRACK_PEAK', String(trackPeakAmplitude)]); + } + + 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]); + } + if (track.explicit) { + comments.push(['ITUNESADVISORY', '1']); + } + + return comments; +} + +export function createVorbisCommentBlock(comments = []) { + // Calculate total size + const vendor = METADATA_STRINGS.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; +} + +export 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; + } +} + +export function rebuildFlacWithMetadata( + dataView, + blocks, + vorbisCommentBlock = createVorbisCommentBlock(), + 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 + } + + if (vorbisCommentBlock) { + // 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; + } + + let lastBlockHeaderOffset = offset; + + if (vorbisCommentBlock) { + // 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; + 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; +} + +export function getFlacBlocks(dataView) { + // Verify FLAC signature + if (!isFlacFile(dataView)) { + throw new Error('Not a valid FLAC file'); + } + + // 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) { + throw new Error('Failed to parse FLAC blocks'); + } + + // Check for STREAMINFO block (must be first, type 0) + if (blocks[0].type !== 0) { + throw new Error('FLAC file missing STREAMINFO block'); + } + + return blocks; +} + +/** + * Removes all metadata from a FLAC file blob and returns the rebuilt FLAC data. + * + * @async + * @param {Blob} flacBlob - The FLAC audio file as a Blob object + * @returns {Promise} A Promise that resolves to a new Blob containing the FLAC file without metadata, + * or the original flacBlob if an error occurs during processing + * @throws {Error} Logs errors to console but catches and returns original blob instead of throwing + * + * @example + * const flacFile = new Blob([arrayBuffer], { type: 'audio/flac' }); + * const cleanFlac = await rebuildFlacWithoutMetadata(flacFile); + */ +export async function rebuildFlacWithoutMetadata(flacBlob) { + try { + const arrayBuffer = await flacBlob.arrayBuffer(); + const dataView = new DataView(arrayBuffer); + const blocks = getFlacBlocks(dataView); + return new Blob([rebuildFlacWithMetadata(dataView, blocks, createVorbisCommentBlock(), null)], { + type: FLAC_MIME_TYPE, + }); + } catch (err) { + console.error('Error rebuilding FLAC file:', err); + return flacBlob; + } +} diff --git a/js/metadata.js b/js/metadata.js index e09320a..c6c37ec 100644 --- a/js/metadata.js +++ b/js/metadata.js @@ -1,46 +1,14 @@ -import { getCoverBlob, getTrackTitle } from './utils.js'; +import { getCoverBlob, getTrackTitle, getFullArtistString, getMimeType } from './utils.js'; import { fetchTagLib, addMetadataWithTagLib, getMetadataWithTagLib } from './taglib.ts'; import { doTimed, doTimedAsync } from './doTimed.ts'; import { managers } from './app.js'; -const VENDOR_STRING = 'Monochrome'; -const DEFAULT_TITLE = 'Unknown Title'; -const DEFAULT_ARTIST = 'Unknown Artist'; -const DEFAULT_ALBUM = 'Unknown Album'; - -/** - * Builds a full artist string by combining the track's listed artists - * with any featured artists parsed from the title (feat./with). - */ -function getFullArtistString(track) { - const knownArtists = - Array.isArray(track.artists) && track.artists.length > 0 - ? track.artists.map((a) => (typeof a === 'string' ? a : a.name) || '').filter(Boolean) - : track.artist?.name - ? [track.artist.name] - : []; - - // Parse featured artists from title, e.g. "Song (feat. A, B & C)" or "(with X & Y)" - // Note: splitting on '&' may incorrectly fragment compound artist names like "Simon & Garfunkel". - const featPattern = /\(\s*(?:feat\.?|ft\.?|with)\s+(.+?)\s*\)/gi; - const allFeatArtists = [...(track.title?.matchAll(featPattern) ?? [])].flatMap((m) => - m[1] - .split(/\s*[,&]\s*/) - .map((s) => s.trim()) - .filter(Boolean) - ); - if (allFeatArtists.length > 0) { - const knownLower = new Set(knownArtists.map((n) => n.toLowerCase())); - for (const feat of allFeatArtists) { - if (!knownLower.has(feat.toLowerCase())) { - knownArtists.push(feat); - knownLower.add(feat.toLowerCase()); - } - } - } - - return knownArtists.join('; ') || null; -} +export const METADATA_STRINGS = { + VENDOR_STRING: 'Monochrome', + DEFAULT_TITLE: 'Unknown Title', + DEFAULT_ARTIST: 'Unknown Artist', + DEFAULT_ALBUM: 'Unknown Album', +}; export function prefetchMetadataObjects(track, api) { const _tagLib = fetchTagLib().catch(console.error); @@ -137,6 +105,10 @@ export async function addMetadataToAudio(audioBlob, track, api, _quality, prefet console.warn('Error setting lyrics metadata', track, e); } + if (!(audioBuffer instanceof Uint8Array)) { + throw new Error('Audio buffer is not a Uint8Array'); + } + const newAudioBuffer = await addMetadataWithTagLib(audioBuffer, { ...data, }); @@ -227,519 +199,3 @@ export async function readTrackMetadata(file, siblings = []) { 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 streamInfo = blocks.find((b) => b.type === 0); - - 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 (upperKey === 'ISRC') metadata.isrc = value; - if (upperKey === 'COPYRIGHT') metadata.copyright = value; - if (upperKey === 'ITUNESADVISORY') metadata.explicit = value === '1'; - } - } - } - - if (streamInfo) { - const offset = streamInfo.offset; - - // Sample Rate is 20 bits spanning bytes 10, 11, and the first 4 bits of 12 - const byte10 = dataView.getUint8(offset + 10); - const byte11 = dataView.getUint8(offset + 11); - const byte12 = dataView.getUint8(offset + 12); - - // since data for some reason spans across multiple bytes, we need to combine them into one int - const sampleRate = (byte10 << 12) | (byte11 << 4) | (byte12 >> 4); - - const byte13 = dataView.getUint8(offset + 13); - const tsHigh = byte13 & 0x0f; - const tsLow = dataView.getUint32(offset + 14, false); - - // same thing for total samples - const totalSamples = tsHigh * 0x100000000 + tsLow; - - if (sampleRate > 0) { - // beatiful - metadata.duration = totalSamples / sampleRate; - } - } - - 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); - - // mvhd metadata tag - const mvhd = moovAtoms.find((a) => a.type === 'mvhd'); - if (mvhd) { - const mvhdStart = moovStart + mvhd.offset + 8; - const version = view.getUint8(mvhdStart); - - // resolution and length, basically - let timeScale, duration; - - if (version === 0) { - // 32-bit format - timeScale = view.getUint32(mvhdStart + 12, false); - duration = view.getUint32(mvhdStart + 16, false); - } else if (version === 1) { - // 64-bit format - timeScale = view.getUint32(mvhdStart + 20, false); - const durHigh = view.getUint32(mvhdStart + 24, false); - const durLow = view.getUint32(mvhdStart + 28, false); - duration = durHigh * 0x100000000 + durLow; - } - - if (timeScale > 0) { - metadata.duration = duration / timeScale; - } - } - - 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 === 'ISRC') { - metadata.isrc = new TextDecoder().decode(new Uint8Array(view.buffer, contentOffset, contentLen)); - } else if (item.type === 'cprt') { - metadata.copyright = 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); - } else if (item.type === 'rtng') { - metadata.explicit = - contentLen > 0 && new Uint8Array(view.buffer, contentOffset, contentLen)[0] === 1; - } - } - } - - 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 === 'TSRC') metadata.isrc = readID3Text(frameData); - if (frameId === 'TCOP') metadata.copyright = readID3Text(frameData); - if (frameId === 'TLEN') metadata.duration = parseInt(readID3Text(frameData)) / 1000; // usually not present - 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 (!metadata.duration || metadata.duration === 0) { - metadata.duration = await calculateMp3Duration(file, tagSize); - } - } - - 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; - } - } -} - -// since mp3 file don't have metadata about duration, estimating it -// uses evil bitwise magic -async function calculateMp3Duration(file, startOffset) { - const buffer = await file.slice(startOffset, startOffset + 32768).arrayBuffer(); - const view = new DataView(buffer); - const uint8 = new Uint8Array(buffer); - - let offset = 0; - - // finding sync word - while (offset < view.byteLength - 4 && !(uint8[offset] === 0xff && (uint8[offset + 1] & 0xe0) === 0xe0)) { - offset++; - } - if (offset >= view.byteLength - 4) return 0; - - const header = view.getUint32(offset, false); - - // header info - const mpegVer = (header >> 19) & 3; - const brIdx = (header >> 12) & 15; - const srIdx = (header >> 10) & 3; - - // Reject invalid headers - if (mpegVer === 1 || brIdx === 0 || brIdx === 15 || srIdx === 3) return 0; - - const sampleRates = [[11025, 12000, 8000], null, [22050, 24000, 16000], [44100, 48000, 32000]]; - const brMpeg1 = [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0]; - const brMpeg2 = [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0]; - - const sampleRate = sampleRates[mpegVer][srIdx]; - const bitrate = mpegVer === 3 ? brMpeg1[brIdx] : brMpeg2[brIdx]; - - // this xing header is present in many mp3 files and contains total frame count, which allows for accurate duration calculation - const channelMode = (header >> 6) & 3; // mono or stereo - const xingOffset = offset + 4 + (mpegVer === 3 ? (channelMode === 3 ? 17 : 32) : channelMode === 3 ? 9 : 17); // the position of xing header - - if (xingOffset + 8 <= view.byteLength) { - const sig = view.getUint32(xingOffset, false); - if ((sig === 0x58696e67 || sig === 0x496e666f) && view.getUint32(xingOffset + 4, false) & 1) { - const frames = view.getUint32(xingOffset + 8, false); - // basically, duration = frames * samples per frame / sample rate - return (frames * (mpegVer === 3 ? 1152 : 576)) / sampleRate; - } - } - - // if no Xing header, estimate duration from file size and bitrate - return ((file.size - startOffset) * 8) / (bitrate * 1000); -} - -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'; -} - -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 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; -} diff --git a/js/metadata.mp3.js b/js/metadata.mp3.js new file mode 100644 index 0000000..cc9eb3d --- /dev/null +++ b/js/metadata.mp3.js @@ -0,0 +1,346 @@ +import { getCoverBlob, getTrackTitle } from './utils.js'; + +export async function writeID3v2Tag(mp3Blob, metadata, coverBlob = null) { + const frames = []; + + if (metadata.title) { + frames.push(createTextFrame('TIT2', getTrackTitle(metadata))); + } + + const artistName = metadata.artist?.name || metadata.artists?.[0]?.name; + if (artistName) { + frames.push(createTextFrame('TPE1', artistName)); + } + + if (metadata.album?.title) { + frames.push(createTextFrame('TALB', metadata.album.title)); + } + + const albumArtistName = metadata.album?.artist?.name || metadata.artist?.name || metadata.artists?.[0]?.name; + if (albumArtistName) { + frames.push(createTextFrame('TPE2', albumArtistName)); + } + + if (metadata.trackNumber) { + frames.push(createTextFrame('TRCK', metadata.trackNumber.toString())); + } + + if (metadata.album?.releaseDate) { + const year = new Date(metadata.album.releaseDate).getFullYear(); + if (!Number.isNaN(year) && Number.isFinite(year)) { + frames.push(createTextFrame('TYER', year.toString())); + } + } + + if (metadata.isrc) { + frames.push(createTextFrame('TSRC', metadata.isrc)); + } + + if (metadata.copyright) { + frames.push(createTextFrame('TCOP', metadata.copyright)); + } + + frames.push(createTextFrame('TENC', 'Monochrome')); + + if (coverBlob) { + frames.push(await createAPICFrame(coverBlob)); + } + + return buildID3v2Tag(mp3Blob, frames); +} + +export function createTextFrame(frameId, text) { + // ID3v2.3 UTF-16 encoding with BOM + const bom = new Uint8Array([0xff, 0xfe]); // UTF-16LE BOM + const utf16Bytes = new Uint8Array(text.length * 2); + + for (let i = 0; i < text.length; i++) { + const charCode = text.charCodeAt(i); + utf16Bytes[i * 2] = charCode & 0xff; + utf16Bytes[i * 2 + 1] = (charCode >> 8) & 0xff; + } + + const frameSize = 1 + bom.length + utf16Bytes.length; + const frame = new Uint8Array(10 + frameSize); + const view = new DataView(frame.buffer); + + for (let i = 0; i < 4; i++) { + frame[i] = frameId.charCodeAt(i); + } + + view.setUint32(4, frameSize, false); + + frame[10] = 0x01; // UTF-16 with BOM + + frame.set(bom, 11); + frame.set(utf16Bytes, 11 + bom.length); + + return frame; +} + +export async function createAPICFrame(coverBlob) { + const imageBytes = new Uint8Array(await coverBlob.arrayBuffer()); + const mimeType = coverBlob.type || 'image/jpeg'; + const mimeBytes = new TextEncoder().encode(mimeType); + + const frameSize = 1 + mimeBytes.length + 1 + 1 + 1 + imageBytes.length; + + const frame = new Uint8Array(10 + frameSize); + const view = new DataView(frame.buffer); + + for (let i = 0; i < 4; i++) { + frame[i] = 'APIC'.charCodeAt(i); + } + + view.setUint32(4, frameSize, false); + + let offset = 10; + frame[offset++] = 0x00; + + frame.set(mimeBytes, offset); + offset += mimeBytes.length; + frame[offset++] = 0x00; + + frame[offset++] = 0x03; + + frame[offset++] = 0x00; + + frame.set(imageBytes, offset); + + return frame; +} + +export function buildID3v2Tag(mp3Blob, frames) { + const framesData = new Uint8Array(frames.reduce((acc, f) => acc + f.length, 0)); + let offset = 0; + for (const frame of frames) { + framesData.set(frame, offset); + offset += frame.length; + } + + const tagSize = framesData.length; + + const header = new Uint8Array(10); + header[0] = 0x49; + header[1] = 0x44; + header[2] = 0x33; + header[3] = 0x03; + header[4] = 0x00; + header[5] = 0x00; + + header[6] = (tagSize >> 21) & 0x7f; + header[7] = (tagSize >> 14) & 0x7f; + header[8] = (tagSize >> 7) & 0x7f; + header[9] = tagSize & 0x7f; + + return new Blob([header, framesData, mp3Blob], { type: 'audio/mpeg' }); +} + +export async function addMp3Metadata(mp3Blob, track, api) { + try { + let coverBlob = null; + + if (track.album?.cover) { + try { + coverBlob = await getCoverBlob(api, track.album.cover); + } catch (error) { + console.warn('Failed to fetch album art for MP3:', error); + } + } + + return await writeID3v2Tag(mp3Blob, track, coverBlob); + } catch (error) { + console.error('Failed to add MP3 metadata:', error); + return mp3Blob; + } +} + +export 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 === 'TSRC') metadata.isrc = readID3Text(frameData); + if (frameId === 'TCOP') metadata.copyright = readID3Text(frameData); + if (frameId === 'TLEN') metadata.duration = parseInt(readID3Text(frameData)) / 1000; // usually not present + 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 (!metadata.duration || metadata.duration === 0) { + metadata.duration = await calculateMp3Duration(file, tagSize); + } + } + + 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; + } + } +} + +// since mp3 file don't have metadata about duration, estimating it +// uses evil bitwise magic +export async function calculateMp3Duration(file, startOffset) { + const buffer = await file.slice(startOffset, startOffset + 32768).arrayBuffer(); + const view = new DataView(buffer); + const uint8 = new Uint8Array(buffer); + + let offset = 0; + + // finding sync word + while (offset < view.byteLength - 4 && !(uint8[offset] === 0xff && (uint8[offset + 1] & 0xe0) === 0xe0)) { + offset++; + } + if (offset >= view.byteLength - 4) return 0; + + const header = view.getUint32(offset, false); + + // header info + const mpegVer = (header >> 19) & 3; + const brIdx = (header >> 12) & 15; + const srIdx = (header >> 10) & 3; + + // Reject invalid headers + if (mpegVer === 1 || brIdx === 0 || brIdx === 15 || srIdx === 3) return 0; + + const sampleRates = [[11025, 12000, 8000], null, [22050, 24000, 16000], [44100, 48000, 32000]]; + const brMpeg1 = [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0]; + const brMpeg2 = [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0]; + + const sampleRate = sampleRates[mpegVer][srIdx]; + const bitrate = mpegVer === 3 ? brMpeg1[brIdx] : brMpeg2[brIdx]; + + // this xing header is present in many mp3 files and contains total frame count, which allows for accurate duration calculation + const channelMode = (header >> 6) & 3; // mono or stereo + const xingOffset = offset + 4 + (mpegVer === 3 ? (channelMode === 3 ? 17 : 32) : channelMode === 3 ? 9 : 17); // the position of xing header + + if (xingOffset + 8 <= view.byteLength) { + const sig = view.getUint32(xingOffset, false); + if ((sig === 0x58696e67 || sig === 0x496e666f) && view.getUint32(xingOffset + 4, false) & 1) { + const frames = view.getUint32(xingOffset + 8, false); + // basically, duration = frames * samples per frame / sample rate + return (frames * (mpegVer === 3 ? 1152 : 576)) / sampleRate; + } + } + + // if no Xing header, estimate duration from file size and bitrate + return ((file.size - startOffset) * 8) / (bitrate * 1000); +} + +export 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) + ); +} + +export 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, ''); +} diff --git a/js/metadata.mp4.js b/js/metadata.mp4.js new file mode 100644 index 0000000..21e6682 --- /dev/null +++ b/js/metadata.mp4.js @@ -0,0 +1,846 @@ +import { getCoverBlob, getTrackTitle, getMimeType, getFullArtistString } from './utils.js'; +import { METADATA_STRINGS } from './metadata.js'; + +const { DEFAULT_TITLE, DEFAULT_ARTIST, DEFAULT_ALBUM } = METADATA_STRINGS; + +export 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); + + // mvhd metadata tag + const mvhd = moovAtoms.find((a) => a.type === 'mvhd'); + if (mvhd) { + const mvhdStart = moovStart + mvhd.offset + 8; + const version = view.getUint8(mvhdStart); + + // resolution and length, basically + let timeScale, duration; + + if (version === 0) { + // 32-bit format + timeScale = view.getUint32(mvhdStart + 12, false); + duration = view.getUint32(mvhdStart + 16, false); + } else if (version === 1) { + // 64-bit format + timeScale = view.getUint32(mvhdStart + 20, false); + const durHigh = view.getUint32(mvhdStart + 24, false); + const durLow = view.getUint32(mvhdStart + 28, false); + duration = durHigh * 0x100000000 + durLow; + } + + if (timeScale > 0) { + metadata.duration = duration / timeScale; + } + } + + 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 === 'ISRC') { + metadata.isrc = new TextDecoder().decode(new Uint8Array(view.buffer, contentOffset, contentLen)); + } else if (item.type === 'cprt') { + metadata.copyright = 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); + } else if (item.type === 'rtng') { + metadata.explicit = + contentLen > 0 && new Uint8Array(view.buffer, contentOffset, contentLen)[0] === 1; + } + } + } + + if (artistStr) { + metadata.artists = artistStr.split(/; |\/|\\/).map((name) => ({ name: name.trim() })); + } + } catch (e) { + console.warn('Error parsing M4A:', e); + } +} + +/** + * Adds metadata to M4A files using MP4 atoms + */ +export 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; + } +} + +export 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; +} + +export function createMp4MetadataAtoms(track) { + // MP4 metadata atoms are more complex than FLAC + // We'll create basic iTunes-style metadata + + /** + * Array of arrays: [namespace, name, value] + */ + const userTags = []; + const tags = { + '©nam': getTrackTitle(track) || DEFAULT_TITLE, + '©ART': getFullArtistString(track) || DEFAULT_ARTIST, + '©alb': track.album?.title || DEFAULT_ALBUM, + aART: track.album?.artist?.name || track.artist?.name || DEFAULT_ARTIST, + }; + + if (track.isrc) { + tags['ISRC'] = track.isrc; + tags['xid '] = ':isrc:' + track.isrc; + } + + if (track.copyright) { + tags['cprt'] = track.copyright; + } + + if (track.trackNumber) { + tags['trkn'] = { + current: track.trackNumber, + total: track.album?.numberOfTracks, + }; + } + if (track.explicit) { + tags['rtng'] = 1; // 1 = Explicit, 2 = Clean, 0 = Unknown + } + + const discNumber = track.volumeNumber ?? track.discNumber; + if (discNumber) { + tags['disk'] = { + current: discNumber, + total: 0, + }; + } + + if (track.bpm) { + tags['tmpo'] = Math.round(track.bpm); + } + + 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 + } + } + + if (track.replayGain) { + const { albumReplayGain, albumPeakAmplitude, trackReplayGain, trackPeakAmplitude } = track.replayGain; + let trackPeakAmplitudeString = String(trackPeakAmplitude); + let albumPeakAmplitudeString = String(albumPeakAmplitude); + + if (trackPeakAmplitudeString.indexOf('.') === -1) { + trackPeakAmplitudeString += '.000000'; + } + if (albumPeakAmplitudeString.indexOf('.') === -1) { + albumPeakAmplitudeString += '.000000'; + } + + if (trackPeakAmplitude) userTags.push(['com.apple.iTunes', 'replaygain_track_peak', trackPeakAmplitudeString]); + if (trackReplayGain) userTags.push(['com.apple.iTunes', 'replaygain_track_gain', `${trackReplayGain} dB`]); + if (albumPeakAmplitude) userTags.push(['com.apple.iTunes', 'replaygain_album_peak', albumPeakAmplitudeString]); + if (albumReplayGain) userTags.push(['com.apple.iTunes', 'replaygain_album_gain', `${albumReplayGain} dB`]); + } + + return { tags, userTags }; +} + +export 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; +} + +export function createMetadataBlock(metadataAtoms) { + const { tags, userTags, cover } = metadataAtoms; + + const ilstChildren = []; + + // Text tags + for (const [key, value] of Object.entries(tags)) { + if (key === 'trkn' || key === 'disk') { + ilstChildren.push(createIntAtom(key, value)); + } else if (key === 'rtng') { + ilstChildren.push(createUintAtom(key, value, 1)); + } else if (key === 'tmpo') { + ilstChildren.push(createUintAtom(key, value, 2)); + } else { + ilstChildren.push(createStringAtom(key, value)); + } + } + + // User tags + for (const [namespace, name, value] of userTags) { + ilstChildren.push(createUserAtom(namespace, name, 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; +} + +export function createStringAtom(type, value, truncateType = true) { + const typeLength = truncateType ? 4 : type.length; + const textBytes = new TextEncoder().encode(value); + const dataSize = 16 + textBytes.length; // 8 (data atom header) + 8 (flags/null) + text + const atomSize = 4 + typeLength + dataSize; + + const buf = new Uint8Array(atomSize); + let offset = 0; + + // Wrapper atom (e.g., ©nam) + writeAtomHeader(buf, offset, atomSize, type, truncateType); + offset += 4 + typeLength; + + // 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; +} + +export function createUserAtom(namespace, name, value) { + const encoder = new TextEncoder(); + const dashBytes = encoder.encode('----'); // User-defined atom type + const namespaceBytes = encoder.encode(namespace); + const meanBytes = encoder.encode('mean'); // Standard 'mean' atom for namespace + const nameBytes = encoder.encode(name); + const valueBytes = encoder.encode('\x00\x00\x00\x01\x00\x00\x00\x00' + value); + + /** + * Atom structure: + * [----] (atom header) + * [mean] (namespace) + * [name] (name) + * [data] (value) + */ + const atomSize = 8 + 12 + namespaceBytes.length + 12 + nameBytes.length + 8 + valueBytes.length; + + const buf = new Uint8Array(atomSize); + let offset = 0; + writeAtomHeader(buf, offset, atomSize, '----'); + offset += 8; // Skip header + writeAtomHeader(buf, offset, namespaceBytes.length + 12, 'mean'); + offset += 12; + buf.set(namespaceBytes, offset); + offset += namespaceBytes.length; + writeAtomHeader(buf, offset, nameBytes.length + 12, 'name'); + offset += 12; + buf.set(nameBytes, offset); + offset += nameBytes.length; + writeAtomHeader(buf, offset, valueBytes.length + 8, 'data'); + offset += 8; + buf.set(valueBytes, offset); + + return buf; +} + +/** + * 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 ] + * + * // Fixed length with padding + * toBigEndianBytes(1, 4); // Uint8Array [ 0, 0, 0, 1 ] + * + * // With BigInt + * toBigEndianBytes(0xDEADBEEFn, 4); // Uint8Array [ 222, 173, 190, 239 ] + */ +export 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); +} + +export 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, key); + offset += 8; + + // Data atom + writeAtomHeader(buf, offset, dataSize, 'data'); + offset += 8; + + // Data Type ((Big Endian Unsigned Integer) + Locale (0)) + buf[offset++] = 0; + buf[offset++] = 0; + buf[offset++] = 0; + buf[offset++] = 21; // Type 21 + buf[offset++] = 0; + buf[offset++] = 0; + buf[offset++] = 0; + buf[offset++] = 0; + buf.set(numberBytes, offset++); + + return buf; +} + +export function createIntAtom(type, value) { + // trkn/disk are 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; + + const current = typeof value === 'object' ? value.current : value; + const total = typeof value === 'object' ? value.total : 0; + + // Numbering payload (track/disc number + total) + buf[offset++] = 0; + buf[offset++] = 0; + const numberValue = parseInt(current, 10) || 0; + buf[offset++] = (numberValue >> 8) & 0xff; + buf[offset++] = numberValue & 0xff; + const totalValue = parseInt(total, 10) || 0; + buf[offset++] = (totalValue >> 8) & 0xff; + buf[offset++] = totalValue & 0xff; + buf[offset++] = 0; + buf[offset++] = 0; + + return buf; +} + +export 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; +} + +/** + * Creates an atom header for MP4 metadata. + * @param {number} size - The size of the atom in bytes. + * @param {string} type - The 4-character atom type identifier. + * @param {boolean} [truncate=false] - Whether to truncate the type to 4 characters or use full length. + * @returns {Uint8Array} A byte array containing the atom header with size and type information. + */ +export function getAtomHeader(size, type, truncate = false) { + const buf = new Uint8Array(4 + (truncate ? 4 : type.length)); + buf[0] = (size >> 24) & 0xff; + buf[1] = (size >> 16) & 0xff; + buf[2] = (size >> 8) & 0xff; + buf[3] = size & 0xff; + + for (let i = 0; i < (truncate ? 4 : type.length); i++) { + buf[4 + i] = type.charCodeAt(i); + } + + return buf; +} + +/** + * Writes an atom header to a buffer at the specified offset. + * @param {Uint8Array} buf - The buffer to write the atom header to. + * @param {number} offset - The offset in the buffer where the atom header should be written. + * @param {number} size - The size of the atom. + * @param {string} type - The type of the atom (typically a 4-character code). + * @param {boolean} [truncate=true] - Whether to truncate the atom header. Defaults to true. + * @returns {void} + */ +export function writeAtomHeader(buf, offset, size, type, truncate = true) { + buf.set(getAtomHeader(size, type, truncate), offset); +} + +export 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); +} + +export 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; + } +} diff --git a/js/taglib.worker.ts b/js/taglib.worker.ts index 0ef3891..cd04a09 100644 --- a/js/taglib.worker.ts +++ b/js/taglib.worker.ts @@ -303,22 +303,28 @@ async function getMetadataFromAudio(message: GetMetadataMessage): Promise) => { + const transfer: Transferable[] = [event.data.audioData.buffer]; + switch (event.data.type) { case 'Add': try { const result = await addMetadataToAudio(event.data as AddMetadataMessage); + transfer.push(result.buffer); self.postMessage( { type: event.data.type, data: result, } satisfies TagLibFileResponse, - [result.buffer, event.data.audioData.buffer] + transfer ); } catch (error) { - self.postMessage({ - type: event.data.type, - error: error instanceof Error ? error.message : String(error), - } satisfies TagLibWorkerResponse); + self.postMessage( + { + type: event.data.type, + error: error instanceof Error ? error.message : String(error), + } satisfies TagLibWorkerResponse, + transfer + ); } break; @@ -330,13 +336,16 @@ self.onmessage = async (event: MessageEvent) => { type: event.data.type, data: result, } satisfies TagLibMetadataResponse, - [event.data.audioData.buffer] + transfer ); } catch (error) { - self.postMessage({ - type: event.data.type, - error: error instanceof Error ? error.message : String(error), - } satisfies TagLibWorkerResponse); + self.postMessage( + { + type: event.data.type, + error: error instanceof Error ? error.message : String(error), + } satisfies TagLibWorkerResponse, + transfer + ); } break; } diff --git a/js/utils.js b/js/utils.js index 84b7711..edf1e93 100644 --- a/js/utils.js +++ b/js/utils.js @@ -557,6 +557,40 @@ export const getShareUrl = (path) => { return `${baseUrl}${safePath}`; }; +/** + * Builds a full artist string by combining the track's listed artists + * with any featured artists parsed from the title (feat./with). + */ +export function getFullArtistString(track) { + const knownArtists = + Array.isArray(track.artists) && track.artists.length > 0 + ? track.artists.map((a) => (typeof a === 'string' ? a : a.name) || '').filter(Boolean) + : track.artist?.name + ? [track.artist.name] + : []; + + // Parse featured artists from title, e.g. "Song (feat. A, B & C)" or "(with X & Y)" + // Note: splitting on '&' may incorrectly fragment compound artist names like "Simon & Garfunkel". + const featPattern = /\(\s*(?:feat\.?|ft\.?|with)\s+(.+?)\s*\)/gi; + const allFeatArtists = [...(track.title?.matchAll(featPattern) ?? [])].flatMap((m) => + m[1] + .split(/\s*[,&]\s*/) + .map((s) => s.trim()) + .filter(Boolean) + ); + if (allFeatArtists.length > 0) { + const knownLower = new Set(knownArtists.map((n) => n.toLowerCase())); + for (const feat of allFeatArtists) { + if (!knownLower.has(feat.toLowerCase())) { + knownArtists.push(feat); + knownLower.add(feat.toLowerCase()); + } + } + } + + return knownArtists.join('; ') || null; +} + export function fetchBlob(url) { return fetch(url).then((d) => d.blob()); } @@ -564,3 +598,10 @@ export function fetchBlob(url) { export async function fetchBlobURL(url) { return await URL.createObjectURL(await fetchBlob(url)); } + +export 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'; +}