From b2b408749c77a12a32d2983f77649a12693e8843 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:01:31 +0200 Subject: [PATCH] Surface repeated keys are string[] for Vorbis-style metadata (fixes #490) --- src/codec-data.ts | 34 +++++++++++++++++++++------------- src/conversion.ts | 2 +- src/metadata.ts | 13 ++++++++----- 3 files changed, 30 insertions(+), 19 deletions(-) diff --git a/src/codec-data.ts b/src/codec-data.ts index acb5988..096caaa 100644 --- a/src/codec-data.ts +++ b/src/codec-data.ts @@ -2597,7 +2597,13 @@ export const readVorbisComments = (bytes: Uint8Array, metadataTags: MetadataTags const value = string.slice(separatorIndex + 1); metadataTags.raw ??= {}; - metadataTags.raw[key] ??= value; + if (Array.isArray(metadataTags.raw[key])) { + metadataTags.raw[key] = [...metadataTags.raw[key], value]; + } else if (typeof metadataTags.raw[key] === 'string') { + metadataTags.raw[key] = [metadataTags.raw[key], value]; + } else { + metadataTags.raw[key] ??= value; + } switch (key) { case 'TITLE': { @@ -2728,7 +2734,7 @@ export const createVorbisComments = (headerBytes: Uint8Array, tags: MetadataTags commentHeaderParts.push(currentBuffer); - const writtenTags = new Set(); + const writtenTags: string[] = []; const addCommentTag = (key: string, value: string) => { const joined = `${key}=${value}`; const encoded = textEncoder.encode(joined); @@ -2740,7 +2746,7 @@ export const createVorbisComments = (headerBytes: Uint8Array, tags: MetadataTags currentBuffer.set(encoded, 4); commentHeaderParts.push(currentBuffer); - writtenTags.add(key); + writtenTags.push(key); }; for (const { key, value } of keyValueIterator(tags)) { @@ -2770,12 +2776,7 @@ export const createVorbisComments = (headerBytes: Uint8Array, tags: MetadataTags }; break; case 'date': { - const rawVersion = tags.raw?.['DATE'] ?? tags.raw?.['date']; - if (rawVersion && typeof rawVersion === 'string') { - addCommentTag('DATE', rawVersion); - } else { - addCommentTag('DATE', value.toISOString().slice(0, 10)); - } + addCommentTag('DATE', value.toISOString().slice(0, 10)); }; break; case 'comment': { @@ -2803,8 +2804,7 @@ export const createVorbisComments = (headerBytes: Uint8Array, tags: MetadataTags }; break; case 'images': { - // For example, in .flac, we put the pictures in a different section, - // not in the Vorbis comment header. + // For example, in .flac, we put the pictures in a different section, not in the Vorbis comment header. if (!writeImages) { break; } @@ -2861,18 +2861,26 @@ export const createVorbisComments = (headerBytes: Uint8Array, tags: MetadataTags if (tags.raw) { for (const key in tags.raw) { const value = tags.raw[key] ?? tags.raw[key.toLowerCase()]; - if (key === 'vendor' || value == null || writtenTags.has(key)) { + if (key === 'vendor' || value == null || writtenTags.includes(key)) { continue; } if (typeof value === 'string') { addCommentTag(key, value); + } else if (Array.isArray(value)) { + // String arrays turn into the tag being repeated for each element + const isOnlyStrings = value.every(x => typeof x === 'string'); + if (isOnlyStrings) { + for (const elem of value) { + addCommentTag(key, elem); + } + } } } } const listLengthBuffer = new Uint8Array(4); - toDataView(listLengthBuffer).setUint32(0, writtenTags.size, true); + toDataView(listLengthBuffer).setUint32(0, writtenTags.length, true); commentHeaderParts.splice(2, 0, listLengthBuffer); // Insert after the header and vendor section // Merge all comment header parts into a single buffer diff --git a/src/conversion.ts b/src/conversion.ts index f3ad24c..fb28d9b 100644 --- a/src/conversion.ts +++ b/src/conversion.ts @@ -1101,7 +1101,7 @@ export class Conversion { const inputAndOutputFormatMatch = inputFormat.mimeType === this.output.format.mimeType; const rawTagsAreUnchanged = inputTags.raw === outputTags.raw; - if (inputTags.raw && rawTagsAreUnchanged && !inputAndOutputFormatMatch) { + if (rawTagsAreUnchanged && !inputAndOutputFormatMatch) { // If the input and output formats aren't the same, copying over raw metadata tags makes no sense and // only results in junk tags, so let's cut them out. delete outputTags.raw; diff --git a/src/metadata.ts b/src/metadata.ts index 73e679d..b07d014 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -76,14 +76,16 @@ export type MetadataTags = { * user-defined text frames are exposed as a `Record`. * - ADTS: The ID3v2 tags, just like in MP3. * - Ogg: The key-value string pairs from the Vorbis-style comment header (see RFC 7845, Section 5.2). - * Additionally, the `'vendor'` key refers to the vendor string within this header. + * Additionally, the `'vendor'` key refers to the vendor string within this header. If a key exists more than once, + * a string array is used instead. * - WAVE: The individual metadata chunks within the RIFF INFO chunk. Values are always ISO 8859-1 strings. * - FLAC: The key-value string pairs from the vorbis metadata block (see RFC 9639, Section D.2.3). - * Additionally, the `'vendor'` key refers to the vendor string within this header. If ID3v2 tags appear at the - * start of the file, their content is stored just like for MP3. + * Additionally, the `'vendor'` key refers to the vendor string within this header. If a key exists more than once, + * a string array is used instead. If ID3v2 tags appear at the start of the file, their content is stored just like + * for MP3. * - MPEG-TS: Not supported. */ - raw?: Record | null>; + raw?: Record | null>; }; /** @@ -238,13 +240,14 @@ export const validateMetadataTags = (tags: MetadataTags) => { if ( value !== null && typeof value !== 'string' + && !(Array.isArray(value) && value.every(x => typeof x === 'string')) && !(value instanceof Uint8Array) && !(value instanceof RichImageData) && !(value instanceof AttachedFile) && !isRecordStringString(value) ) { throw new TypeError( - 'Each value in tags.raw must be a string, Uint8Array, RichImageData, AttachedFile, ' + 'Each value in tags.raw must be a string, string array, Uint8Array, RichImageData, AttachedFile, ' + 'Record, or null.', ); }