From efa933863dce100b7b67f7973d709d9b1f2f73de Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Thu, 4 Sep 2025 21:28:23 +0200 Subject: [PATCH] Add metadata read/write support to ISOBMFF, add more metadata fields, add raw metadata support --- dev/convert.html | 18 +- src/isobmff/isobmff-boxes.ts | 356 +++++++++++++++++++++----- src/isobmff/isobmff-demuxer.ts | 439 ++++++++++++++++++++++++++++++--- src/isobmff/isobmff-muxer.ts | 12 +- src/isobmff/isobmff-reader.ts | 31 ++- src/metadata.ts | 61 ++++- src/misc.ts | 18 ++ src/mp3/mp3-muxer.ts | 149 ++++++++--- src/mp3/mp3-reader.ts | 57 +++-- 9 files changed, 967 insertions(+), 174 deletions(-) diff --git a/dev/convert.html b/dev/convert.html index 3f2ae08..10e164b 100644 --- a/dev/convert.html +++ b/dev/convert.html @@ -18,15 +18,13 @@ const file = fileInput.files[0]; const source = new Mediabunny.BlobSource(file); - const target = new Mediabunny.NullTarget() ?? new Mediabunny.BufferTarget() ?? new Mediabunny.StreamTarget(new WritableStream({ + const target = new Mediabunny.BufferTarget() ?? new Mediabunny.StreamTarget(new WritableStream({ write: console.log }), { chunked: true, chunkSize: 2**20 }); - const outputFormat = new Mediabunny.Mp4OutputFormat({ - onMoov: console.log - }); + const outputFormat = new Mediabunny.Mp4OutputFormat({}); const button = document.createElement('button'); button.textContent = 'Cancel'; @@ -57,7 +55,14 @@ kind: 'coverFront', mimeType: 'image/jpeg' }], - lyrics: "There's no way\nThat it's not going there" + trackNumber: 4, + trackNumberMax: 10, + discNumber: 5, + discNumberMax: 8, + lyrics: "There's no way\nThat it's not going there", + raw: { + //'TBRU': 'this is a BRUH metadatum', + } }); const conversion = await Mediabunny.Conversion.init({ @@ -100,6 +105,7 @@ }, */ video: () => ({ + discard: true, //codec: 'avc', //fit: 'contain', //frameRate: 27.123, @@ -123,7 +129,7 @@ }), trim: { start: 0, - end: 20 + end: 1 }, }); console.log(conversion); diff --git a/src/isobmff/isobmff-boxes.ts b/src/isobmff/isobmff-boxes.ts index e25824a..f42e92e 100644 --- a/src/isobmff/isobmff-boxes.ts +++ b/src/isobmff/isobmff-boxes.ts @@ -18,6 +18,8 @@ import { MATRIX_COEFFICIENTS_MAP, colorSpaceIsComplete, UNDETERMINED_LANGUAGE, + assertNever, + keyValueIterator, } from '../misc'; import { AudioCodec, @@ -35,12 +37,14 @@ import { GLOBAL_TIMESCALE, intoTimescale, IsobmffAudioTrackData, + IsobmffMuxer, IsobmffSubtitleTrackData, IsobmffTrackData, IsobmffVideoTrackData, Sample, } from './isobmff-muxer'; import { parseOpusIdentificationHeader } from '../codec-data'; +import { MediaMetadata, RichImageData } from '../metadata'; export class IsobmffBoxWriter { private helper = new Uint8Array(8); @@ -331,13 +335,13 @@ export const mdat = (reserveLargeSize: boolean): Box => ({ type: 'mdat', largeSi * an application to interpret the sample data that is stored elsewhere. */ export const moov = ( - trackDatas: IsobmffTrackData[], - creationTime: number, + muxer: IsobmffMuxer, fragmented = false, ) => box('moov', undefined, [ - mvhd(creationTime, trackDatas), - ...trackDatas.map(x => trak(x, creationTime)), - fragmented ? mvex(trackDatas) : null, + mvhd(muxer.creationTime, muxer.trackDatas), + ...muxer.trackDatas.map(x => trak(x, muxer.creationTime)), + fragmented ? mvex(muxer.trackDatas) : null, + udta(muxer), ]); /** Movie Header Box: Used to specify the characteristics of the entire movie, such as timescale and duration. */ @@ -387,7 +391,7 @@ export const trak = (trackData: IsobmffTrackData, creationTime: number) => { mdia(trackData, creationTime), trackMetadata.name !== undefined ? box('udta', undefined, [ - box('©nam', [ + box('name', [ // VLC (and Mediabunny) also recognize ©nam ...textEncoder.encode(trackMetadata.name), ]), ]) @@ -395,57 +399,6 @@ export const trak = (trackData: IsobmffTrackData, creationTime: number) => { ]); }; -/* -const meta = (trackData: IsobmffTrackData) => { - const trackMetadata = getTrackMetadata(trackData); - - if (trackData.muxer.isQuickTime) { - const keyMap: Record = { - name: 'com.apple.quicktime.title', - }; - - return box('meta', undefined, [ - hdlr(false, 'mdta', ''), - fullBox('keys', 0, 0, [ - u32(Object.keys(trackMetadata).length), - ], Object.keys(trackMetadata).map(key => - box('mdta', [ - ascii(keyMap[key as keyof IsobmffMetadata]), // Key name - ]), - )), - box('ilst', undefined, Object.values(trackMetadata) - .map((value, i) => box(u32(i + 1).map(x => String.fromCharCode(x)).join(''), undefined, [ - data(value), - ]))), - ]); - } else { - const keyMap: Record = { - name: '©nam', - }; - - return fullBox('meta', 0, 0, undefined, [ - hdlr(false, 'mdir', ''), - box('ilst', undefined, Object.entries(trackMetadata) - .map(([key, value]) => box(keyMap[key as keyof IsobmffMetadata], undefined, [ - data(value), - ]))), - ]); - } -}; - -const data = (value: unknown) => { - if (typeof value === 'string') { - return box('data', [ - u32(1), // Type indicator (UTF-8) - u32(0), // Locale indicator - ...textEncoder.encode(value), - ]); - } - - throw new Error('Unhandled data type.'); -}; -*/ - /** Track Header Box: Specifies the characteristics of a single track within a movie. */ export const tkhd = ( trackData: IsobmffTrackData, @@ -506,18 +459,12 @@ export const mdhd = ( const needsU64 = !isU32(creationTime) || !isU32(localDuration); const u32OrU64 = needsU64 ? u64 : u32; - let language = 0; - for (const character of (trackData.track.metadata.languageCode ?? UNDETERMINED_LANGUAGE)) { - language <<= 5; - language += character.charCodeAt(0) - 0x60; - } - return fullBox('mdhd', +needsU64, 0, [ u32OrU64(creationTime), // Creation time u32OrU64(creationTime), // Modification time u32(trackData.timescale), // Timescale u32OrU64(localDuration), // Duration - u16(language), // Language + u16(getLanguageCodeInt(trackData.track.metadata.languageCode ?? UNDETERMINED_LANGUAGE)), // Language u16(0), // Quality ]); }; @@ -535,10 +482,15 @@ const TRACK_TYPE_TO_HANDLER_NAME: Record = { }; /** Handler Reference Box. */ -export const hdlr = (hasComponentType: boolean, handlerType: string, name: string) => fullBox('hdlr', 0, 0, [ +export const hdlr = ( + hasComponentType: boolean, + handlerType: string, + name: string, + manufacturer = '\0\0\0\0', +) => fullBox('hdlr', 0, 0, [ hasComponentType ? ascii('mhlr') : u32(0), // Component type ascii(handlerType), // Component subtype - u32(0), // Component manufacturer + ascii(manufacturer), // Component manufacturer u32(0), // Component flags u32(0), // Component flags mask ascii(name, true), // Component name @@ -1318,6 +1270,266 @@ export const vttc = ( /** VTT Additional Text Box */ export const vtta = (notes: string) => box('vtta', [...textEncoder.encode(notes)]); +/** User Data Box */ +const udta = (muxer: IsobmffMuxer) => { + const boxes: Box[] = []; + + // Depending on the format, metadata is written differently + if (muxer.isQuickTime) { + addQuickTimeMetadataboxes(boxes, muxer.output._metadata); + } else { + const metaBox = meta(muxer.output._metadata); + if (metaBox) { + boxes.push(metaBox); + } + } + + if (boxes.length === 0) { + return null; + } + + return box('udta', undefined, boxes); +}; + +const addQuickTimeMetadataboxes = (boxes: Box[], metadata: MediaMetadata) => { + // https://exiftool.org/TagNames/QuickTime.html (QuickTime UserData Tags) + // For QuickTime files, metadata is dumped into the udta box + + for (const { key, value } of keyValueIterator(metadata)) { + switch (key) { + case 'title': { + boxes.push(metadataStringBoxShort('©nam', value)); + }; break; + + case 'description': { + boxes.push(metadataStringBoxShort('©des', value)); + }; break; + + case 'artist': { + boxes.push(metadataStringBoxShort('©ART', value)); + }; break; + + case 'album': { + boxes.push(metadataStringBoxShort('©alb', value)); + }; break; + + case 'albumArtist': { + boxes.push(metadataStringBoxShort('albr', value)); + }; break; + + case 'genre': { + boxes.push(metadataStringBoxShort('©gen', value)); + }; break; + + case 'date': { + boxes.push(metadataStringBoxShort('©day', value.toISOString().split('T')[0]!)); + }; break; + + case 'comment': { + boxes.push(metadataStringBoxShort('©cmt', value)); + }; break; + + case 'lyrics': { + boxes.push(metadataStringBoxShort('©lyr', value)); + }; break; + + case 'raw': { + // Handled later + }; break; + + case 'discNumber': + case 'discNumberMax': + case 'trackNumber': + case 'trackNumberMax': + case 'images': { + // Not written for QuickTime (common Apple L) + }; break; + + default: assertNever(key); + } + } + + if (metadata.raw) { + for (const key in metadata.raw) { + const value = metadata.raw[key]; + if (value == null || key.length !== 4 || boxes.some(x => x.type === key)) { + continue; + } + + if (typeof value === 'string') { + boxes.push(metadataStringBoxShort(key, value)); + } else if (value instanceof Uint8Array) { + boxes.push(box(key, Array.from(value))); + } + } + } +}; + +const metadataStringBoxShort = (name: string, value: string) => { + const encoded = textEncoder.encode(value); + + return box(name, [ + u16(encoded.length), + u16(getLanguageCodeInt('und')), + Array.from(encoded), + ]); +}; + +const DATA_BOX_MIME_TYPE_MAP: Record = { + 'image/jpeg': 13, + 'image/png': 14, + 'image/bmp': 27, +}; + +/** Metadata Box */ +const meta = (metadata: MediaMetadata) => { + const boxes: Box[] = []; + + // https://exiftool.org/TagNames/QuickTime.html (QuickTime ItemList Tags) + // This is the metadata format used for MP4 files + + for (const { key, value } of keyValueIterator(metadata)) { + switch (key) { + case 'title': { + boxes.push(metadataStringBoxLong('©nam', value)); + }; break; + + case 'description': { + boxes.push(metadataStringBoxLong('©des', value)); + }; break; + + case 'artist': { + boxes.push(metadataStringBoxLong('©ART', value)); + }; break; + + case 'album': { + boxes.push(metadataStringBoxLong('©alb', value)); + }; break; + + case 'albumArtist': { + boxes.push(metadataStringBoxLong('aART', value)); + }; break; + + case 'comment': { + boxes.push(metadataStringBoxLong('©cmt', value)); + }; break; + + case 'genre': { + boxes.push(metadataStringBoxLong('©gen', value)); + }; break; + + case 'lyrics': { + boxes.push(metadataStringBoxLong('©lyr', value)); + }; break; + + case 'date': { + boxes.push(metadataStringBoxLong('©day', value.toISOString().split('T')[0]!)); + }; break; + + case 'images': { + for (const image of value) { + if (image.kind !== 'coverFront') { + continue; + } + + boxes.push(box('covr', undefined, [ + box('data', [ + u32(DATA_BOX_MIME_TYPE_MAP[image.mimeType] ?? 0), // Type indicator + u32(0), // Locale indicator + Array.from(image.data), // Kinda slow, hopefully temp + ]), + ])); + } + }; break; + + case 'trackNumber': { + boxes.push(box('trkn', undefined, [ + box('data', [ + u32(0), // 8 bytes empty + u32(0), + u16(0), // Empty + u16(value), + u16(metadata.trackNumberMax ?? 0), + u16(0), // Empty + ]), + ])); + }; break; + + case 'discNumber': { + boxes.push(box('disc', undefined, [ + box('data', [ + u32(0), // 8 bytes empty + u32(0), + u16(0), // Empty + u16(value), + u16(metadata.discNumberMax ?? 0), + u16(0), // Empty + ]), + ])); + }; break; + + case 'trackNumberMax': + case 'discNumberMax':{ + // These are included with 'trackNumber' and 'discNumber' respectively + }; break; + + case 'raw': { + // Handled later + }; break; + + default: assertNever(key); + } + } + + if (metadata.raw) { + for (const key in metadata.raw) { + const value = metadata.raw[key]; + if (value == null || key.length !== 4 || boxes.some(x => x.type === key)) { + continue; + } + + if (typeof value === 'string') { + boxes.push(metadataStringBoxLong(key, value)); + } else if (value instanceof Uint8Array) { + boxes.push(box(key, undefined, [ + box('data', [ + u32(0), // Type indicator + u32(0), // Locale indicator + Array.from(value), + ]), + ])); + } else if (value instanceof RichImageData) { + boxes.push(box(key, undefined, [ + box('data', [ + u32(DATA_BOX_MIME_TYPE_MAP[value.mimeType] ?? 0), // Type indicator + u32(0), // Locale indicator + Array.from(value.data), // Kinda slow, hopefully temp + ]), + ])); + } + } + } + + if (boxes.length === 0) { + return null; + } + + return fullBox('meta', 0, 0, undefined, [ + hdlr(false, 'mdir', '', 'appl'), + box('ilst', undefined, boxes), + ]); +}; + +const metadataStringBoxLong = (name: string, value: string) => { + return box(name, undefined, [ + box('data', [ + u32(1), // Type indicator (UTF-8) + u32(0), // Locale indicator + ...textEncoder.encode(value), + ]), + ]); +}; + const VIDEO_CODEC_TO_BOX_NAME: Record = { avc: 'avc1', hevc: 'hvc1', @@ -1426,3 +1638,15 @@ const SUBTITLE_CODEC_TO_CONFIGURATION_BOX: Record< > = { webvtt: vttC, }; + +const getLanguageCodeInt = (code: string) => { + assert(code.length === 3); ; + + let language = 0; + for (let i = 0; i < 3; i++) { + language <<= 5; + language += code.charCodeAt(i) - 0x60; + } + + return language; +}; diff --git a/src/isobmff/isobmff-demuxer.ts b/src/isobmff/isobmff-demuxer.ts index f43c784..aa6e5ba 100644 --- a/src/isobmff/isobmff-demuxer.ts +++ b/src/isobmff/isobmff-demuxer.ts @@ -57,6 +57,7 @@ import { TransformationMatrix, TRANSFER_CHARACTERISTICS_MAP_INVERSE, UNDETERMINED_LANGUAGE, + toDataView, } from '../misc'; import { EncodedPacket, PLACEHOLDER_DATA } from '../packet'; import { buildIsobmffMimeType } from './isobmff-misc'; @@ -64,9 +65,11 @@ import { MAX_BOX_HEADER_SIZE, MIN_BOX_HEADER_SIZE, readBoxHeader, + readDataBox, readFixed_16_16, readFixed_2_30, readIsomVariableInteger, + readMetadataStringShort, } from './isobmff-reader'; import { FileSlice, @@ -83,6 +86,20 @@ import { readU8, readAscii, } from '../reader'; +import { MediaMetadata, RichImageData } from '../metadata'; + +// https://exiftool.org/TagNames/QuickTime.html +const UDTA_STRING_KEYS = new Set([ + '@day', '@mak', '@mod', '@swr', '@xyz', 'CAME', 'CNCV', 'CNFV', 'CNMN', 'FIRM', 'FOV\0', 'GoPr', 'LENS', 'PXMN', + 'SIGM', 'SNum', 'TAGS', 'albm', 'albr', 'angl', 'auth', 'ccid', 'cdis', 'clfn', 'clid', 'clsf', 'cmid', 'cmnm', + 'coll', 'cprt', 'cver', 'cvru', 'date', 'dscp', 'fsid', 'gnre', 'hinv', 'icnu', 'info', 'infu', 'kgtt', 'loci', + 'lrcu', 'mcvr', 'name', 'perf', 'pmcc', 'reel', 'rtng', 'scen', 'shot', 'slno', 'thmb', 'titl', 'tnam', 'urat', + 'uuid', 'vndr', 'yrrc', '©ART', '©TIM', '©TSC', '©TSZ', '©alb', '©arg', '©ark', '©cmt', '©cok', '©com', '©cpy', + '©day', '©dir', '©ed1', '©ed2', '©ed3', '©ed4', '©ed5', '©ed6', '©ed7', '©ed8', '©ed9', '©enc', '©fmt', '©fpt', + '©frl', '©fyw', '©gen', '©gpt', '©grl', '©grp', '©gyw', '©inf', '©isr', '©lab', '©lal', '©lyr', '©mak', '©mal', + '©mdl', '©mod', '©nam', '©pdk', '©phg', '©prd', '©prf', '©prk', '©prl', '©req', '©snk', '©snm', '©src', '©swf', + '©swk', '©swr', '©too', '©trk', '©wrt', '©xsp', '©xyz', '©ysp', '©zsp', +]); type InternalTrack = { id: number; @@ -232,6 +249,8 @@ export class IsobmffDemuxer extends Demuxer { movieTimescale = -1; movieDurationInTimescale = -1; isQuickTime = false; + metadata: MediaMetadata = {}; + currentMetadataKeys: Map | null = null; isFragmented = false; fragmentTrackDefaults: FragmentTrackDefaults[] = []; @@ -269,6 +288,11 @@ export class IsobmffDemuxer extends Demuxer { }); } + async getMetadata() { + await this.readMetadata(); + return this.metadata; + } + readMetadata() { return this.metadataPromise ??= (async () => { let currentPos = 0; @@ -605,6 +629,22 @@ export class IsobmffDemuxer extends Demuxer { } } + // eslint-disable-next-line @stylistic/generator-star-spacing + *iterateContiguousBoxes(slice: FileSlice) { + const startIndex = slice.filePos; + + while (slice.filePos - startIndex <= slice.length - MIN_BOX_HEADER_SIZE) { + const startPos = slice.filePos; + const boxInfo = readBoxHeader(slice); + if (!boxInfo) { + break; + } + + yield { boxInfo, slice }; + slice.filePos = startPos + boxInfo.totalSize; + } + } + traverseBox(slice: FileSlice): boolean { const startPos = slice.filePos; const boxInfo = readBoxHeader(slice); @@ -620,8 +660,7 @@ export class IsobmffDemuxer extends Demuxer { case 'minf': case 'dinf': case 'mfra': - case 'edts': - case 'udta': { + case 'edts': { this.readContiguousBoxes(slice.slice(contentStartPos, boxInfo.contentSize)); }; break; @@ -683,7 +722,9 @@ export class IsobmffDemuxer extends Demuxer { case 'tkhd': { const track = this.currentTrack; - assert(track); + if (!track) { + break; + } const version = readU8(slice); const flags = readU24Be(slice); @@ -729,7 +770,9 @@ export class IsobmffDemuxer extends Demuxer { case 'elst': { const track = this.currentTrack; - assert(track); + if (!track) { + break; + } const version = readU8(slice); slice.skip(3); // Flags @@ -777,7 +820,9 @@ export class IsobmffDemuxer extends Demuxer { case 'mdhd': { const track = this.currentTrack; - assert(track); + if (!track) { + break; + } const version = readU8(slice); slice.skip(3); // Flags @@ -811,7 +856,9 @@ export class IsobmffDemuxer extends Demuxer { case 'hdlr': { const track = this.currentTrack; - assert(track); + if (!track) { + break; + } slice.skip(8); // Version + flags + pre-defined const handlerType = readAscii(slice, 4); @@ -843,7 +890,9 @@ export class IsobmffDemuxer extends Demuxer { case 'stbl': { const track = this.currentTrack; - assert(track); + if (!track) { + break; + } track.sampleTableByteOffset = startPos; @@ -852,7 +901,9 @@ export class IsobmffDemuxer extends Demuxer { case 'stsd': { const track = this.currentTrack; - assert(track); + if (!track) { + break; + } if (track.info === null || track.sampleTable) { break; @@ -1048,21 +1099,30 @@ export class IsobmffDemuxer extends Demuxer { case 'avcC': { const track = this.currentTrack; - assert(track && track.info); + if (!track) { + break; + } + assert(track.info); track.info.codecDescription = readBytes(slice, boxInfo.contentSize); }; break; case 'hvcC': { const track = this.currentTrack; - assert(track && track.info); + if (!track) { + break; + } + assert(track.info); track.info.codecDescription = readBytes(slice, boxInfo.contentSize); }; break; case 'vpcC': { const track = this.currentTrack; - assert(track && track.info?.type === 'video'); + if (!track) { + break; + } + assert(track.info?.type === 'video'); slice.skip(4); // Version + flags @@ -1090,7 +1150,10 @@ export class IsobmffDemuxer extends Demuxer { case 'av1C': { const track = this.currentTrack; - assert(track && track.info?.type === 'video'); + if (!track) { + break; + } + assert(track.info?.type === 'video'); slice.skip(1); // Marker + version @@ -1124,7 +1187,10 @@ export class IsobmffDemuxer extends Demuxer { case 'colr': { const track = this.currentTrack; - assert(track && track.info?.type === 'video'); + if (!track) { + break; + } + assert(track.info?.type === 'video'); const colourType = readAscii(slice, 4); if (colourType !== 'nclx') { @@ -1150,7 +1216,10 @@ export class IsobmffDemuxer extends Demuxer { case 'esds': { const track = this.currentTrack; - assert(track && track.info?.type === 'audio'); + if (!track) { + break; + } + assert(track.info?.type === 'audio'); slice.skip(4); // Version + flags @@ -1224,7 +1293,10 @@ export class IsobmffDemuxer extends Demuxer { case 'enda': { const track = this.currentTrack; - assert(track && track.info?.type === 'audio'); + if (!track) { + break; + } + assert(track.info?.type === 'audio'); const littleEndian = readU16Be(slice) & 0xff; // 0xff is from FFmpeg @@ -1245,7 +1317,10 @@ export class IsobmffDemuxer extends Demuxer { case 'pcmC': { const track = this.currentTrack; - assert(track && track.info?.type === 'audio'); + if (!track) { + break; + } + assert(track.info?.type === 'audio'); slice.skip(1 + 3); // Version + flags @@ -1310,7 +1385,10 @@ export class IsobmffDemuxer extends Demuxer { case 'dOps': { // Used for Opus audio const track = this.currentTrack; - assert(track && track.info?.type === 'audio'); + if (!track) { + break; + } + assert(track.info?.type === 'audio'); slice.skip(1); // Version @@ -1348,7 +1426,10 @@ export class IsobmffDemuxer extends Demuxer { case 'dfLa': { // Used for FLAC audio const track = this.currentTrack; - assert(track && track.info?.type === 'audio'); + if (!track) { + break; + } + assert(track.info?.type === 'audio'); slice.skip(4); // Version + flags @@ -1402,7 +1483,9 @@ export class IsobmffDemuxer extends Demuxer { case 'stts': { const track = this.currentTrack; - assert(track); + if (!track) { + break; + } if (!track.sampleTable) { break; @@ -1433,7 +1516,9 @@ export class IsobmffDemuxer extends Demuxer { case 'ctts': { const track = this.currentTrack; - assert(track); + if (!track) { + break; + } if (!track.sampleTable) { break; @@ -1460,7 +1545,9 @@ export class IsobmffDemuxer extends Demuxer { case 'stsz': { const track = this.currentTrack; - assert(track); + if (!track) { + break; + } if (!track.sampleTable) { break; @@ -1483,7 +1570,9 @@ export class IsobmffDemuxer extends Demuxer { case 'stz2': { const track = this.currentTrack; - assert(track); + if (!track) { + break; + } if (!track.sampleTable) { break; @@ -1506,7 +1595,9 @@ export class IsobmffDemuxer extends Demuxer { case 'stss': { const track = this.currentTrack; - assert(track); + if (!track) { + break; + } if (!track.sampleTable) { break; @@ -1531,7 +1622,9 @@ export class IsobmffDemuxer extends Demuxer { case 'stsc': { const track = this.currentTrack; - assert(track); + if (!track) { + break; + } if (!track.sampleTable) { break; @@ -1569,7 +1662,9 @@ export class IsobmffDemuxer extends Demuxer { case 'stco': { const track = this.currentTrack; - assert(track); + if (!track) { + break; + } if (!track.sampleTable) { break; @@ -1587,7 +1682,9 @@ export class IsobmffDemuxer extends Demuxer { case 'co64': { const track = this.currentTrack; - assert(track); + if (!track) { + break; + } if (!track.sampleTable) { break; @@ -1950,14 +2047,294 @@ export class IsobmffDemuxer extends Demuxer { this.currentFragment.implicitBaseDataOffset = currentOffset; }; break; - // These appear in udta: - case '©nam': - case 'name': { - if (!this.currentTrack) { + // Metadata section + // https://exiftool.org/TagNames/QuickTime.html + // https://mp4workshop.com/about + + case 'udta': { // Contains either movie metadata or track metadata + const iterator = this.iterateContiguousBoxes(slice.slice(contentStartPos, boxInfo.contentSize)); + + for (const { boxInfo, slice } of iterator) { + if (boxInfo.name !== 'meta' && !this.currentTrack) { + const startPos = slice.filePos; + this.metadata.raw ??= {}; + + if (UDTA_STRING_KEYS.has(boxInfo.name)) { + this.metadata.raw[boxInfo.name] = readMetadataStringShort(slice); + } else { + this.metadata.raw[boxInfo.name] = readBytes(slice, boxInfo.contentSize); + } + + slice.filePos = startPos; + } + + switch (boxInfo.name) { + case 'meta': { + slice.skip(-boxInfo.headerSize); + this.traverseBox(slice); + }; break; + + case '©nam': + case 'name': { + if (this.currentTrack) { + this.currentTrack.name = textDecoder.decode(readBytes(slice, boxInfo.contentSize)); + } else { + this.metadata.title = readMetadataStringShort(slice); + } + }; break; + + case '©des': { + if (!this.currentTrack) { + this.metadata.description = readMetadataStringShort(slice); + } + }; break; + + case '©ART': { + if (!this.currentTrack) { + this.metadata.artist = readMetadataStringShort(slice); + } + }; break; + + case '©alb': { + if (!this.currentTrack) { + this.metadata.album = readMetadataStringShort(slice); + } + }; break; + + case 'albr': { + if (!this.currentTrack) { + this.metadata.albumArtist = readMetadataStringShort(slice); + } + }; break; + + case '©gen': { + if (!this.currentTrack) { + this.metadata.genre = readMetadataStringShort(slice); + } + }; break; + + case '©day': { + if (!this.currentTrack) { + const date = new Date(readMetadataStringShort(slice)); + if (!Number.isNaN(date.getTime())) { + this.metadata.date = date; + } + } + }; break; + + case '©cmt': { + if (!this.currentTrack) { + this.metadata.comment = readMetadataStringShort(slice); + } + }; break; + + case '©lyr': { + if (!this.currentTrack) { + this.metadata.lyrics = readMetadataStringShort(slice); + } + }; break; + } + } + }; break; + + case 'meta': { + if (this.currentTrack) { + break; // Only care about movie-level metadata for now + } + + // The 'meta' box comes in two flavors, one with flags/version and one without. To know which is which, + // let's read the next 4 bytes, which are either the version or the size of the first subbox. + const word = readU32Be(slice); + const isQuickTime = word !== 0; + + this.currentMetadataKeys = new Map(); + + if (isQuickTime) { + this.readContiguousBoxes(slice.slice(contentStartPos, boxInfo.contentSize)); + } else { + this.readContiguousBoxes(slice.slice(contentStartPos + 4, boxInfo.contentSize - 4)); + } + + this.currentMetadataKeys = null; + }; break; + + case 'keys': { + if (!this.currentMetadataKeys) { break; } - this.currentTrack.name = textDecoder.decode(readBytes(slice, boxInfo.contentSize)); + slice.skip(4); // Version + flags + + const entryCount = readU32Be(slice); + + for (let i = 0; i < entryCount; i++) { + const keySize = readU32Be(slice); + slice.skip(4); // Key namespace + const keyName = textDecoder.decode(readBytes(slice, keySize - 8)); + + this.currentMetadataKeys.set(i + 1, keyName); + } + }; break; + + case 'ilst': { + if (!this.currentMetadataKeys) { + break; + } + + const iterator = this.iterateContiguousBoxes(slice.slice(contentStartPos, boxInfo.contentSize)); + + for (const { boxInfo, slice } of iterator) { + let metadataKey = boxInfo.name; + + // Interpret the box name as a u32be + const nameAsNumber = (metadataKey.charCodeAt(0) << 24) + + (metadataKey.charCodeAt(1) << 16) + + (metadataKey.charCodeAt(2) << 8) + + metadataKey.charCodeAt(3); + + if (this.currentMetadataKeys.has(nameAsNumber)) { + // An entry exists for this number + metadataKey = this.currentMetadataKeys.get(nameAsNumber)!; + } + + const data = readDataBox(slice); + + this.metadata.raw ??= {}; + this.metadata.raw[metadataKey] = data; + + switch (metadataKey) { + case '©nam': + case 'titl': + case 'com.apple.quicktime.title': + case 'title': { + if (typeof data === 'string') { + this.metadata.title = data; + } + }; break; + + case '©des': + case 'desc': + case 'dscp': + case 'com.apple.quicktime.description': + case 'description': { + if (typeof data === 'string') { + this.metadata.description = data; + } + }; break; + + case '©ART': + case 'com.apple.quicktime.artist': + case 'artist': { + if (typeof data === 'string') { + this.metadata.artist = data; + } + }; break; + + case '©alb': + case 'albm': + case 'com.apple.quicktime.album': + case 'album': { + if (typeof data === 'string') { + this.metadata.album = data; + } + }; break; + + case 'aART': + case 'album_artist': { + if (typeof data === 'string') { + this.metadata.albumArtist = data; + } + }; break; + + case '©cmt': + case 'com.apple.quicktime.comment': + case 'comment': { + if (typeof data === 'string') { + this.metadata.comment = data; + } + }; break; + + case '©gen': + case 'gnre': + case 'com.apple.quicktime.genre': + case 'genre': { + if (typeof data === 'string') { + this.metadata.genre = data; + } + }; break; + + case '©lyr': + case 'lyrics': { + if (typeof data === 'string') { + this.metadata.lyrics = data; + } + }; break; + + case '©day': + case 'rldt': + case 'com.apple.quicktime.creationdate': + case 'date': { + if (typeof data === 'string') { + const date = new Date(data); + if (!Number.isNaN(date.getTime())) { + this.metadata.date = date; + } + } + }; break; + + case 'covr': + case 'com.apple.quicktime.artwork': { + if (data instanceof RichImageData) { + this.metadata.images ??= []; + this.metadata.images.push({ + data: data.data, + kind: 'coverFront', + mimeType: data.mimeType, + }); + } else if (data instanceof Uint8Array) { + this.metadata.images ??= []; + this.metadata.images.push({ + data, + kind: 'coverFront', + mimeType: 'image/*', + }); + } + }; break; + + case 'trkn': { + if (data instanceof Uint8Array) { + const view = toDataView(data); + + const trackNumber = view.getUint16(2, false); + const trackNumberMax = view.getUint16(4, false); + + if (trackNumber > 0) { + this.metadata.trackNumber = trackNumber; + } + if (trackNumberMax > 0) { + this.metadata.trackNumberMax = trackNumberMax; + } + } + }; break; + + case 'disc': + case 'disk': { + if (data instanceof Uint8Array) { + const view = toDataView(data); + + const discNumber = view.getUint16(2, false); + const discNumberMax = view.getUint16(4, false); + + if (discNumber > 0) { + this.metadata.discNumber = discNumber; + } + if (discNumberMax > 0) { + this.metadata.discNumberMax = discNumberMax; + } + } + }; break; + } + } }; break; } diff --git a/src/isobmff/isobmff-muxer.ts b/src/isobmff/isobmff-muxer.ts index 1ffd0c8..cd14688 100644 --- a/src/isobmff/isobmff-muxer.ts +++ b/src/isobmff/isobmff-muxer.ts @@ -152,10 +152,10 @@ export class IsobmffMuxer extends Muxer { private mdat: Box | null = null; - private trackDatas: IsobmffTrackData[] = []; + trackDatas: IsobmffTrackData[] = []; private allTracksKnown = promiseWithResolvers(); - private creationTime = Math.floor(Date.now() / 1000) + TIMESTAMP_OFFSET; + creationTime = Math.floor(Date.now() / 1000) + TIMESTAMP_OFFSET; private finalizedChunks: Chunk[] = []; private nextFragmentNumber = 1; @@ -988,7 +988,7 @@ export class IsobmffMuxer extends Muxer { } // Write the moov box now that we have all decoder configs - const movieBox = moov(this.trackDatas, this.creationTime, true); + const movieBox = moov(this, true); this.boxWriter.writeBox(movieBox); if (this.format._options.onMoov) { @@ -1140,7 +1140,7 @@ export class IsobmffMuxer extends Muxer { // size of the moov box and can compute the proper chunk positions. for (let i = 0; i < 2; i++) { - const movieBox = moov(this.trackDatas, this.creationTime); + const movieBox = moov(this); const movieBoxSize = this.boxWriter.measureBox(movieBox); mdatSize = this.boxWriter.measureBox(this.mdat); let currentChunkPos = this.writer.getPos() + movieBoxSize + mdatSize; @@ -1162,7 +1162,7 @@ export class IsobmffMuxer extends Muxer { this.writer.startTrackingWrites(); } - const movieBox = moov(this.trackDatas, this.creationTime); + const movieBox = moov(this); this.boxWriter.writeBox(movieBox); if (this.format._options.onMoov) { @@ -1218,7 +1218,7 @@ export class IsobmffMuxer extends Muxer { this.writer.startTrackingWrites(); } - const movieBox = moov(this.trackDatas, this.creationTime); + const movieBox = moov(this); this.boxWriter.writeBox(movieBox); if (this.format._options.onMoov) { diff --git a/src/isobmff/isobmff-reader.ts b/src/isobmff/isobmff-reader.ts index f1d0605..3aa5170 100644 --- a/src/isobmff/isobmff-reader.ts +++ b/src/isobmff/isobmff-reader.ts @@ -6,7 +6,9 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { FileSlice, readAscii, readI32Be, readU32Be, readU64Be, readU8 } from '../reader'; +import { RichImageData } from '../metadata'; +import { textDecoder } from '../misc'; +import { FileSlice, readAscii, readBytes, readI32Be, readU16Be, readU32Be, readU64Be, readU8 } from '../reader'; export const MIN_BOX_HEADER_SIZE = 8; export const MAX_BOX_HEADER_SIZE = 16; @@ -53,3 +55,30 @@ export const readIsomVariableInteger = (slice: FileSlice) => { return result; }; + +export const readMetadataStringShort = (slice: FileSlice) => { + const stringLength = readU16Be(slice); + slice.skip(2); // Language + return textDecoder.decode(readBytes(slice, stringLength)); +}; + +export const readDataBox = (slice: FileSlice) => { + const header = readBoxHeader(slice); + if (!header || header.name !== 'data') { + return null; + } + + const typeIndicator = readU32Be(slice); + slice.skip(4); // Locale indicator + const data = readBytes(slice, header.contentSize - 8); + + switch (typeIndicator) { + case 1: return textDecoder.decode(data); // UTF-8 + case 2: return new TextDecoder('utf-16be').decode(data); // UTF-16-BE + case 13: return new RichImageData(data, 'image/jpeg'); + case 14: return new RichImageData(data, 'image/png'); + case 27: return new RichImageData(data, 'image/bmp'); + + default: return data; + } +}; diff --git a/src/metadata.ts b/src/metadata.ts index 1737491..adda62f 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -2,13 +2,16 @@ export type MediaMetadata = { title?: string; + description?: string; artist?: string; album?: string; albumArtist?: string; trackNumber?: number; + trackNumberMax?: number; discNumber?: number; + discNumberMax?: number; genre?: string; - releasedAt?: Date; + date?: Date; lyrics?: string; images?: { data: Uint8Array; @@ -18,8 +21,13 @@ export type MediaMetadata = { description?: string; }[]; comment?: string; + raw?: Record; }; +export class RichImageData { + constructor(public data: Uint8Array, public mimeType: string) {} +} + export const validateMediaMetadata = (metadata: MediaMetadata) => { if (!metadata || typeof metadata !== 'object') { throw new TypeError('metadata must be an object.'); @@ -27,6 +35,9 @@ export const validateMediaMetadata = (metadata: MediaMetadata) => { if (metadata.title !== undefined && typeof metadata.title !== 'string') { throw new TypeError('metadata.title, when provided, must be a string.'); } + if (metadata.description !== undefined && typeof metadata.description !== 'string') { + throw new TypeError('metadata.description, when provided, must be a string.'); + } if (metadata.artist !== undefined && typeof metadata.artist !== 'string') { throw new TypeError('metadata.artist, when provided, must be a string.'); } @@ -39,21 +50,30 @@ export const validateMediaMetadata = (metadata: MediaMetadata) => { if (metadata.trackNumber !== undefined && (!Number.isInteger(metadata.trackNumber) || metadata.trackNumber <= 0)) { throw new TypeError('metadata.trackNumber, when provided, must be a positive integer.'); } + if ( + metadata.trackNumberMax !== undefined + && (!Number.isInteger(metadata.trackNumberMax) || metadata.trackNumberMax <= 0) + ) { + throw new TypeError('metadata.trackNumberMax, when provided, must be a positive integer.'); + } if (metadata.discNumber !== undefined && (!Number.isInteger(metadata.discNumber) || metadata.discNumber <= 0)) { throw new TypeError('metadata.discNumber, when provided, must be a positive integer.'); } + if ( + metadata.discNumberMax !== undefined + && (!Number.isInteger(metadata.discNumberMax) || metadata.discNumberMax <= 0) + ) { + throw new TypeError('metadata.discNumberMax, when provided, must be a positive integer.'); + } if (metadata.genre !== undefined && typeof metadata.genre !== 'string') { throw new TypeError('metadata.genre, when provided, must be a string.'); } - if (metadata.releasedAt !== undefined && !(metadata.releasedAt instanceof Date)) { - throw new TypeError('metadata.releasedAt, when provided, must be a Date.'); + if (metadata.date !== undefined && !(metadata.date instanceof Date)) { + throw new TypeError('metadata.date, when provided, must be a Date.'); } if (metadata.lyrics !== undefined && typeof metadata.lyrics !== 'string') { throw new TypeError('metadata.lyrics, when provided, must be a string.'); } - if (metadata.comment !== undefined && typeof metadata.comment !== 'string') { - throw new TypeError('metadata.comment, when provided, must be a string.'); - } if (metadata.images !== undefined) { if (!Array.isArray(metadata.images)) { throw new TypeError('metadata.images, when provided, must be an array.'); @@ -73,18 +93,43 @@ export const validateMediaMetadata = (metadata: MediaMetadata) => { } } } + if (metadata.comment !== undefined && typeof metadata.comment !== 'string') { + throw new TypeError('metadata.comment, when provided, must be a string.'); + } + if (metadata.raw !== undefined) { + if (!metadata.raw || typeof metadata.raw !== 'object') { + throw new TypeError('metadata.raw, when provided, must be an object.'); + } + + for (const value of Object.values(metadata.raw)) { + if ( + value !== null + && typeof value !== 'string' + && !(value instanceof Uint8Array) + && !(value instanceof RichImageData) + ) { + throw new TypeError( + 'Each value in metadata.raw must be a string, Uint8Array, RichImageData, or null.', + ); + } + } + } }; export const mediaMetadataIsEmpty = (metadata: MediaMetadata) => { return metadata.title === undefined + && metadata.description === undefined && metadata.artist === undefined && metadata.album === undefined && metadata.albumArtist === undefined && metadata.trackNumber === undefined + && metadata.trackNumberMax === undefined && metadata.discNumber === undefined + && metadata.discNumberMax === undefined && metadata.genre === undefined - && metadata.releasedAt === undefined + && metadata.date === undefined && metadata.lyrics === undefined + && (!metadata.images || metadata.images.length === 0) && metadata.comment === undefined - && (!metadata.images || metadata.images.length === 0); + && (metadata.raw === undefined || Object.keys(metadata.raw).length === 0); }; diff --git a/src/misc.ts b/src/misc.ts index c794b10..a94ff73 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -657,3 +657,21 @@ export const coalesceIndex = (a: number, b: number) => { export const closedIntervalsOverlap = (startA: number, endA: number, startB: number, endB: number) => { return startA <= endB && startB <= endA; }; + +type KeyValuePair> = { + [K in keyof T]-?: { + key: K; + value: T[K] extends infer R | undefined ? R : T[K]; + } +}[keyof T]; + +export const keyValueIterator = function* >(object: T) { + for (const key in object) { + const value = object[key]; + if (value === undefined) { + continue; + } + + yield { key, value } as KeyValuePair; + } +}; diff --git a/src/mp3/mp3-muxer.ts b/src/mp3/mp3-muxer.ts index 81549d6..03870a9 100644 --- a/src/mp3/mp3-muxer.ts +++ b/src/mp3/mp3-muxer.ts @@ -6,7 +6,7 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { assert, toDataView } from '../misc'; +import { assert, assertNever, keyValueIterator, textEncoder, toDataView } from '../misc'; import { mediaMetadataIsEmpty, MediaMetadata } from '../metadata'; import { Muxer } from '../muxer'; import { Output, OutputAudioTrack } from '../output'; @@ -15,6 +15,7 @@ import { EncodedPacket } from '../packet'; import { Writer } from '../writer'; import { getXingOffset, INFO, readFrameHeader, XING } from '../../shared/mp3-misc'; import { Mp3Writer, XingFrameData } from './mp3-writer'; +import { Id3V2TextEncoding } from './mp3-reader'; export class Mp3Muxer extends Muxer { private format: Mp3OutputFormat; @@ -129,43 +130,119 @@ export class Mp3Muxer extends Muxer { this.mp3Writer.writeSynchsafeU32(0); // Size placeholder const startPos = this.writer.getPos(); + const writtenTags = new Set(); - if (metadata.title !== undefined) { - this.mp3Writer.writeId3V2TextFrame('TIT2', metadata.title); + for (const { key, value } of keyValueIterator(metadata)) { + switch (key) { + case 'title': { + this.mp3Writer.writeId3V2TextFrame('TIT2', value); + writtenTags.add('TIT2'); + }; break; + + case 'description': { + this.mp3Writer.writeId3V2TextFrame('TIT3', value); + writtenTags.add('TIT3'); + }; break; + + case 'artist': { + this.mp3Writer.writeId3V2TextFrame('TPE1', value); + writtenTags.add('TPE1'); + }; break; + + case 'album': { + this.mp3Writer.writeId3V2TextFrame('TALB', value); + writtenTags.add('TALB'); + }; break; + + case 'albumArtist': { + this.mp3Writer.writeId3V2TextFrame('TPE2', value); + writtenTags.add('TPE2'); + }; break; + + case 'trackNumber': { + const string = metadata.trackNumberMax !== undefined + ? `${value}/${metadata.trackNumberMax}` + : value.toString(); + + this.mp3Writer.writeId3V2TextFrame('TRCK', string); + writtenTags.add('TRCK'); + }; break; + + case 'discNumber': { + const string = metadata.discNumberMax !== undefined + ? `${value}/${metadata.discNumberMax}` + : value.toString(); + + this.mp3Writer.writeId3V2TextFrame('TPOS', string); + writtenTags.add('TPOS'); + }; break; + + case 'genre': { + this.mp3Writer.writeId3V2TextFrame('TCON', value); + writtenTags.add('TCON'); + }; break; + + case 'date': { + this.mp3Writer.writeId3V2TextFrame('TDRC', value.toISOString().split('.')[0]!); + writtenTags.add('TDRC'); + }; break; + + case 'lyrics': { + this.mp3Writer.writeId3V2LyricsFrame(value); + writtenTags.add('USLT'); + }; break; + + case 'comment': { + this.mp3Writer.writeId3V2CommentFrame(value); + writtenTags.add('COMM'); + }; break; + + case 'images': { + const pictureTypeMap = { coverFront: 0x03, coverBack: 0x04, unknown: 0x00 }; + for (const image of value) { + const pictureType = pictureTypeMap[image.kind]; + const description = image.description ?? ''; + this.mp3Writer.writeId3V2ApicFrame(image.mimeType, pictureType, description, image.data); + } + }; break; + + case 'trackNumberMax': + case 'discNumberMax': { + // Handled with trackNumber and discNumber respectively + }; break; + + case 'raw': { + // Handled later + }; break; + + default: assertNever(key); + } } - if (metadata.artist !== undefined) { - this.mp3Writer.writeId3V2TextFrame('TPE1', metadata.artist); - } - if (metadata.album !== undefined) { - this.mp3Writer.writeId3V2TextFrame('TALB', metadata.album); - } - if (metadata.albumArtist !== undefined) { - this.mp3Writer.writeId3V2TextFrame('TPE2', metadata.albumArtist); - } - if (metadata.trackNumber !== undefined) { - this.mp3Writer.writeId3V2TextFrame('TRCK', metadata.trackNumber.toString()); - } - if (metadata.discNumber !== undefined) { - this.mp3Writer.writeId3V2TextFrame('TPOS', metadata.discNumber.toString()); - } - if (metadata.genre !== undefined) { - this.mp3Writer.writeId3V2TextFrame('TCON', metadata.genre); - } - if (metadata.releasedAt !== undefined) { - this.mp3Writer.writeId3V2TextFrame('TDRC', metadata.releasedAt.toISOString().split('.')[0]!); - } - if (metadata.lyrics !== undefined) { - this.mp3Writer.writeId3V2LyricsFrame(metadata.lyrics); - } - if (metadata.comment !== undefined) { - this.mp3Writer.writeId3V2CommentFrame(metadata.comment); - } - if (metadata.images) { - const pictureTypeMap = { coverFront: 0x03, coverBack: 0x04, unknown: 0x00 }; - for (const image of metadata.images) { - const pictureType = pictureTypeMap[image.kind]; - const description = image.description ?? ''; - this.mp3Writer.writeId3V2ApicFrame(image.mimeType, pictureType, description, image.data); + + if (metadata.raw) { + for (const key in metadata.raw) { + const value = metadata.raw[key]; + if (value == null || key.length !== 4 || writtenTags.has(key)) { + continue; + } + + let bytes: Uint8Array; + if (typeof value === 'string') { + const encoded = textEncoder.encode(value); + bytes = new Uint8Array(encoded.byteLength + 2); + bytes[0] = Id3V2TextEncoding.UTF_8; + bytes.set(encoded, 1); + // Last byte is the null terminator + } else if (value instanceof Uint8Array) { + bytes = value; + } else { + continue; + } + + this.mp3Writer.writeAscii(key); + this.mp3Writer.writeSynchsafeU32(bytes.byteLength); + this.mp3Writer.writeU16(0x0000); + this.writer.write(bytes); } } diff --git a/src/mp3/mp3-reader.ts b/src/mp3/mp3-reader.ts index 0bb7066..5d581f3 100644 --- a/src/mp3/mp3-reader.ts +++ b/src/mp3/mp3-reader.ts @@ -65,21 +65,6 @@ export const ID3_V1_GENRES = [ 'Dubstep', 'Garage rock', 'Psybient', ]; -/* -export const readId3 = (slice: FileSlice) => { - const tag = readAscii(slice, 3); - if (tag !== 'ID3') { - slice.skip(-3); - return null; - } - - slice.skip(3); - - const size = decodeSynchsafe(readU32Be(slice)); - return { size }; -}; -*/ - export const readNextFrameHeader = async (reader: Reader, startPos: number, until: number | null): Promise<{ header: FrameHeader; startPos: number; @@ -105,6 +90,11 @@ export const readNextFrameHeader = async (reader: Reader, startPos: number, unti }; export const parseId3V1Tag = (slice: FileSlice, metadata: MediaMetadata) => { + const startPos = slice.filePos; + metadata.raw ??= {}; + metadata.raw['TAG'] = readBytes(slice, ID3_V1_TAG_SIZE - 3); // Dump the whole tag into the raw metadata + slice.filePos = startPos; + const title = readId3V1String(slice, 30); if (title) metadata.title ??= title; @@ -117,7 +107,7 @@ export const parseId3V1Tag = (slice: FileSlice, metadata: MediaMetadata) => { const yearText = readId3V1String(slice, 4); const year = Number.parseInt(yearText, 10); if (Number.isInteger(year) && year > 0) { - metadata.releasedAt ??= new Date(year, 0, 1); + metadata.date ??= new Date(year, 0, 1); } const commentBytes = readBytes(slice, 30); @@ -216,6 +206,7 @@ export const parseId3V2Tag = (slice: FileSlice, header: Id3V2Header, metadata: M break; } + const frameStartPos = reader.pos; const frameEndPos = reader.pos + frame.size; let frameEncrypted = false; @@ -248,12 +239,28 @@ export const parseId3V2Tag = (slice: FileSlice, header: Id3V2Header, metadata: M reader.ununsynchronizeRegion(reader.pos, frameEndPos); } + metadata.raw ??= {}; + if (frame.id[0] === 'T') { + // It's a text frame, let's decode as text + metadata.raw[frame.id] = reader.readId3V2EncodingAndText(frameEndPos); + } else { + // For the others, let's just get the bytes + metadata.raw[frame.id] = reader.readBytes(frame.size); + } + + reader.pos = frameStartPos; + switch (frame.id) { case 'TIT2': case 'TT2': { metadata.title ??= reader.readId3V2EncodingAndText(frameEndPos); }; break; + case 'TIT3': + case 'TT3': { + metadata.description ??= reader.readId3V2EncodingAndText(frameEndPos); + }; break; + case 'TPE1': case 'TP1': { metadata.artist ??= reader.readId3V2EncodingAndText(frameEndPos); @@ -272,21 +279,31 @@ export const parseId3V2Tag = (slice: FileSlice, header: Id3V2Header, metadata: M case 'TRCK': case 'TRK': { const trackText = reader.readId3V2EncodingAndText(frameEndPos); - const trackNum = Number.parseInt(trackText, 10); + const parts = trackText.split('/'); + const trackNum = Number.parseInt(parts[0]!, 10); + const trackNumMax = parts[1] && Number.parseInt(parts[1], 10); if (Number.isInteger(trackNum) && trackNum > 0) { metadata.trackNumber ??= trackNum; } + if (trackNumMax && Number.isInteger(trackNumMax) && trackNumMax > 0) { + metadata.trackNumberMax ??= trackNumMax; + } }; break; case 'TPOS': case 'TPA': { const discText = reader.readId3V2EncodingAndText(frameEndPos); - const discNum = Number.parseInt(discText, 10); + const parts = discText.split('/'); + const discNum = Number.parseInt(parts[0]!, 10); + const discNumMax = parts[1] && Number.parseInt(parts[1], 10); if (Number.isInteger(discNum) && discNum > 0) { metadata.discNumber ??= discNum; } + if (discNumMax && Number.isInteger(discNumMax) && discNumMax > 0) { + metadata.discNumberMax ??= discNumMax; + } }; break; case 'TCON': @@ -319,7 +336,7 @@ export const parseId3V2Tag = (slice: FileSlice, header: Id3V2Header, metadata: M const date = new Date(dateText); if (!Number.isNaN(date.getTime())) { - metadata.releasedAt ??= date; + metadata.date ??= date; } }; break; @@ -329,7 +346,7 @@ export const parseId3V2Tag = (slice: FileSlice, header: Id3V2Header, metadata: M const year = Number.parseInt(yearText, 10); if (Number.isInteger(year)) { - metadata.releasedAt ??= new Date(year, 0, 1); + metadata.date ??= new Date(year, 0, 1); } }; break;