diff --git a/dev/convert.html b/dev/convert.html index a58ddec..cf6f001 100644 --- a/dev/convert.html +++ b/dev/convert.html @@ -56,7 +56,7 @@ mimeType: 'image/jpeg' }], trackNumber: 4, - trackNumberMax: 10, + tracksTotal: 10, discNumber: 5, discNumberMax: 8, lyrics: "There's no way\nThat it's not going there", diff --git a/src/adts/adts-demuxer.ts b/src/adts/adts-demuxer.ts index 63a5ceb..5985df0 100644 --- a/src/adts/adts-demuxer.ts +++ b/src/adts/adts-demuxer.ts @@ -126,8 +126,8 @@ export class AdtsDemuxer extends Demuxer { return track.computeDuration(); } - async getMetadata() { - return {}; // No metadata in this one + async getMetadataTags() { + return {}; // No tags in this one } } diff --git a/src/demuxer.ts b/src/demuxer.ts index 75e2803..f654dab 100644 --- a/src/demuxer.ts +++ b/src/demuxer.ts @@ -8,7 +8,7 @@ import { Input } from './input'; import { InputTrack } from './input-track'; -import { MediaMetadata } from './metadata'; +import { MetadataTags } from './tags'; export abstract class Demuxer { input: Input; @@ -20,5 +20,5 @@ export abstract class Demuxer { abstract computeDuration(): Promise; abstract getTracks(): Promise; abstract getMimeType(): Promise; - abstract getMetadata(): Promise; + abstract getMetadataTags(): Promise; } diff --git a/src/input.ts b/src/input.ts index bc52896..f7fe149 100644 --- a/src/input.ts +++ b/src/input.ts @@ -144,6 +144,6 @@ export class Input { async getMetadata() { const demuxer = await this._getDemuxer(); - return demuxer.getMetadata(); + return demuxer.getMetadataTags(); } } diff --git a/src/isobmff/isobmff-boxes.ts b/src/isobmff/isobmff-boxes.ts index 823ceda..c15b05f 100644 --- a/src/isobmff/isobmff-boxes.ts +++ b/src/isobmff/isobmff-boxes.ts @@ -44,7 +44,7 @@ import { Sample, } from './isobmff-muxer'; import { parseOpusIdentificationHeader } from '../codec-data'; -import { MediaMetadata, RichImageData } from '../metadata'; +import { MetadataTags, RichImageData } from '../tags'; export class IsobmffBoxWriter { private helper = new Uint8Array(8); @@ -1274,11 +1274,11 @@ export const vtta = (notes: string) => box('vtta', [...textEncoder.encode(notes) const udta = (muxer: IsobmffMuxer) => { const boxes: Box[] = []; - // Depending on the format, metadata is written differently + // Depending on the format, metadata tags are written differently if (muxer.isQuickTime) { - addQuickTimeMetadataboxes(boxes, muxer.output._metadata); + addQuickTimeMetadataTagBoxes(boxes, muxer.output._metadataTags); } else { - const metaBox = meta(muxer.output._metadata); + const metaBox = meta(muxer.output._metadataTags); if (metaBox) { boxes.push(metaBox); } @@ -1291,46 +1291,46 @@ const udta = (muxer: IsobmffMuxer) => { return box('udta', undefined, boxes); }; -const addQuickTimeMetadataboxes = (boxes: Box[], metadata: MediaMetadata) => { +const addQuickTimeMetadataTagBoxes = (boxes: Box[], tags: MetadataTags) => { // https://exiftool.org/TagNames/QuickTime.html (QuickTime UserData Tags) - // For QuickTime files, metadata is dumped into the udta box + // For QuickTime files, metadata tags are dumped into the udta box - for (const { key, value } of keyValueIterator(metadata)) { + for (const { key, value } of keyValueIterator(tags)) { switch (key) { case 'title': { - boxes.push(metadataStringBoxShort('©nam', value)); + boxes.push(metadataTagStringBoxShort('©nam', value)); }; break; case 'description': { - boxes.push(metadataStringBoxShort('©des', value)); + boxes.push(metadataTagStringBoxShort('©des', value)); }; break; case 'artist': { - boxes.push(metadataStringBoxShort('©ART', value)); + boxes.push(metadataTagStringBoxShort('©ART', value)); }; break; case 'album': { - boxes.push(metadataStringBoxShort('©alb', value)); + boxes.push(metadataTagStringBoxShort('©alb', value)); }; break; case 'albumArtist': { - boxes.push(metadataStringBoxShort('albr', value)); + boxes.push(metadataTagStringBoxShort('albr', value)); }; break; case 'genre': { - boxes.push(metadataStringBoxShort('©gen', value)); + boxes.push(metadataTagStringBoxShort('©gen', value)); }; break; case 'date': { - boxes.push(metadataStringBoxShort('©day', value.toISOString().slice(0, 10))); + boxes.push(metadataTagStringBoxShort('©day', value.toISOString().slice(0, 10))); }; break; case 'comment': { - boxes.push(metadataStringBoxShort('©cmt', value)); + boxes.push(metadataTagStringBoxShort('©cmt', value)); }; break; case 'lyrics': { - boxes.push(metadataStringBoxShort('©lyr', value)); + boxes.push(metadataTagStringBoxShort('©lyr', value)); }; break; case 'raw': { @@ -1338,9 +1338,9 @@ const addQuickTimeMetadataboxes = (boxes: Box[], metadata: MediaMetadata) => { }; break; case 'discNumber': - case 'discNumberMax': + case 'discsTotal': case 'trackNumber': - case 'trackNumberMax': + case 'tracksTotal': case 'images': { // Not written for QuickTime (common Apple L) }; break; @@ -1349,15 +1349,15 @@ const addQuickTimeMetadataboxes = (boxes: Box[], metadata: MediaMetadata) => { } } - if (metadata.raw) { - for (const key in metadata.raw) { - const value = metadata.raw[key]; + if (tags.raw) { + for (const key in tags.raw) { + const value = tags.raw[key]; if (value == null || key.length !== 4 || boxes.some(x => x.type === key)) { continue; } if (typeof value === 'string') { - boxes.push(metadataStringBoxShort(key, value)); + boxes.push(metadataTagStringBoxShort(key, value)); } else if (value instanceof Uint8Array) { boxes.push(box(key, Array.from(value))); } @@ -1365,7 +1365,7 @@ const addQuickTimeMetadataboxes = (boxes: Box[], metadata: MediaMetadata) => { } }; -const metadataStringBoxShort = (name: string, value: string) => { +const metadataTagStringBoxShort = (name: string, value: string) => { const encoded = textEncoder.encode(value); return box(name, [ @@ -1382,48 +1382,48 @@ const DATA_BOX_MIME_TYPE_MAP: Record = { }; /** Metadata Box */ -const meta = (metadata: MediaMetadata) => { +const meta = (tags: MetadataTags) => { 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)) { + for (const { key, value } of keyValueIterator(tags)) { switch (key) { case 'title': { - boxes.push(metadataStringBoxLong('©nam', value)); + boxes.push(metadataTagStringBoxLong('©nam', value)); }; break; case 'description': { - boxes.push(metadataStringBoxLong('©des', value)); + boxes.push(metadataTagStringBoxLong('©des', value)); }; break; case 'artist': { - boxes.push(metadataStringBoxLong('©ART', value)); + boxes.push(metadataTagStringBoxLong('©ART', value)); }; break; case 'album': { - boxes.push(metadataStringBoxLong('©alb', value)); + boxes.push(metadataTagStringBoxLong('©alb', value)); }; break; case 'albumArtist': { - boxes.push(metadataStringBoxLong('aART', value)); + boxes.push(metadataTagStringBoxLong('aART', value)); }; break; case 'comment': { - boxes.push(metadataStringBoxLong('©cmt', value)); + boxes.push(metadataTagStringBoxLong('©cmt', value)); }; break; case 'genre': { - boxes.push(metadataStringBoxLong('©gen', value)); + boxes.push(metadataTagStringBoxLong('©gen', value)); }; break; case 'lyrics': { - boxes.push(metadataStringBoxLong('©lyr', value)); + boxes.push(metadataTagStringBoxLong('©lyr', value)); }; break; case 'date': { - boxes.push(metadataStringBoxLong('©day', value.toISOString().slice(0, 10))); + boxes.push(metadataTagStringBoxLong('©day', value.toISOString().slice(0, 10))); }; break; case 'images': { @@ -1449,7 +1449,7 @@ const meta = (metadata: MediaMetadata) => { u32(0), u16(0), // Empty u16(value), - u16(metadata.trackNumberMax ?? 0), + u16(tags.tracksTotal ?? 0), u16(0), // Empty ]), ])); @@ -1462,14 +1462,14 @@ const meta = (metadata: MediaMetadata) => { u32(0), u16(0), // Empty u16(value), - u16(metadata.discNumberMax ?? 0), + u16(tags.discsTotal ?? 0), u16(0), // Empty ]), ])); }; break; - case 'trackNumberMax': - case 'discNumberMax':{ + case 'tracksTotal': + case 'discsTotal':{ // These are included with 'trackNumber' and 'discNumber' respectively }; break; @@ -1481,15 +1481,15 @@ const meta = (metadata: MediaMetadata) => { } } - if (metadata.raw) { - for (const key in metadata.raw) { - const value = metadata.raw[key]; + if (tags.raw) { + for (const key in tags.raw) { + const value = tags.raw[key]; if (value == null || key.length !== 4 || boxes.some(x => x.type === key)) { continue; } if (typeof value === 'string') { - boxes.push(metadataStringBoxLong(key, value)); + boxes.push(metadataTagStringBoxLong(key, value)); } else if (value instanceof Uint8Array) { boxes.push(box(key, undefined, [ box('data', [ @@ -1520,7 +1520,7 @@ const meta = (metadata: MediaMetadata) => { ]); }; -const metadataStringBoxLong = (name: string, value: string) => { +const metadataTagStringBoxLong = (name: string, value: string) => { return box(name, undefined, [ box('data', [ u32(1), // Type indicator (UTF-8) diff --git a/src/isobmff/isobmff-demuxer.ts b/src/isobmff/isobmff-demuxer.ts index 431bd30..fddbb3e 100644 --- a/src/isobmff/isobmff-demuxer.ts +++ b/src/isobmff/isobmff-demuxer.ts @@ -86,7 +86,7 @@ import { readU8, readAscii, } from '../reader'; -import { MediaMetadata, RichImageData } from '../metadata'; +import { MetadataTags, RichImageData } from '../tags'; // https://exiftool.org/TagNames/QuickTime.html const UDTA_STRING_KEYS = new Set([ @@ -249,7 +249,7 @@ export class IsobmffDemuxer extends Demuxer { movieTimescale = -1; movieDurationInTimescale = -1; isQuickTime = false; - metadata: MediaMetadata = {}; + metadataTags: MetadataTags = {}; currentMetadataKeys: Map | null = null; isFragmented = false; @@ -288,9 +288,9 @@ export class IsobmffDemuxer extends Demuxer { }); } - async getMetadata() { + async getMetadataTags() { await this.readMetadata(); - return this.metadata; + return this.metadataTags; } readMetadata() { @@ -2057,12 +2057,12 @@ export class IsobmffDemuxer extends Demuxer { for (const { boxInfo, slice } of iterator) { if (boxInfo.name !== 'meta' && !this.currentTrack) { const startPos = slice.filePos; - this.metadata.raw ??= {}; + this.metadataTags.raw ??= {}; if (UDTA_STRING_KEYS.has(boxInfo.name)) { - this.metadata.raw[boxInfo.name] ??= readMetadataStringShort(slice); + this.metadataTags.raw[boxInfo.name] ??= readMetadataStringShort(slice); } else { - this.metadata.raw[boxInfo.name] ??= readBytes(slice, boxInfo.contentSize); + this.metadataTags.raw[boxInfo.name] ??= readBytes(slice, boxInfo.contentSize); } slice.filePos = startPos; @@ -2079,37 +2079,37 @@ export class IsobmffDemuxer extends Demuxer { if (this.currentTrack) { this.currentTrack.name = textDecoder.decode(readBytes(slice, boxInfo.contentSize)); } else { - this.metadata.title ??= readMetadataStringShort(slice); + this.metadataTags.title ??= readMetadataStringShort(slice); } }; break; case '©des': { if (!this.currentTrack) { - this.metadata.description ??= readMetadataStringShort(slice); + this.metadataTags.description ??= readMetadataStringShort(slice); } }; break; case '©ART': { if (!this.currentTrack) { - this.metadata.artist ??= readMetadataStringShort(slice); + this.metadataTags.artist ??= readMetadataStringShort(slice); } }; break; case '©alb': { if (!this.currentTrack) { - this.metadata.album ??= readMetadataStringShort(slice); + this.metadataTags.album ??= readMetadataStringShort(slice); } }; break; case 'albr': { if (!this.currentTrack) { - this.metadata.albumArtist ??= readMetadataStringShort(slice); + this.metadataTags.albumArtist ??= readMetadataStringShort(slice); } }; break; case '©gen': { if (!this.currentTrack) { - this.metadata.genre ??= readMetadataStringShort(slice); + this.metadataTags.genre ??= readMetadataStringShort(slice); } }; break; @@ -2117,20 +2117,20 @@ export class IsobmffDemuxer extends Demuxer { if (!this.currentTrack) { const date = new Date(readMetadataStringShort(slice)); if (!Number.isNaN(date.getTime())) { - this.metadata.date ??= date; + this.metadataTags.date ??= date; } } }; break; case '©cmt': { if (!this.currentTrack) { - this.metadata.comment ??= readMetadataStringShort(slice); + this.metadataTags.comment ??= readMetadataStringShort(slice); } }; break; case '©lyr': { if (!this.currentTrack) { - this.metadata.lyrics ??= readMetadataStringShort(slice); + this.metadataTags.lyrics ??= readMetadataStringShort(slice); } }; break; } @@ -2199,8 +2199,8 @@ export class IsobmffDemuxer extends Demuxer { const data = readDataBox(slice); - this.metadata.raw ??= {}; - this.metadata.raw[metadataKey] ??= data; + this.metadataTags.raw ??= {}; + this.metadataTags.raw[metadataKey] ??= data; switch (metadataKey) { case '©nam': @@ -2208,7 +2208,7 @@ export class IsobmffDemuxer extends Demuxer { case 'com.apple.quicktime.title': case 'title': { if (typeof data === 'string') { - this.metadata.title ??= data; + this.metadataTags.title ??= data; } }; break; @@ -2218,7 +2218,7 @@ export class IsobmffDemuxer extends Demuxer { case 'com.apple.quicktime.description': case 'description': { if (typeof data === 'string') { - this.metadata.description ??= data; + this.metadataTags.description ??= data; } }; break; @@ -2226,7 +2226,7 @@ export class IsobmffDemuxer extends Demuxer { case 'com.apple.quicktime.artist': case 'artist': { if (typeof data === 'string') { - this.metadata.artist ??= data; + this.metadataTags.artist ??= data; } }; break; @@ -2235,14 +2235,14 @@ export class IsobmffDemuxer extends Demuxer { case 'com.apple.quicktime.album': case 'album': { if (typeof data === 'string') { - this.metadata.album ??= data; + this.metadataTags.album ??= data; } }; break; case 'aART': case 'album_artist': { if (typeof data === 'string') { - this.metadata.albumArtist ??= data; + this.metadataTags.albumArtist ??= data; } }; break; @@ -2250,7 +2250,7 @@ export class IsobmffDemuxer extends Demuxer { case 'com.apple.quicktime.comment': case 'comment': { if (typeof data === 'string') { - this.metadata.comment ??= data; + this.metadataTags.comment ??= data; } }; break; @@ -2259,14 +2259,14 @@ export class IsobmffDemuxer extends Demuxer { case 'com.apple.quicktime.genre': case 'genre': { if (typeof data === 'string') { - this.metadata.genre ??= data; + this.metadataTags.genre ??= data; } }; break; case '©lyr': case 'lyrics': { if (typeof data === 'string') { - this.metadata.lyrics ??= data; + this.metadataTags.lyrics ??= data; } }; break; @@ -2277,7 +2277,7 @@ export class IsobmffDemuxer extends Demuxer { if (typeof data === 'string') { const date = new Date(data); if (!Number.isNaN(date.getTime())) { - this.metadata.date ??= date; + this.metadataTags.date ??= date; } } }; break; @@ -2285,15 +2285,15 @@ export class IsobmffDemuxer extends Demuxer { case 'covr': case 'com.apple.quicktime.artwork': { if (data instanceof RichImageData) { - this.metadata.images ??= []; - this.metadata.images.push({ + this.metadataTags.images ??= []; + this.metadataTags.images.push({ data: data.data, kind: 'coverFront', mimeType: data.mimeType, }); } else if (data instanceof Uint8Array) { - this.metadata.images ??= []; - this.metadata.images.push({ + this.metadataTags.images ??= []; + this.metadataTags.images.push({ data, kind: 'coverFront', mimeType: 'image/*', @@ -2306,13 +2306,13 @@ export class IsobmffDemuxer extends Demuxer { const view = toDataView(data); const trackNumber = view.getUint16(2, false); - const trackNumberMax = view.getUint16(4, false); + const tracksTotal = view.getUint16(4, false); if (trackNumber > 0) { - this.metadata.trackNumber ??= trackNumber; + this.metadataTags.trackNumber ??= trackNumber; } - if (trackNumberMax > 0) { - this.metadata.trackNumberMax ??= trackNumberMax; + if (tracksTotal > 0) { + this.metadataTags.tracksTotal ??= tracksTotal; } } }; break; @@ -2326,10 +2326,10 @@ export class IsobmffDemuxer extends Demuxer { const discNumberMax = view.getUint16(4, false); if (discNumber > 0) { - this.metadata.discNumber ??= discNumber; + this.metadataTags.discNumber ??= discNumber; } if (discNumberMax > 0) { - this.metadata.discNumberMax ??= discNumberMax; + this.metadataTags.discsTotal ??= discNumberMax; } } }; break; diff --git a/src/isobmff/isobmff-reader.ts b/src/isobmff/isobmff-reader.ts index 3aa5170..584a0a8 100644 --- a/src/isobmff/isobmff-reader.ts +++ b/src/isobmff/isobmff-reader.ts @@ -6,7 +6,7 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { RichImageData } from '../metadata'; +import { RichImageData } from '../tags'; import { textDecoder } from '../misc'; import { FileSlice, readAscii, readBytes, readI32Be, readU16Be, readU32Be, readU64Be, readU8 } from '../reader'; diff --git a/src/matroska/matroska-demuxer.ts b/src/matroska/matroska-demuxer.ts index 9db5834..fbcfec4 100644 --- a/src/matroska/matroska-demuxer.ts +++ b/src/matroska/matroska-demuxer.ts @@ -30,7 +30,7 @@ import { InputVideoTrack, InputVideoTrackBacking, } from '../input-track'; -import { MediaMetadata } from '../metadata'; +import { MetadataTags } from '../tags'; import { PacketRetrievalOptions } from '../media-sink'; import { assert, @@ -94,8 +94,8 @@ type Segment = { clusters: Cluster[]; clusterLookupMutex: AsyncMutex; - metadata: MediaMetadata; - metadataCollected: boolean; + metadataTags: MetadataTags; + metadataTagsCollected: boolean; }; type SeekEntry = { @@ -246,25 +246,25 @@ export class MatroskaDemuxer extends Demuxer { }); } - async getMetadata() { + async getMetadataTags() { await this.readMetadata(); - // Load metadata from each segment lazily (only once) + // Load metadata tags from each segment lazily (only once) for (const segment of this.segments) { - if (!segment.metadataCollected) { + if (!segment.metadataTagsCollected) { await this.loadSegmentMetadata(segment); - segment.metadataCollected = true; + segment.metadataTagsCollected = true; } } // This is kinda handwavy, and how we handle multiple segments isn't suuuuper well-defined anyway; so we just - // shallow-merge metadata from all (usually just one) segments. - let metadata: MediaMetadata = {}; + // shallow-merge metadata tags from all (usually just one) segments. + let metadataTags: MetadataTags = {}; for (const segment of this.segments) { - metadata = { ...metadata, ...segment.metadata }; + metadataTags = { ...metadataTags, ...segment.metadataTags }; } - return metadata; + return metadataTags; } readMetadata() { @@ -365,8 +365,8 @@ export class MatroskaDemuxer extends Demuxer { clusters: [], clusterLookupMutex: new AsyncMutex(), - metadata: {}, - metadataCollected: false, + metadataTags: {}, + metadataTagsCollected: false, }; this.segments.push(this.currentSegment); @@ -1426,8 +1426,8 @@ export class MatroskaDemuxer extends Demuxer { } } - this.currentSegment.metadata.images ??= []; - this.currentSegment.metadata.images.push({ + this.currentSegment.metadataTags.images ??= []; + this.currentSegment.metadataTags.images.push({ data: this.currentAttachedFile.fileData, mimeType: this.currentAttachedFile.fileMediaType, kind, @@ -1469,50 +1469,50 @@ export class MatroskaDemuxer extends Demuxer { } processTagValue(name: string, value: string | Uint8Array) { - if (!this.currentSegment?.metadata) return; + if (!this.currentSegment?.metadataTags) return; - const metadata = this.currentSegment.metadata; - metadata.raw ??= {}; - metadata.raw[name] ??= value; + const metadataTags = this.currentSegment.metadataTags; + metadataTags.raw ??= {}; + metadataTags.raw[name] ??= value; if (typeof value === 'string') { switch (name.toLowerCase()) { case 'title': { - metadata.title ??= value; + metadataTags.title ??= value; }; break; case 'description': { - metadata.description ??= value; + metadataTags.description ??= value; }; break; case 'artist': { - metadata.artist ??= value; + metadataTags.artist ??= value; }; break; case 'album': { - metadata.album ??= value; + metadataTags.album ??= value; }; break; case 'album_artist': { - metadata.albumArtist ??= value; + metadataTags.albumArtist ??= value; }; break; case 'genre': { - metadata.genre ??= value; + metadataTags.genre ??= value; }; break; case 'comment': { - metadata.comment ??= value; + metadataTags.comment ??= value; }; break; case 'lyrics': { - metadata.lyrics ??= value; + metadataTags.lyrics ??= value; }; break; case 'date': { const date = new Date(value); if (!Number.isNaN(date.getTime())) { - metadata.date ??= date; + metadataTags.date ??= date; } }; break; @@ -1523,10 +1523,10 @@ export class MatroskaDemuxer extends Demuxer { const trackNumMax = parts[1] && Number.parseInt(parts[1], 10); if (Number.isInteger(trackNum) && trackNum > 0) { - metadata.trackNumber ??= trackNum; + metadataTags.trackNumber ??= trackNum; } if (trackNumMax && Number.isInteger(trackNumMax) && trackNumMax > 0) { - metadata.trackNumberMax ??= trackNumMax; + metadataTags.tracksTotal ??= trackNumMax; } }; break; @@ -1537,10 +1537,10 @@ export class MatroskaDemuxer extends Demuxer { const discNumMax = discParts[1] && Number.parseInt(discParts[1], 10); if (Number.isInteger(discNum) && discNum > 0) { - metadata.discNumber ??= discNum; + metadataTags.discNumber ??= discNum; } if (discNumMax && Number.isInteger(discNumMax) && discNumMax > 0) { - metadata.discNumberMax ??= discNumMax; + metadataTags.discsTotal ??= discNumMax; } }; break; } diff --git a/src/matroska/matroska-muxer.ts b/src/matroska/matroska-muxer.ts index 60220f2..d878f6e 100644 --- a/src/matroska/matroska-muxer.ts +++ b/src/matroska/matroska-muxer.ts @@ -424,10 +424,10 @@ export class MatroskaMuxer extends Muxer { ] }); }; - const metadata = this.output._metadata; + const metadataTags = this.output._metadataTags; const writtenTags = new Set(); - for (const { key, value } of keyValueIterator(metadata)) { + for (const { key, value } of keyValueIterator(metadataTags)) { switch (key) { case 'title': { addSimpleTag('TITLE', value); @@ -475,8 +475,8 @@ export class MatroskaMuxer extends Muxer { }; break; case 'trackNumber': { - const string = metadata.trackNumberMax !== undefined - ? `${value}/${metadata.trackNumberMax}` + const string = metadataTags.tracksTotal !== undefined + ? `${value}/${metadataTags.tracksTotal}` : value.toString(); addSimpleTag('PART_NUMBER', string); @@ -484,16 +484,16 @@ export class MatroskaMuxer extends Muxer { }; break; case 'discNumber': { - const string = metadata.discNumberMax !== undefined - ? `${value}/${metadata.discNumberMax}` + const string = metadataTags.discsTotal !== undefined + ? `${value}/${metadataTags.discsTotal}` : value.toString(); addSimpleTag('DISC', string); writtenTags.add('DISC'); }; break; - case 'trackNumberMax': - case 'discNumberMax': { + case 'tracksTotal': + case 'discsTotal': { // Handled with trackNumber and discNumber respectively }; break; @@ -506,9 +506,9 @@ export class MatroskaMuxer extends Muxer { } } - if (metadata.raw) { - for (const key in metadata.raw) { - const value = metadata.raw[key]!; + if (metadataTags.raw) { + for (const key in metadataTags.raw) { + const value = metadataTags.raw[key]!; if (value == null || writtenTags.has(key)) { continue; } @@ -536,14 +536,14 @@ export class MatroskaMuxer extends Muxer { } private maybeCreateAttachments() { - const metadata = this.output._metadata; - if (!metadata.images || metadata.images.length === 0) { + const metadataTags = this.output._metadataTags; + if (!metadataTags.images || metadataTags.images.length === 0) { return; } const existingFileUids = new Set(); - this.attachmentsElement = { id: EBMLId.Attachments, data: metadata.images.map((image): EBMLElement => { + this.attachmentsElement = { id: EBMLId.Attachments, data: metadataTags.images.map((image): EBMLElement => { let imageName = image.name; if (imageName === undefined) { const baseName = image.kind === 'coverFront' ? 'cover' : image.kind === 'coverBack' ? 'back' : 'image'; diff --git a/src/metadata.ts b/src/metadata.ts deleted file mode 100644 index 9c37005..0000000 --- a/src/metadata.ts +++ /dev/null @@ -1,135 +0,0 @@ -// TODO: Give this a different name. This clashes with a web concept. Question is, what? - -export type MediaMetadata = { - title?: string; - description?: string; - artist?: string; - album?: string; - albumArtist?: string; - trackNumber?: number; - trackNumberMax?: number; - discNumber?: number; - discNumberMax?: number; - genre?: string; - date?: Date; - lyrics?: string; - images?: { - data: Uint8Array; - mimeType: string; - kind: 'coverFront' | 'coverBack' | 'unknown'; - name?: string; - 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.'); - } - 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.'); - } - if (metadata.album !== undefined && typeof metadata.album !== 'string') { - throw new TypeError('metadata.album, when provided, must be a string.'); - } - if (metadata.albumArtist !== undefined && typeof metadata.albumArtist !== 'string') { - throw new TypeError('metadata.albumArtist, when provided, must be a string.'); - } - 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.date !== undefined && (!(metadata.date instanceof Date) || Number.isNaN(metadata.date.getTime()))) { - throw new TypeError('metadata.date, when provided, must be a valid Date.'); - } - if (metadata.lyrics !== undefined && typeof metadata.lyrics !== 'string') { - throw new TypeError('metadata.lyrics, 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.'); - } - for (const image of metadata.images) { - if (!image || typeof image !== 'object') { - throw new TypeError('Each image in metadata.images must be an object.'); - } - if (!(image.data instanceof Uint8Array)) { - throw new TypeError('Each image.data must be a Uint8Array.'); - } - if (typeof image.mimeType !== 'string') { - throw new TypeError('Each image.mimeType must be a string.'); - } - if (!['coverFront', 'coverBack', 'other'].includes(image.kind)) { - throw new TypeError('Each image.kind must be \'coverFront\', \'coverBack\', or \'other\'.'); - } - } - } - 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.date === undefined - && metadata.lyrics === undefined - && (!metadata.images || metadata.images.length === 0) - && metadata.comment === undefined - && (metadata.raw === undefined || Object.keys(metadata.raw).length === 0); -}; diff --git a/src/mp3/mp3-demuxer.ts b/src/mp3/mp3-demuxer.ts index 432d44f..742a626 100644 --- a/src/mp3/mp3-demuxer.ts +++ b/src/mp3/mp3-demuxer.ts @@ -10,7 +10,7 @@ import { AudioCodec } from '../codec'; import { Demuxer } from '../demuxer'; import { Input } from '../input'; import { InputAudioTrack, InputAudioTrackBacking } from '../input-track'; -import { MediaMetadata } from '../metadata'; +import { MetadataTags } from '../tags'; import { PacketRetrievalOptions } from '../media-sink'; import { assert, AsyncMutex, binarySearchExact, binarySearchLessOrEqual, UNDETERMINED_LANGUAGE } from '../misc'; import { EncodedPacket, PLACEHOLDER_DATA } from '../packet'; @@ -38,7 +38,7 @@ export class Mp3Demuxer extends Demuxer { metadataPromise: Promise | null = null; firstFrameHeader: FrameHeader | null = null; loadedSamples: Sample[] = []; // All samples from the start of the file to lastLoadedPos - metadata: MediaMetadata | null = null; + metadataTags: MetadataTags | null = null; tracks: InputAudioTrack[] = []; @@ -148,17 +148,17 @@ export class Mp3Demuxer extends Demuxer { return track.computeDuration(); } - async getMetadata() { + async getMetadataTags() { const release = await this.readingMutex.acquire(); try { await this.readMetadata(); - if (this.metadata) { - return this.metadata; + if (this.metadataTags) { + return this.metadataTags; } - this.metadata = {}; + this.metadataTags = {}; let currentPos = 0; let id3V2HeaderFound = false; @@ -178,7 +178,7 @@ export class Mp3Demuxer extends Demuxer { if (contentSlice instanceof Promise) contentSlice = await contentSlice; if (!contentSlice) break; - parseId3V2Tag(contentSlice, id3V2Header, this.metadata); + parseId3V2Tag(contentSlice, id3V2Header, this.metadataTags); currentPos = headerSlice.filePos + id3V2Header.size; } @@ -191,11 +191,11 @@ export class Mp3Demuxer extends Demuxer { const tag = readAscii(slice, 3); if (tag === 'TAG') { - parseId3V1Tag(slice, this.metadata); + parseId3V1Tag(slice, this.metadataTags); } } - return this.metadata; + return this.metadataTags; } finally { release(); } diff --git a/src/mp3/mp3-muxer.ts b/src/mp3/mp3-muxer.ts index 5589a49..a18335b 100644 --- a/src/mp3/mp3-muxer.ts +++ b/src/mp3/mp3-muxer.ts @@ -7,7 +7,7 @@ */ import { assert, assertNever, keyValueIterator, textEncoder, toDataView } from '../misc'; -import { mediaMetadataIsEmpty, MediaMetadata } from '../metadata'; +import { metadataTagsAreEmpty, MetadataTags } from '../tags'; import { Muxer } from '../muxer'; import { Output, OutputAudioTrack } from '../output'; import { Mp3OutputFormat } from '../output-format'; @@ -35,8 +35,8 @@ export class Mp3Muxer extends Muxer { } async start() { - if (!mediaMetadataIsEmpty(this.output._metadata)) { - this.writeId3v2Tag(this.output._metadata); + if (!metadataTagsAreEmpty(this.output._metadataTags)) { + this.writeId3v2Tag(this.output._metadataTags); } } @@ -122,7 +122,7 @@ export class Mp3Muxer extends Muxer { throw new Error('MP3 does not support subtitles.'); } - writeId3v2Tag(metadata: MediaMetadata) { + writeId3v2Tag(tags: MetadataTags) { this.mp3Writer.writeAscii('ID3'); this.mp3Writer.writeU8(0x04); // Version 2.4 this.mp3Writer.writeU8(0x00); // Revision 0 @@ -132,7 +132,7 @@ export class Mp3Muxer extends Muxer { const startPos = this.writer.getPos(); const writtenTags = new Set(); - for (const { key, value } of keyValueIterator(metadata)) { + for (const { key, value } of keyValueIterator(tags)) { switch (key) { case 'title': { this.mp3Writer.writeId3V2TextFrame('TIT2', value); @@ -160,8 +160,8 @@ export class Mp3Muxer extends Muxer { }; break; case 'trackNumber': { - const string = metadata.trackNumberMax !== undefined - ? `${value}/${metadata.trackNumberMax}` + const string = tags.tracksTotal !== undefined + ? `${value}/${tags.tracksTotal}` : value.toString(); this.mp3Writer.writeId3V2TextFrame('TRCK', string); @@ -169,8 +169,8 @@ export class Mp3Muxer extends Muxer { }; break; case 'discNumber': { - const string = metadata.discNumberMax !== undefined - ? `${value}/${metadata.discNumberMax}` + const string = tags.discsTotal !== undefined + ? `${value}/${tags.discsTotal}` : value.toString(); this.mp3Writer.writeId3V2TextFrame('TPOS', string); @@ -206,8 +206,8 @@ export class Mp3Muxer extends Muxer { } }; break; - case 'trackNumberMax': - case 'discNumberMax': { + case 'tracksTotal': + case 'discsTotal': { // Handled with trackNumber and discNumber respectively }; break; @@ -219,9 +219,9 @@ export class Mp3Muxer extends Muxer { } } - if (metadata.raw) { - for (const key in metadata.raw) { - const value = metadata.raw[key]; + if (tags.raw) { + for (const key in tags.raw) { + const value = tags.raw[key]; if (value == null || key.length !== 4 || writtenTags.has(key)) { continue; } diff --git a/src/mp3/mp3-reader.ts b/src/mp3/mp3-reader.ts index 9a6757b..869e702 100644 --- a/src/mp3/mp3-reader.ts +++ b/src/mp3/mp3-reader.ts @@ -7,7 +7,7 @@ */ import { decodeSynchsafe, FRAME_HEADER_SIZE, FrameHeader, readFrameHeader } from '../../shared/mp3-misc'; -import { MediaMetadata } from '../metadata'; +import { MetadataTags } from '../tags'; import { coalesceIndex, textDecoder } from '../misc'; import { FileSlice, readAscii, readBytes, Reader, readU32Be, readU8 } from '../reader'; @@ -89,25 +89,25 @@ export const readNextFrameHeader = async (reader: Reader, startPos: number, unti return null; }; -export const parseId3V1Tag = (slice: FileSlice, metadata: MediaMetadata) => { +export const parseId3V1Tag = (slice: FileSlice, tags: MetadataTags) => { const startPos = slice.filePos; - metadata.raw ??= {}; - metadata.raw['TAG'] ??= readBytes(slice, ID3_V1_TAG_SIZE - 3); // Dump the whole tag into the raw metadata + tags.raw ??= {}; + tags.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; + if (title) tags.title ??= title; const artist = readId3V1String(slice, 30); - if (artist) metadata.artist ??= artist; + if (artist) tags.artist ??= artist; const album = readId3V1String(slice, 30); - if (album) metadata.album ??= album; + if (album) tags.album ??= album; const yearText = readId3V1String(slice, 4); const year = Number.parseInt(yearText, 10); if (Number.isInteger(year) && year > 0) { - metadata.date ??= new Date(year, 0, 1); + tags.date ??= new Date(year, 0, 1); } const commentBytes = readBytes(slice, 30); @@ -118,7 +118,7 @@ export const parseId3V1Tag = (slice: FileSlice, metadata: MediaMetadata) => { if (commentBytes[28] === 0 && commentBytes[29] !== 0) { const trackNum = commentBytes[29]!; if (trackNum > 0) { - metadata.trackNumber ??= trackNum; + tags.trackNumber ??= trackNum; } slice.skip(-30); @@ -129,11 +129,11 @@ export const parseId3V1Tag = (slice: FileSlice, metadata: MediaMetadata) => { comment = readId3V1String(slice, 30); } - if (comment) metadata.comment ??= comment; + if (comment) tags.comment ??= comment; const genreIndex = readU8(slice); if (genreIndex < ID3_V1_GENRES.length) { - metadata.genre ??= ID3_V1_GENRES[genreIndex]; + tags.genre ??= ID3_V1_GENRES[genreIndex]; } }; @@ -171,7 +171,7 @@ export const readId3V2Header = (slice: FileSlice): Id3V2Header | null => { return { majorVersion, revision, flags, size }; }; -export const parseId3V2Tag = (slice: FileSlice, header: Id3V2Header, metadata: MediaMetadata) => { +export const parseId3V2Tag = (slice: FileSlice, header: Id3V2Header, tags: MetadataTags) => { // https://id3.org/id3v2.3.0 if (![2, 3, 4].includes(header.majorVersion)) { @@ -239,13 +239,13 @@ export const parseId3V2Tag = (slice: FileSlice, header: Id3V2Header, metadata: M reader.ununsynchronizeRegion(reader.pos, frameEndPos); } - metadata.raw ??= {}; + tags.raw ??= {}; if (frame.id[0] === 'T') { // It's a text frame, let's decode as text - metadata.raw[frame.id] ??= reader.readId3V2EncodingAndText(frameEndPos); + tags.raw[frame.id] ??= reader.readId3V2EncodingAndText(frameEndPos); } else { // For the others, let's just get the bytes - metadata.raw[frame.id] ??= reader.readBytes(frame.size); + tags.raw[frame.id] ??= reader.readBytes(frame.size); } reader.pos = frameStartPos; @@ -253,27 +253,27 @@ export const parseId3V2Tag = (slice: FileSlice, header: Id3V2Header, metadata: M switch (frame.id) { case 'TIT2': case 'TT2': { - metadata.title ??= reader.readId3V2EncodingAndText(frameEndPos); + tags.title ??= reader.readId3V2EncodingAndText(frameEndPos); }; break; case 'TIT3': case 'TT3': { - metadata.description ??= reader.readId3V2EncodingAndText(frameEndPos); + tags.description ??= reader.readId3V2EncodingAndText(frameEndPos); }; break; case 'TPE1': case 'TP1': { - metadata.artist ??= reader.readId3V2EncodingAndText(frameEndPos); + tags.artist ??= reader.readId3V2EncodingAndText(frameEndPos); }; break; case 'TALB': case 'TAL': { - metadata.album ??= reader.readId3V2EncodingAndText(frameEndPos); + tags.album ??= reader.readId3V2EncodingAndText(frameEndPos); }; break; case 'TPE2': case 'TP2': { - metadata.albumArtist ??= reader.readId3V2EncodingAndText(frameEndPos); + tags.albumArtist ??= reader.readId3V2EncodingAndText(frameEndPos); }; break; case 'TRCK': @@ -284,10 +284,10 @@ export const parseId3V2Tag = (slice: FileSlice, header: Id3V2Header, metadata: M const trackNumMax = parts[1] && Number.parseInt(parts[1], 10); if (Number.isInteger(trackNum) && trackNum > 0) { - metadata.trackNumber ??= trackNum; + tags.trackNumber ??= trackNum; } if (trackNumMax && Number.isInteger(trackNumMax) && trackNumMax > 0) { - metadata.trackNumberMax ??= trackNumMax; + tags.tracksTotal ??= trackNumMax; } }; break; @@ -299,10 +299,10 @@ export const parseId3V2Tag = (slice: FileSlice, header: Id3V2Header, metadata: M const discNumMax = parts[1] && Number.parseInt(parts[1], 10); if (Number.isInteger(discNum) && discNum > 0) { - metadata.discNumber ??= discNum; + tags.discNumber ??= discNum; } if (discNumMax && Number.isInteger(discNumMax) && discNumMax > 0) { - metadata.discNumberMax ??= discNumMax; + tags.discsTotal ??= discNumMax; } }; break; @@ -313,7 +313,7 @@ export const parseId3V2Tag = (slice: FileSlice, header: Id3V2Header, metadata: M if (match) { const genreNumber = Number.parseInt(match[1]!); if (ID3_V1_GENRES[genreNumber] !== undefined) { - metadata.genre ??= ID3_V1_GENRES[genreNumber]; + tags.genre ??= ID3_V1_GENRES[genreNumber]; break; } } @@ -322,12 +322,12 @@ export const parseId3V2Tag = (slice: FileSlice, header: Id3V2Header, metadata: M if (match) { const genreNumber = Number.parseInt(match[0]); if (ID3_V1_GENRES[genreNumber] !== undefined) { - metadata.genre ??= ID3_V1_GENRES[genreNumber]; + tags.genre ??= ID3_V1_GENRES[genreNumber]; break; } } - metadata.genre ??= genreText; + tags.genre ??= genreText; }; break; case 'TDRC': @@ -336,7 +336,7 @@ export const parseId3V2Tag = (slice: FileSlice, header: Id3V2Header, metadata: M const date = new Date(dateText); if (!Number.isNaN(date.getTime())) { - metadata.date ??= date; + tags.date ??= date; } }; break; @@ -346,7 +346,7 @@ export const parseId3V2Tag = (slice: FileSlice, header: Id3V2Header, metadata: M const year = Number.parseInt(yearText, 10); if (Number.isInteger(year)) { - metadata.date ??= new Date(year, 0, 1); + tags.date ??= new Date(year, 0, 1); } }; break; @@ -355,7 +355,7 @@ export const parseId3V2Tag = (slice: FileSlice, header: Id3V2Header, metadata: M const encoding = reader.readU8(); reader.pos += 3; // Skip language reader.readId3V2Text(encoding, frameEndPos); // Short content description - metadata.lyrics ??= reader.readId3V2Text(encoding, frameEndPos); + tags.lyrics ??= reader.readId3V2Text(encoding, frameEndPos); }; break; case 'COMM': @@ -363,7 +363,7 @@ export const parseId3V2Tag = (slice: FileSlice, header: Id3V2Header, metadata: M const encoding = reader.readU8(); reader.pos += 3; // Skip language reader.readId3V2Text(encoding, frameEndPos); // Short content description - metadata.comment ??= reader.readId3V2Text(encoding, frameEndPos); + tags.comment ??= reader.readId3V2Text(encoding, frameEndPos); }; break; case 'APIC': @@ -389,8 +389,8 @@ export const parseId3V2Tag = (slice: FileSlice, header: Id3V2Header, metadata: M if (imageDataSize >= 0) { const imageData = reader.readBytes(imageDataSize); - if (!metadata.images) metadata.images = []; - metadata.images.push({ + if (!tags.images) tags.images = []; + tags.images.push({ data: imageData, mimeType, kind: pictureType === 3 diff --git a/src/ogg/ogg-demuxer.ts b/src/ogg/ogg-demuxer.ts index 4fb4c54..88e248b 100644 --- a/src/ogg/ogg-demuxer.ts +++ b/src/ogg/ogg-demuxer.ts @@ -12,7 +12,7 @@ import { Demuxer } from '../demuxer'; import { Input } from '../input'; import { InputAudioTrack, InputAudioTrackBacking } from '../input-track'; import { PacketRetrievalOptions } from '../media-sink'; -import { MediaMetadata } from '../metadata'; +import { MetadataTags } from '../tags'; import { assert, AsyncMutex, @@ -60,7 +60,7 @@ export class OggDemuxer extends Demuxer { metadataPromise: Promise | null = null; bitstreams: LogicalBitstream[] = []; tracks: InputAudioTrack[] = []; - metadata: MediaMetadata = {}; + metadataTags: MetadataTags = {}; constructor(input: Input) { super(input); @@ -274,8 +274,8 @@ export class OggDemuxer extends Demuxer { if (vendorStringLength > 0) { // Expose the vendor string in the raw metadata - this.metadata.raw ??= {}; - this.metadata.raw['vendor'] ??= vendorString; + this.metadataTags.raw ??= {}; + this.metadataTags.raw['vendor'] ??= vendorString; } const listLength = commentView.getUint32(commentPos, true); @@ -299,36 +299,36 @@ export class OggDemuxer extends Demuxer { const key = string.slice(0, separatorIndex).toUpperCase(); const value = string.slice(separatorIndex + 1); - this.metadata.raw ??= {}; - this.metadata.raw[key] ??= value; + this.metadataTags.raw ??= {}; + this.metadataTags.raw[key] ??= value; switch (key) { case 'TITLE': { - this.metadata.title ??= value; + this.metadataTags.title ??= value; }; break; case 'DESCRIPTION': { - this.metadata.description ??= value; + this.metadataTags.description ??= value; }; break; case 'ARTIST': { - this.metadata.artist ??= value; + this.metadataTags.artist ??= value; }; break; case 'ALBUM': { - this.metadata.album ??= value; + this.metadataTags.album ??= value; }; break; case 'ALBUMARTIST': { - this.metadata.albumArtist ??= value; + this.metadataTags.albumArtist ??= value; }; break; case 'COMMENT': { - this.metadata.comment ??= value; + this.metadataTags.comment ??= value; }; break; case 'LYRICS': { - this.metadata.lyrics ??= value; + this.metadataTags.lyrics ??= value; }; break; case 'TRACKNUMBER': { @@ -337,17 +337,17 @@ export class OggDemuxer extends Demuxer { const trackNumMax = parts[1] && Number.parseInt(parts[1], 10); if (Number.isInteger(trackNum) && trackNum > 0) { - this.metadata.trackNumber ??= trackNum; + this.metadataTags.trackNumber ??= trackNum; } if (trackNumMax && Number.isInteger(trackNumMax) && trackNumMax > 0) { - this.metadata.trackNumberMax ??= trackNumMax; + this.metadataTags.tracksTotal ??= trackNumMax; } }; break; case 'TRACKTOTAL': { const trackNumMax = Number.parseInt(value, 10); if (Number.isInteger(trackNumMax) && trackNumMax > 0) { - this.metadata.trackNumberMax ??= trackNumMax; + this.metadataTags.tracksTotal ??= trackNumMax; } }; break; @@ -357,29 +357,29 @@ export class OggDemuxer extends Demuxer { const discNumMax = parts[1] && Number.parseInt(parts[1], 10); if (Number.isInteger(discNum) && discNum > 0) { - this.metadata.discNumber ??= discNum; + this.metadataTags.discNumber ??= discNum; } if (discNumMax && Number.isInteger(discNumMax) && discNumMax > 0) { - this.metadata.discNumberMax ??= discNumMax; + this.metadataTags.discsTotal ??= discNumMax; } }; break; case 'DISCTOTAL': { const trackNumMax = Number.parseInt(value, 10); if (Number.isInteger(trackNumMax) && trackNumMax > 0) { - this.metadata.trackNumberMax ??= trackNumMax; + this.metadataTags.tracksTotal ??= trackNumMax; } }; break; case 'DATE': { const date = new Date(value); if (!Number.isNaN(date.getTime())) { - this.metadata.date ??= date; + this.metadataTags.date ??= date; } }; break; case 'GENRE': { - this.metadata.genre ??= value; + this.metadataTags.genre ??= value; }; break; case 'METADATA_BLOCK_PICTURE': { @@ -401,8 +401,8 @@ export class OggDemuxer extends Demuxer { mediaTypeLength + descriptionLength + 32 + dataLength, ); - this.metadata.images ??= []; - this.metadata.images.push({ + this.metadataTags.images ??= []; + this.metadataTags.images.push({ data, mimeType: mediaType, kind: pictureType === 3 ? 'coverFront' : pictureType === 4 ? 'coverBack' : 'unknown', @@ -551,9 +551,9 @@ export class OggDemuxer extends Demuxer { return Math.max(0, ...trackDurations); } - async getMetadata() { + async getMetadataTags() { await this.readMetadata(); - return this.metadata; + return this.metadataTags; } } diff --git a/src/ogg/ogg-muxer.ts b/src/ogg/ogg-muxer.ts index 12a8a06..8e2cc5e 100644 --- a/src/ogg/ogg-muxer.ts +++ b/src/ogg/ogg-muxer.ts @@ -262,14 +262,14 @@ export class OggMuxer extends Muxer { createVorbisComments(headerBytes: Uint8Array) { // https://datatracker.ietf.org/doc/html/rfc7845#section-5.2 - const metadata = this.output._metadata; + const tags = this.output._metadataTags; const commentHeaderParts = [ headerBytes, ]; let vendorString = ''; - if (typeof metadata.raw?.['vendor'] === 'string') { - vendorString = metadata.raw?.['vendor']; + if (typeof tags.raw?.['vendor'] === 'string') { + vendorString = tags.raw?.['vendor']; } const encodedVendorString = textEncoder.encode(vendorString); @@ -295,7 +295,7 @@ export class OggMuxer extends Muxer { writtenTags.add(key); }; - for (const { key, value } of keyValueIterator(metadata)) { + for (const { key, value } of keyValueIterator(tags)) { switch (key) { case 'title': { addCommentTag('TITLE', value); @@ -333,22 +333,22 @@ export class OggMuxer extends Muxer { addCommentTag('LYRICS', value); }; break; - case 'discNumber': { - addCommentTag('TRACKNUMBER', value.toString()); - }; break; - - case 'discNumberMax': { - addCommentTag('TRACKTOTAL', value.toString()); - }; break; - case 'trackNumber': { addCommentTag('DISCNUMBER', value.toString()); }; break; - case 'trackNumberMax': { + case 'tracksTotal': { addCommentTag('DISCTOTAL', value.toString()); }; break; + case 'discNumber': { + addCommentTag('TRACKNUMBER', value.toString()); + }; break; + + case 'discsTotal': { + addCommentTag('TRACKTOTAL', value.toString()); + }; break; + case 'images': { for (const image of value) { // https://datatracker.ietf.org/doc/rfc9639/ Section 8.8 @@ -400,9 +400,9 @@ export class OggMuxer extends Muxer { } } - if (metadata.raw) { - for (const key in metadata.raw) { - const value = metadata.raw[key]; + if (tags.raw) { + for (const key in tags.raw) { + const value = tags.raw[key]; if (key === 'vendor' || value == null || writtenTags.has(key)) { continue; } diff --git a/src/output.ts b/src/output.ts index 234941f..e1ff034 100644 --- a/src/output.ts +++ b/src/output.ts @@ -7,7 +7,7 @@ */ import { AsyncMutex, isIso639Dash2LanguageCode, Rotation } from './misc'; -import { MediaMetadata, validateMediaMetadata } from './metadata'; +import { MetadataTags, validateMetadataTags } from './tags'; import { Muxer } from './muxer'; import { OutputFormat } from './output-format'; import { AudioSource, MediaSource, SubtitleSource, VideoSource } from './media-source'; @@ -147,7 +147,7 @@ export class Output< /** @internal */ _mutex = new AsyncMutex(); /** @internal */ - _metadata: MediaMetadata = {}; + _metadataTags: MetadataTags = {}; /** * Creates a new instance of {@link Output} which can then be used to create a new media file according to the @@ -220,14 +220,14 @@ export class Output< this._addTrack('subtitle', source, metadata); } - setMetadata(metadata: MediaMetadata) { - validateMediaMetadata(metadata); + setMetadataTags(tags: MetadataTags) { + validateMetadataTags(tags); if (this.state !== 'pending') { - throw new Error('Cannot set metadata after output has been started or canceled.'); + throw new Error('Cannot set metadata tags after output has been started or canceled.'); } - this._metadata = metadata; + this._metadataTags = tags; } /** @internal */ diff --git a/src/tags.ts b/src/tags.ts new file mode 100644 index 0000000..68b6947 --- /dev/null +++ b/src/tags.ts @@ -0,0 +1,133 @@ +export type MetadataTags = { + title?: string; + description?: string; + artist?: string; + album?: string; + albumArtist?: string; + trackNumber?: number; + tracksTotal?: number; + discNumber?: number; + discsTotal?: number; + genre?: string; + date?: Date; + lyrics?: string; + comment?: string; + images?: { + data: Uint8Array; + mimeType: string; + kind: 'coverFront' | 'coverBack' | 'unknown'; + name?: string; + description?: string; + }[]; + raw?: Record; +}; + +export class RichImageData { + constructor(public data: Uint8Array, public mimeType: string) {} +} + +export const validateMetadataTags = (tags: MetadataTags) => { + if (!tags || typeof tags !== 'object') { + throw new TypeError('tags must be an object.'); + } + if (tags.title !== undefined && typeof tags.title !== 'string') { + throw new TypeError('tags.title, when provided, must be a string.'); + } + if (tags.description !== undefined && typeof tags.description !== 'string') { + throw new TypeError('tags.description, when provided, must be a string.'); + } + if (tags.artist !== undefined && typeof tags.artist !== 'string') { + throw new TypeError('tags.artist, when provided, must be a string.'); + } + if (tags.album !== undefined && typeof tags.album !== 'string') { + throw new TypeError('tags.album, when provided, must be a string.'); + } + if (tags.albumArtist !== undefined && typeof tags.albumArtist !== 'string') { + throw new TypeError('tags.albumArtist, when provided, must be a string.'); + } + if (tags.trackNumber !== undefined && (!Number.isInteger(tags.trackNumber) || tags.trackNumber <= 0)) { + throw new TypeError('tags.trackNumber, when provided, must be a positive integer.'); + } + if ( + tags.tracksTotal !== undefined + && (!Number.isInteger(tags.tracksTotal) || tags.tracksTotal <= 0) + ) { + throw new TypeError('tags.tracksTotal, when provided, must be a positive integer.'); + } + if (tags.discNumber !== undefined && (!Number.isInteger(tags.discNumber) || tags.discNumber <= 0)) { + throw new TypeError('tags.discNumber, when provided, must be a positive integer.'); + } + if ( + tags.discsTotal !== undefined + && (!Number.isInteger(tags.discsTotal) || tags.discsTotal <= 0) + ) { + throw new TypeError('tags.discsTotal, when provided, must be a positive integer.'); + } + if (tags.genre !== undefined && typeof tags.genre !== 'string') { + throw new TypeError('tags.genre, when provided, must be a string.'); + } + if (tags.date !== undefined && (!(tags.date instanceof Date) || Number.isNaN(tags.date.getTime()))) { + throw new TypeError('tags.date, when provided, must be a valid Date.'); + } + if (tags.lyrics !== undefined && typeof tags.lyrics !== 'string') { + throw new TypeError('tags.lyrics, when provided, must be a string.'); + } + if (tags.images !== undefined) { + if (!Array.isArray(tags.images)) { + throw new TypeError('tags.images, when provided, must be an array.'); + } + for (const image of tags.images) { + if (!image || typeof image !== 'object') { + throw new TypeError('Each image in tags.images must be an object.'); + } + if (!(image.data instanceof Uint8Array)) { + throw new TypeError('Each image.data must be a Uint8Array.'); + } + if (typeof image.mimeType !== 'string') { + throw new TypeError('Each image.mimeType must be a string.'); + } + if (!['coverFront', 'coverBack', 'other'].includes(image.kind)) { + throw new TypeError('Each image.kind must be \'coverFront\', \'coverBack\', or \'other\'.'); + } + } + } + if (tags.comment !== undefined && typeof tags.comment !== 'string') { + throw new TypeError('tags.comment, when provided, must be a string.'); + } + if (tags.raw !== undefined) { + if (!tags.raw || typeof tags.raw !== 'object') { + throw new TypeError('tags.raw, when provided, must be an object.'); + } + + for (const value of Object.values(tags.raw)) { + if ( + value !== null + && typeof value !== 'string' + && !(value instanceof Uint8Array) + && !(value instanceof RichImageData) + ) { + throw new TypeError( + 'Each value in tags.raw must be a string, Uint8Array, RichImageData, or null.', + ); + } + } + } +}; + +export const metadataTagsAreEmpty = (tags: MetadataTags) => { + return tags.title === undefined + && tags.description === undefined + && tags.artist === undefined + && tags.album === undefined + && tags.albumArtist === undefined + && tags.trackNumber === undefined + && tags.tracksTotal === undefined + && tags.discNumber === undefined + && tags.discsTotal === undefined + && tags.genre === undefined + && tags.date === undefined + && tags.lyrics === undefined + && (!tags.images || tags.images.length === 0) + && tags.comment === undefined + && (tags.raw === undefined || Object.keys(tags.raw).length === 0); +}; diff --git a/src/wave/wave-demuxer.ts b/src/wave/wave-demuxer.ts index 6f4799e..0c7ef97 100644 --- a/src/wave/wave-demuxer.ts +++ b/src/wave/wave-demuxer.ts @@ -11,7 +11,7 @@ import { Demuxer } from '../demuxer'; import { Input } from '../input'; import { InputAudioTrack, InputAudioTrackBacking } from '../input-track'; import { PacketRetrievalOptions } from '../media-sink'; -import { MediaMetadata } from '../metadata'; +import { MetadataTags } from '../tags'; import { assert, UNDETERMINED_LANGUAGE } from '../misc'; import { EncodedPacket, PLACEHOLDER_DATA } from '../packet'; import { readAscii, readBytes, Reader, readU16, readU32, readU64 } from '../reader'; @@ -40,7 +40,7 @@ export class WaveDemuxer extends Demuxer { tracks: InputAudioTrack[] = []; lastKnownPacketIndex = 0; - metadata: MediaMetadata = {}; + metadataTags: MetadataTags = {}; constructor(input: Input) { super(input); @@ -204,25 +204,25 @@ export class WaveDemuxer extends Demuxer { const value = String.fromCharCode(...bytes.subarray(0, stringLength)); - this.metadata.raw ??= {}; - this.metadata.raw[chunkName] = value; + this.metadataTags.raw ??= {}; + this.metadataTags.raw[chunkName] = value; switch (chunkName) { case 'INAM': case 'TITL': { - this.metadata.title ??= value; + this.metadataTags.title ??= value; }; break; case 'TIT3': { - this.metadata.description ??= value; + this.metadataTags.description ??= value; }; break; case 'IART': { - this.metadata.artist ??= value; + this.metadataTags.artist ??= value; }; break; case 'IPRD': { - this.metadata.album ??= value; + this.metadataTags.album ??= value; }; break; case 'IPRT': @@ -233,10 +233,10 @@ export class WaveDemuxer extends Demuxer { const trackNumMax = parts[1] && Number.parseInt(parts[1], 10); if (Number.isInteger(trackNum) && trackNum > 0) { - this.metadata.trackNumber ??= trackNum; + this.metadataTags.trackNumber ??= trackNum; } if (trackNumMax && Number.isInteger(trackNumMax) && trackNumMax > 0) { - this.metadata.trackNumberMax ??= trackNumMax; + this.metadataTags.tracksTotal ??= trackNumMax; } }; break; @@ -244,26 +244,26 @@ export class WaveDemuxer extends Demuxer { case 'IDIT': { const date = new Date(value); if (!Number.isNaN(date.getTime())) { - this.metadata.date ??= date; + this.metadataTags.date ??= date; } }; break; case 'YEAR': { const year = Number.parseInt(value, 10); if (Number.isInteger(year) && year > 0) { - this.metadata.date ??= new Date(year, 0, 1); + this.metadataTags.date ??= new Date(year, 0, 1); } }; break; case 'IGNR': case 'GENR': { - this.metadata.genre ??= value; + this.metadataTags.genre ??= value; }; break; case 'ICMT': case 'CMNT': case 'COMM': { - this.metadata.comment ??= value; + this.metadataTags.comment ??= value; }; break; } @@ -319,9 +319,9 @@ export class WaveDemuxer extends Demuxer { return this.tracks; } - async getMetadata() { + async getMetadataTags() { await this.readMetadata(); - return this.metadata; + return this.metadataTags; } } diff --git a/src/wave/wave-muxer.ts b/src/wave/wave-muxer.ts index 33efd3f..c8126da 100644 --- a/src/wave/wave-muxer.ts +++ b/src/wave/wave-muxer.ts @@ -15,7 +15,7 @@ import { Writer } from '../writer'; import { EncodedPacket } from '../packet'; import { WavOutputFormat } from '../output-format'; import { assert, assertNever, keyValueIterator } from '../misc'; -import { MediaMetadata, mediaMetadataIsEmpty } from '../metadata'; +import { MetadataTags, metadataTagsAreEmpty } from '../tags'; export class WaveMuxer extends Muxer { private format: WavOutputFormat; @@ -159,9 +159,9 @@ export class WaveMuxer extends Muxer { this.riffWriter.writeU16(blockSize); this.riffWriter.writeU16(8 * pcmInfo.sampleSize); - if (!mediaMetadataIsEmpty(this.output._metadata)) { + if (!metadataTagsAreEmpty(this.output._metadataTags)) { // Metadata exists, let's write an INFO chunk - this.writeInfoChunk(this.output._metadata); + this.writeInfoChunk(this.output._metadataTags); } // data chunk @@ -180,7 +180,7 @@ export class WaveMuxer extends Muxer { } } - private writeInfoChunk(metadata: MediaMetadata) { + private writeInfoChunk(metadata: MetadataTags) { const startPos = this.writer.getPos(); this.riffWriter.writeAscii('LIST'); @@ -207,8 +207,8 @@ export class WaveMuxer extends Muxer { }; break; case 'trackNumber': { - const string = metadata.trackNumberMax !== undefined - ? `${value}/${metadata.trackNumberMax}` + const string = metadata.tracksTotal !== undefined + ? `${value}/${metadata.tracksTotal}` : value.toString(); this.writeInfoTag('ITRK', string); @@ -232,8 +232,8 @@ export class WaveMuxer extends Muxer { case 'albumArtist': case 'discNumber': - case 'trackNumberMax': - case 'discNumberMax': + case 'tracksTotal': + case 'discsTotal': case 'description': case 'lyrics': case 'images': {