Add special TXXX handling for ID3v2

This commit is contained in:
Vanilagy
2026-04-24 19:42:15 +02:00
parent 9969b92d51
commit 97f6b35cda
4 changed files with 82 additions and 11 deletions
+54 -7
View File
@@ -16,6 +16,7 @@ import {
assertNever, assertNever,
keyValueIterator, keyValueIterator,
toDataView, toDataView,
isRecordStringString,
} from './misc'; } from './misc';
import { FileSlice, readAscii, readBytes, readU32Be, readU8 } from './reader'; import { FileSlice, readAscii, readBytes, readU32Be, readU8 } from './reader';
import { Writer } from './writer'; import { Writer } from './writer';
@@ -225,7 +226,14 @@ export const parseId3V2Tag = (slice: FileSlice, header: Id3V2Header, tags: Metad
} }
tags.raw ??= {}; tags.raw ??= {};
if (frame.id[0] === 'T') { if (frame.id === 'TXXX') {
const txxx = tags.raw['TXXX'] ??= {};
const encoding = reader.readId3V2TextEncoding();
const description = reader.readId3V2Text(encoding, frameEndPos);
const value = reader.readId3V2Text(encoding, frameEndPos);
(txxx as Record<string, string>)[description] ??= value;
} else if (frame.id[0] === 'T') {
// It's a text frame, let's decode as text // It's a text frame, let's decode as text
tags.raw[frame.id] ??= reader.readId3V2EncodingAndText(frameEndPos); tags.raw[frame.id] ??= reader.readId3V2EncodingAndText(frameEndPos);
} else { } else {
@@ -761,13 +769,51 @@ export class Id3V2Writer {
let bytes: Uint8Array; let bytes: Uint8Array;
if (typeof value === 'string') { if (typeof value === 'string') {
const encoded = textEncoder.encode(value); const useIso88591 = isIso88591Compatible(value);
bytes = new Uint8Array(encoded.byteLength + 2); if (useIso88591) {
bytes[0] = Id3V2TextEncoding.UTF_8; bytes = new Uint8Array(value.length + 2);
bytes.set(encoded, 1); bytes[0] = Id3V2TextEncoding.ISO_8859_1;
// Last byte is the null terminator for (let i = 0; i < value.length; i++) {
bytes[i + 1] = value.charCodeAt(i);
}
// Last byte is the null terminator
} else {
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) { } else if (value instanceof Uint8Array) {
bytes = value; bytes = value;
} else if (key === 'TXXX' && isRecordStringString(value)) {
for (const description in value) {
const frameValue = value[description]!;
const useIso88591 = isIso88591Compatible(description) && isIso88591Compatible(frameValue);
const descriptionDataLength = useIso88591
? description.length
: textEncoder.encode(description).byteLength;
const valueData = useIso88591 ? null : textEncoder.encode(frameValue);
const valueDataLength = useIso88591 ? frameValue.length : valueData!.byteLength;
const frameSize = 1 + descriptionDataLength + 1 + valueDataLength;
this.writeAscii('TXXX');
this.writeSynchsafeU32(frameSize);
this.writeU16(0x0000);
this.writeU8(useIso88591 ? Id3V2TextEncoding.ISO_8859_1 : Id3V2TextEncoding.UTF_8);
if (useIso88591) {
this.writeIsoString(description);
for (let i = 0; i < frameValue.length; i++) {
this.writeU8(frameValue.charCodeAt(i));
}
} else {
this.writeUtf8String(description);
this.writer.write(valueData!);
}
}
continue;
} else { } else {
continue; continue;
} }
@@ -821,7 +867,8 @@ export class Id3V2Writer {
for (let i = 0; i < text.length; i++) { for (let i = 0; i < text.length; i++) {
bytes[i] = text.charCodeAt(i); bytes[i] = text.charCodeAt(i);
} }
bytes[text.length] = 0x00; // Last byte is the null terminator
this.writer.write(bytes); this.writer.write(bytes);
} }
+9 -4
View File
@@ -6,6 +6,8 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/. * file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/ */
import { isRecordStringString } from './misc';
/** /**
* Represents descriptive (non-technical) metadata about a media file, such as title, author, date, cover art, or other * Represents descriptive (non-technical) metadata about a media file, such as title, author, date, cover art, or other
* attached files. Common tags are normalized by Mediabunny into a uniform format, while the `raw` field can be used to * attached files. Common tags are normalized by Mediabunny into a uniform format, while the `raw` field can be used to
@@ -69,8 +71,9 @@ export type MetadataTags = {
* - WebM/Matroska: `SimpleTag` elements whose target is 50 (MOVIE), either containing string or `Uint8Array` * - WebM/Matroska: `SimpleTag` elements whose target is 50 (MOVIE), either containing string or `Uint8Array`
* values. Additionally, all attached files (such as font files) are included here, where the key corresponds to * values. Additionally, all attached files (such as font files) are included here, where the key corresponds to
* the FileUID and the value is an {@link AttachedFile}. * the FileUID and the value is an {@link AttachedFile}.
* - MP3: The ID3v2 tags, or a single `'TAG'` key with the contents of the ID3v1 tag. * - MP3: The ID3v2 tags, or a single `'TAG'` key with the contents of the ID3v1 tag. The ID3v2 `'TXXX'`
* - ADTS: The ID3v2 tags. * user-defined text frames are exposed as a `Record<string, string>`.
* - 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). * - 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.
* - WAVE: The individual metadata chunks within the RIFF INFO chunk. Values are always ISO 8859-1 strings. * - WAVE: The individual metadata chunks within the RIFF INFO chunk. Values are always ISO 8859-1 strings.
@@ -78,7 +81,7 @@ export type MetadataTags = {
* Additionally, the `'vendor'` key refers to the vendor string within this header. * Additionally, the `'vendor'` key refers to the vendor string within this header.
* - MPEG-TS: Not supported. * - MPEG-TS: Not supported.
*/ */
raw?: Record<string, string | Uint8Array | RichImageData | AttachedFile | null>; raw?: Record<string, string | Uint8Array | RichImageData | AttachedFile | Record<string, string> | null>;
}; };
/** /**
@@ -236,9 +239,11 @@ export const validateMetadataTags = (tags: MetadataTags) => {
&& !(value instanceof Uint8Array) && !(value instanceof Uint8Array)
&& !(value instanceof RichImageData) && !(value instanceof RichImageData)
&& !(value instanceof AttachedFile) && !(value instanceof AttachedFile)
&& !isRecordStringString(value)
) { ) {
throw new TypeError( throw new TypeError(
'Each value in tags.raw must be a string, Uint8Array, RichImageData, AttachedFile, or null.', 'Each value in tags.raw must be a string, Uint8Array, RichImageData, AttachedFile, '
+ 'Record<string, string>, or null.',
); );
} }
} }
+7
View File
@@ -1009,3 +1009,10 @@ export const clearIntervalUnthrottled = (timer: UnthrottledTimerHandle) => {
timerId: timer.id, timerId: timer.id,
} satisfies UnthrottledTimerMessage); } satisfies UnthrottledTimerMessage);
}; };
export const isRecordStringString = (value: unknown): value is Record<string, string> => {
return value !== null
&& typeof value === 'object'
&& Object.getPrototypeOf(value) === Object.prototype
&& Object.values(value).every(x => typeof x === 'string');
};
+12
View File
@@ -338,6 +338,12 @@ test('Read and write metadata, MP3', async () => {
...songMetadata, ...songMetadata,
raw: { raw: {
TXXY: 'ID3v2 goated', TXXY: 'ID3v2 goated',
TXXZ: '鱼',
TXXX: {
foo: 'bar',
baz: 'qux',
你: '好',
},
}, },
}); });
@@ -377,6 +383,12 @@ test('Read and write metadata, MP3', async () => {
expect(readTags.raw!['TIT2']).toBe(songMetadata.title); expect(readTags.raw!['TIT2']).toBe(songMetadata.title);
expect(readTags.raw!['APIC']).instanceOf(Uint8Array); expect(readTags.raw!['APIC']).instanceOf(Uint8Array);
expect(readTags.raw!['TXXY']).toBe('ID3v2 goated'); expect(readTags.raw!['TXXY']).toBe('ID3v2 goated');
expect(readTags.raw!['TXXZ']).toBe('鱼');
expect(readTags.raw!['TXXX']).toEqual({
foo: 'bar',
baz: 'qux',
你: '好',
});
}); });
test('Read and write metadata, Ogg', async () => { test('Read and write metadata, Ogg', async () => {