diff --git a/build/types/complete b/build/types/complete
index 5ae17fd65..0d42a0a19 100644
--- a/build/types/complete
+++ b/build/types/complete
@@ -6,6 +6,7 @@
+@fairplay
+@networking
+@manifests
++@metadata
+@polyfill
+@polyfillForUI
+@queue
diff --git a/build/types/core b/build/types/core
index e1e6cb225..25a5a6d58 100644
--- a/build/types/core
+++ b/build/types/core
@@ -82,6 +82,9 @@
+../../lib/transmuxer/transmuxer_proxy.js
+../../lib/transmuxer/transmuxer_utils.js
++../../lib/metadata/id3_utils.js
++../../lib/metadata/metadata.js
+
+../../lib/util/abortable_operation.js
+../../lib/util/array_utils.js
+../../lib/util/buffer_utils.js
@@ -101,7 +104,6 @@
+../../lib/util/functional.js
+../../lib/util/i_destroyable.js
+../../lib/util/i_releasable.js
-+../../lib/util/id3_utils.js
+../../lib/util/iterables.js
+../../lib/util/language_utils.js
+../../lib/util/lazy.js
diff --git a/build/types/metadata b/build/types/metadata
new file mode 100644
index 000000000..dc002ef94
--- /dev/null
+++ b/build/types/metadata
@@ -0,0 +1,5 @@
+# Metadata
+
++../../lib/metadata/id3v1_utils.js
++../../lib/metadata/ilst_utils.js
++../../lib/metadata/vorbis_utils.js
diff --git a/demo/common/assets.js b/demo/common/assets.js
index f9fadfa36..dbe609b08 100644
--- a/demo/common/assets.js
+++ b/demo/common/assets.js
@@ -744,6 +744,13 @@ shakaAssets.testAssets = [
.addFeature(shakaAssets.Feature.HLS)
.addFeature(shakaAssets.Feature.LIVE)
.addFeature(shakaAssets.Feature.MP4),
+ new ShakaDemoAssetInfo(
+ /* name= */ 'Raw MP3 with ID3 metadata (src=)',
+ /* iconUri= */ '',
+ /* manifestUri= */ 'https://raw.githubusercontent.com/shaka-project/shaka-player-history/refs/heads/main/sources/bach/John_Lewis_Grant_-_01_-_Bach_Prelude___Fugue_1.mp3',
+ /* source= */ shakaAssets.Source.SHAKA)
+ .addFeature(shakaAssets.Feature.AUDIO_ONLY)
+ .addFeature(shakaAssets.Feature.CONTAINERLESS),
// End Shaka assets }}}
// Axinom assets {{{
diff --git a/demo/config.js b/demo/config.js
index 418b2ff98..b31d69cf7 100644
--- a/demo/config.js
+++ b/demo/config.js
@@ -725,7 +725,10 @@ shakaDemo.Config = class {
'streaming.returnToEndOfLiveWindowWhenOutside')
.addBoolInput_(
'Stop fetching new segments on pause',
- 'streaming.stopFetchingOnPause');
+ 'streaming.stopFetchingOnPause')
+ .addBoolInput_(
+ 'Process metadata when using src=',
+ 'streaming.processSrcEqualMetadata');
this.addRetrySection_('streaming', 'Streaming Retry Parameters');
this.addLiveSyncSection_();
}
diff --git a/externs/shaka/metadata_parser.js b/externs/shaka/metadata_parser.js
new file mode 100644
index 000000000..385a0fa44
--- /dev/null
+++ b/externs/shaka/metadata_parser.js
@@ -0,0 +1,80 @@
+/*! @license
+ * Shaka Player
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+
+/**
+ * @externs
+ */
+
+
+/**
+ * Parses binary media data and returns the metadata frames it contains.
+ *
+ * A metadata parser is responsible for extracting tag information from a
+ * specific container/codec format (e.g. ID3v2, Vorbis comments, MP4 ILST).
+ * Parsers are registered with {@link shaka.metadata.Metadata} via
+ * {@link shaka.metadata.Metadata.registerParserByMime} and are looked up at
+ * runtime by MIME type.
+ *
+ *
Key naming convention
+ * Every {@link shaka.extern.MetadataFrame} produced by a parser MUST use the
+ * corresponding ID3v2.4 four-character frame ID as its
+ * {@code key} whenever one exists for the concept being described. This
+ * ensures that consumers can work with a single, format-agnostic vocabulary
+ * regardless of the underlying container.
+ *
+ * Examples of the expected mapping:
+ *
+ * - Track title → {@code TIT2}
+ * - Lead artist → {@code TPE1}
+ * - Album name → {@code TALB}
+ * - Genre → {@code TCON}
+ * - Recording year / date → {@code TDRC}
+ * - Track number → {@code TRCK}
+ * - Disc number → {@code TPOS}
+ * - Comment → {@code COMM}
+ * - Attached picture / cover art → {@code APIC}
+ *
+ *
+ * If no ID3v2.4 equivalent exists for a given tag (e.g. a vendor-specific
+ * freeform field), the parser MAY use any non-empty string as the key, but
+ * MUST NOT shadow a well-known ID3v2.4 frame ID with unrelated data.
+ *
+ * @interface
+ * @exportDoc
+ */
+shaka.extern.MetadataParser = class {
+ constructor() {}
+
+ /**
+ * Parses {@code data} and returns every metadata frame found in it.
+ *
+ * Implementations MUST:
+ *
+ * - Return an empty array (never {@code null}) when no frames are
+ * found or the data is not recognised.
+ * - Not throw; all errors should be swallowed and result in an empty
+ * or partial return value.
+ * - Use ID3v2.4 four-character frame IDs as {@code key} values
+ * wherever a mapping exists (see class-level documentation).
+ *
+ *
+ * @param {!Uint8Array} data Raw bytes of the media segment or file.
+ * @return {!Array}
+ * @exportDoc
+ */
+ parse(data) {}
+};
+
+
+/**
+ * A factory function that creates a {@link shaka.extern.MetadataParser}
+ * instance.
+ *
+ * @typedef {function():!shaka.extern.MetadataParser}
+ * @exportDoc
+ */
+shaka.extern.MetadataParser.Factory;
diff --git a/externs/shaka/player.js b/externs/shaka/player.js
index caea394ca..2a463ad79 100644
--- a/externs/shaka/player.js
+++ b/externs/shaka/player.js
@@ -2047,6 +2047,7 @@ shaka.extern.SpeechToTextConfiguration;
* returnToEndOfLiveWindowWhenOutside: boolean,
* stopFetchingOnPause: boolean,
* clampAppendWindowToDuration: boolean,
+ * processSrcEqualMetadata: boolean,
* }}
*
* @description
@@ -2307,6 +2308,14 @@ shaka.extern.SpeechToTextConfiguration;
* "ended" when seeking to end.
*
* Defaults to false.
+ * @property {boolean} processSrcEqualMetadata
+ * If true, Shaka Player checks if the content MIME type supports
+ * metadata extraction (such as ID3, Vorbis Comments, or iTunes ILST).
+ * For supported types, the content is downloaded and loaded through a
+ * Blob URL instead of being passed directly to the HTMLMediaElement.
+ * This allows metadata frames to be parsed and exposed by the player.
+ *
+ * Defaults to true.
* @exportDoc
*/
shaka.extern.StreamingConfiguration;
diff --git a/lib/media/media_source_engine.js b/lib/media/media_source_engine.js
index f5f5d36f1..25911a2ec 100644
--- a/lib/media/media_source_engine.js
+++ b/lib/media/media_source_engine.js
@@ -16,6 +16,7 @@ goog.require('shaka.media.ClosedCaptionParser');
goog.require('shaka.media.ManifestParser');
goog.require('shaka.media.SegmentReference');
goog.require('shaka.media.TimeRangesUtils');
+goog.require('shaka.metadata.Id3Utils');
goog.require('shaka.text.TextEngine');
goog.require('shaka.transmuxer.TransmuxerEngine');
goog.require('shaka.transmuxer.TransmuxerProxy');
@@ -27,7 +28,6 @@ goog.require('shaka.util.Error');
goog.require('shaka.util.EventManager');
goog.require('shaka.util.FakeEvent');
goog.require('shaka.util.IDestroyable');
-goog.require('shaka.util.Id3Utils');
goog.require('shaka.util.ManifestParserUtils');
goog.require('shaka.util.MediaElementEvent');
goog.require('shaka.util.MimeUtils');
@@ -872,7 +872,7 @@ shaka.media.MediaSourceEngine = class {
const uint8ArrayData = shaka.util.BufferUtils.toUint8(data);
if (shaka.util.MimeUtils.RAW_FORMATS.includes(mimeType)) {
- const frames = shaka.util.Id3Utils.getID3Frames(uint8ArrayData);
+ const frames = shaka.metadata.Id3Utils.getID3Frames(uint8ArrayData);
if (frames.length && reference) {
const metadataTimestamp = frames.find((frame) => {
return frame.description ===
@@ -1092,7 +1092,7 @@ shaka.media.MediaSourceEngine = class {
(emsg.schemeIdUri == 'https://aomedia.org/emsg/ID3' ||
emsg.schemeIdUri == 'https://developer.apple.com/streaming/emsg-id3')) {
// See https://aomediacodec.github.io/id3-emsg/
- const frames = shaka.util.Id3Utils.getID3Frames(emsg.messageData);
+ const frames = shaka.metadata.Id3Utils.getID3Frames(emsg.messageData);
if (frames.length) {
const startTime = emsg.startTime;
/** @private {shaka.extern.ID3Metadata} */
diff --git a/lib/util/id3_utils.js b/lib/metadata/id3_utils.js
similarity index 87%
rename from lib/util/id3_utils.js
rename to lib/metadata/id3_utils.js
index a4a291b21..505561bf0 100644
--- a/lib/util/id3_utils.js
+++ b/lib/metadata/id3_utils.js
@@ -4,18 +4,21 @@
* SPDX-License-Identifier: Apache-2.0
*/
-goog.provide('shaka.util.Id3Utils');
+goog.provide('shaka.metadata.Id3Utils');
goog.require('shaka.log');
+goog.require('shaka.metadata.Metadata');
goog.require('shaka.util.BufferUtils');
+goog.require('shaka.util.MimeUtils');
goog.require('shaka.util.StringUtils');
/**
* @summary A set of Id3Utils utility functions.
+ * @implements {shaka.extern.MetadataParser}
* @export
*/
-shaka.util.Id3Utils = class {
+shaka.metadata.Id3Utils = class {
/**
* @param {Uint8Array} data
* @param {number} offset
@@ -138,13 +141,13 @@ shaka.util.Id3Utils = class {
static decodeString_(data, encoding) {
const StringUtils = shaka.util.StringUtils;
switch (encoding) {
- case shaka.util.Id3Utils.LATIN1_encoding:
+ case shaka.metadata.Id3Utils.LATIN1_encoding:
return Array.from(data).map((b) => String.fromCharCode(b)).join('');
- case shaka.util.Id3Utils.UTF16BOM_encoding:
+ case shaka.metadata.Id3Utils.UTF16BOM_encoding:
return StringUtils.fromBytesAutoDetect(data);
- case shaka.util.Id3Utils.UTF16BE_encoding:
+ case shaka.metadata.Id3Utils.UTF16BE_encoding:
return StringUtils.fromUTF16(data, /* littleEndian= */ false);
- case shaka.util.Id3Utils.UTF8_encoding:
+ case shaka.metadata.Id3Utils.UTF8_encoding:
return StringUtils.fromUTF8(data);
default:
return '';
@@ -160,8 +163,8 @@ shaka.util.Id3Utils = class {
* @private
*/
static nullTermSize_(encoding) {
- return (encoding === shaka.util.Id3Utils.UTF16BOM_encoding ||
- encoding === shaka.util.Id3Utils.UTF16BE_encoding) ? 2 : 1;
+ return (encoding === shaka.metadata.Id3Utils.UTF16BOM_encoding ||
+ encoding === shaka.metadata.Id3Utils.UTF16BE_encoding) ? 2 : 1;
}
/**
@@ -178,8 +181,8 @@ shaka.util.Id3Utils = class {
* @private
*/
static findNull_(data, encoding, offset = 0) {
- if (encoding === shaka.util.Id3Utils.UTF16BOM_encoding ||
- encoding === shaka.util.Id3Utils.UTF16BE_encoding) {
+ if (encoding === shaka.metadata.Id3Utils.UTF16BOM_encoding ||
+ encoding === shaka.metadata.Id3Utils.UTF16BE_encoding) {
for (let i = offset; i + 1 < data.length; i += 2) {
if (data[i] === 0 && data[i + 1] === 0) {
return i;
@@ -203,8 +206,8 @@ shaka.util.Id3Utils = class {
* Flags $xx xx
*/
const type = String.fromCharCode(data[0], data[1], data[2], data[3]);
- const size = version >= 4 ? shaka.util.Id3Utils.readSize_(data, 4) :
- shaka.util.Id3Utils.readSizeBE_(data, 4);
+ const size = version >= 4 ? shaka.metadata.Id3Utils.readSize_(data, 4) :
+ shaka.metadata.Id3Utils.readSizeBE_(data, 4);
// skip frame id, size, and flags
const offset = 10;
@@ -222,7 +225,7 @@ shaka.util.Id3Utils = class {
* @private
*/
static decodeFrame_(frame) {
- const Id3Utils = shaka.util.Id3Utils;
+ const Id3Utils = shaka.metadata.Id3Utils;
const BufferUtils = shaka.util.BufferUtils;
const StringUtils = shaka.util.StringUtils;
@@ -470,7 +473,7 @@ shaka.util.Id3Utils = class {
* @export
*/
static getID3Frames(id3Data) {
- const Id3Utils = shaka.util.Id3Utils;
+ const Id3Utils = shaka.metadata.Id3Utils;
let offset = 0;
const frames = [];
while (Id3Utils.isHeader_(id3Data, offset)) {
@@ -520,7 +523,7 @@ shaka.util.Id3Utils = class {
* @export
*/
static getID3Data(id3Data, offset = 0) {
- const Id3Utils = shaka.util.Id3Utils;
+ const Id3Utils = shaka.metadata.Id3Utils;
const front = offset;
let length = 0;
@@ -549,65 +552,11 @@ shaka.util.Id3Utils = class {
}
/**
- * Returns metadata frames found in ID3v1 tags.
- *
- * @param {Uint8Array} data
- * @return {!Array}
+ * @override
+ * @export
*/
- static getID3v1Frames(data) {
- const frames = [];
- const v1Offset = data.length - 128;
-
- if (v1Offset < 0 ||
- data[v1Offset] !== 0x54 ||
- data[v1Offset + 1] !== 0x41 ||
- data[v1Offset + 2] !== 0x47) {
- return frames;
- }
-
- const read = (start, length) => {
- return shaka.util.StringUtils.fromUTF8(
- data.subarray(v1Offset + start, v1Offset + start + length),
- ).replace(/\0/g, '').trim();
- };
-
- const push = (key, value) => {
- if (!value) {
- return;
- }
- frames.push({
- key,
- description: '',
- data: value,
- mimeType: null,
- pictureType: null,
- });
- };
-
- push('TIT2', read(3, 30));
- push('TPE1', read(33, 30));
- push('TALB', read(63, 30));
- push('TYER', read(93, 4));
-
- let comment = '';
- let track = null;
-
- if (data[v1Offset + 125] === 0) {
- comment = read(97, 28);
- track = data[v1Offset + 126];
- } else {
- comment = read(97, 30);
- }
-
- push('COMM', comment);
-
- if (track !== null) {
- push('TRCK', String(track));
- }
-
- push('TCON', String(data[v1Offset + 127]));
-
- return frames;
+ parse(data) {
+ return shaka.metadata.Id3Utils.getID3Frames(data);
}
};
@@ -615,22 +564,27 @@ shaka.util.Id3Utils = class {
* ISO-8859-1 / Latin-1 encoding byte.
* @const {number}
*/
-shaka.util.Id3Utils.LATIN1_encoding = 0x00;
+shaka.metadata.Id3Utils.LATIN1_encoding = 0x00;
/**
* UTF-16 with BOM encoding byte.
* @const {number}
*/
-shaka.util.Id3Utils.UTF16BOM_encoding = 0x01;
+shaka.metadata.Id3Utils.UTF16BOM_encoding = 0x01;
/**
* UTF-16BE without BOM encoding byte.
* @const {number}
*/
-shaka.util.Id3Utils.UTF16BE_encoding = 0x02;
+shaka.metadata.Id3Utils.UTF16BE_encoding = 0x02;
/**
* UTF-8 encoding byte.
* @const {number}
*/
-shaka.util.Id3Utils.UTF8_encoding = 0x03;
+shaka.metadata.Id3Utils.UTF8_encoding = 0x03;
+
+for (const mimeType of shaka.util.MimeUtils.RAW_FORMATS) {
+ shaka.metadata.Metadata.registerParserByMime(
+ mimeType, () => new shaka.metadata.Id3Utils());
+}
diff --git a/lib/metadata/id3v1_utils.js b/lib/metadata/id3v1_utils.js
new file mode 100644
index 000000000..5b1d789b2
--- /dev/null
+++ b/lib/metadata/id3v1_utils.js
@@ -0,0 +1,84 @@
+/*! @license
+ * Shaka Player
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+goog.provide('shaka.metadata.Id3V1Utils');
+
+goog.require('shaka.metadata.Metadata');
+goog.require('shaka.util.MimeUtils');
+goog.require('shaka.util.StringUtils');
+
+
+/**
+ * @summary Metadata parser for ID3v1 tags.
+ * @implements {shaka.extern.MetadataParser}
+ * @export
+ */
+shaka.metadata.Id3V1Utils = class {
+ /**
+ * @override
+ * @export
+ */
+ parse(data) {
+ const frames = [];
+ const v1Offset = data.length - 128;
+
+ if (v1Offset < 0 ||
+ data[v1Offset] !== 0x54 ||
+ data[v1Offset + 1] !== 0x41 ||
+ data[v1Offset + 2] !== 0x47) {
+ return frames;
+ }
+
+ const read = (start, length) => {
+ return shaka.util.StringUtils.fromUTF8(
+ data.subarray(v1Offset + start, v1Offset + start + length),
+ ).replace(/\0/g, '').trim();
+ };
+
+ const push = (key, value) => {
+ if (!value) {
+ return;
+ }
+ frames.push({
+ key,
+ description: '',
+ data: value,
+ mimeType: null,
+ pictureType: null,
+ });
+ };
+
+ push('TIT2', read(3, 30));
+ push('TPE1', read(33, 30));
+ push('TALB', read(63, 30));
+ push('TYER', read(93, 4));
+
+ let comment = '';
+ let track = null;
+
+ if (data[v1Offset + 125] === 0) {
+ comment = read(97, 28);
+ track = data[v1Offset + 126];
+ } else {
+ comment = read(97, 30);
+ }
+
+ push('COMM', comment);
+
+ if (track !== null) {
+ push('TRCK', String(track));
+ }
+
+ push('TCON', String(data[v1Offset + 127]));
+
+ return frames;
+ }
+};
+
+for (const mimeType of shaka.util.MimeUtils.RAW_FORMATS) {
+ shaka.metadata.Metadata.registerParserByMime(
+ mimeType, () => new shaka.metadata.Id3V1Utils());
+}
diff --git a/lib/metadata/ilst_utils.js b/lib/metadata/ilst_utils.js
new file mode 100644
index 000000000..c67654c76
--- /dev/null
+++ b/lib/metadata/ilst_utils.js
@@ -0,0 +1,286 @@
+/*! @license
+ * Shaka Player
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+goog.provide('shaka.metadata.IlstUtils');
+
+goog.require('shaka.metadata.Metadata');
+goog.require('shaka.util.BufferUtils');
+goog.require('shaka.util.Mp4Parser');
+goog.require('shaka.util.StringUtils');
+
+/**
+ * Utility class for parsing MP4 ILST (iTunes metadata) atoms.
+ * @implements {shaka.extern.MetadataParser}
+ * @export
+ */
+shaka.metadata.IlstUtils = class {
+ /**
+ * @override
+ * @export
+ */
+ parse(data) {
+ const frames = [];
+
+ new shaka.util.Mp4Parser()
+ .boxes(['moov', 'udta'], shaka.util.Mp4Parser.children)
+ .fullBox('meta', shaka.util.Mp4Parser.children)
+ .box('ilst', (box) => {
+ while (box.reader.hasMoreData()) {
+ // We need at least 4 bytes for `size` and 4 bytes for `type`.
+ if (box.reader.getLength() - box.reader.getPosition() < 8) {
+ break;
+ }
+
+ const size = box.reader.readUint32();
+ const type = shaka.util.Mp4Parser.typeToString(
+ box.reader.readUint32());
+
+ // A valid box must be at least 8 bytes (header only).
+ // If size < 8 we already consumed 8 bytes, so break to avoid
+ // reading a huge (or negative) number of bytes.
+ if (size < 8) {
+ break;
+ }
+
+ // Clamp payload read to remaining available data so that a
+ // truncated segment does not throw a range error.
+ const maxPayload =
+ box.reader.getLength() - box.reader.getPosition();
+ const payloadSize = Math.min(size - 8, maxPayload);
+
+ const payload = box.reader.readBytes(payloadSize, false);
+
+ const frame = (type === '----') ?
+ this.parseFreeform_(payload) :
+ this.parseStandard_(type, payload);
+
+ if (frame) {
+ frames.push(frame);
+ }
+ }
+ })
+ .parse(data);
+
+ return frames;
+ }
+
+ /**
+ * Parses Apple freeform metadata (---- box).
+ *
+ * @param {!Uint8Array} data
+ * @return {?shaka.extern.MetadataFrame}
+ * @private
+ */
+ parseFreeform_(data) {
+ const StringUtils = shaka.util.StringUtils;
+
+ let mean = '';
+ let name = '';
+
+ /**
+ * @type {?{
+ * data: (string|number|ArrayBuffer),
+ * mimeType: ?string,
+ * pictureType: ?number
+ * }}
+ */
+ let value = null;
+
+ new shaka.util.Mp4Parser()
+ .fullBox('mean', (box) => {
+ const remaining = box.reader.getLength() - box.reader.getPosition();
+ mean = StringUtils.fromUTF8(box.reader.readBytes(remaining, false));
+ })
+ .fullBox('name', (box) => {
+ const remaining = box.reader.getLength() - box.reader.getPosition();
+ name = StringUtils.fromUTF8(box.reader.readBytes(remaining, false));
+ })
+ .fullBox('data', (box) => {
+ value = this.parseDataBox_(box);
+ })
+ .parse(data);
+
+ if (!value || !name) {
+ return null;
+ }
+
+ return {
+ key: name,
+ data: value.data,
+ description: mean ? `Domain: ${mean}` : '',
+ mimeType: value.mimeType,
+ pictureType: value.pictureType,
+ };
+ }
+
+ /**
+ * Parses standard ILST atoms (non-freeform).
+ *
+ * @param {string} key
+ * @param {!Uint8Array} data
+ * @return {?shaka.extern.MetadataFrame}
+ * @private
+ */
+ parseStandard_(key, data) {
+ const mappedKey = shaka.metadata.IlstUtils.ILST_TO_ID3_.get(key) || key;
+
+ /** @type {?shaka.extern.MetadataFrame} */
+ let frame = null;
+
+ new shaka.util.Mp4Parser()
+ .fullBox('data', (box) => {
+ const parsed = this.parseDataBox_(box, key);
+
+ frame = {
+ key: mappedKey,
+ data: parsed.data,
+ description: '',
+ mimeType: parsed.mimeType,
+ pictureType: parsed.pictureType,
+ };
+ })
+ .parse(data);
+
+ return frame;
+ }
+
+ /**
+ * Parses a MP4 ILST `data` sub-box.
+ *
+ * The Mp4Parser fullBox handler already consumed version (1 byte) and
+ * flags (3 bytes) before invoking this callback, so `box.reader` starts
+ * immediately after those 4 bytes.
+ *
+ * Remaining payload layout:
+ * - 4 bytes reserved (always 0x00000000)
+ * - remaining bytes = actual value
+ *
+ * `box.flags` contains the iTunes type indicator:
+ * - 1 : UTF-8 text
+ * - 13 : JPEG image
+ * - 14 : PNG image
+ * - 21 : Big-endian signed integer
+ *
+ * @param {!shaka.extern.ParsedBox} box
+ * @param {string=} key Original ILST four-char code, used for trkn/disk.
+ * @return {{
+ * data: (string|number|ArrayBuffer),
+ * mimeType: ?string,
+ * pictureType: ?number
+ * }}
+ * @private
+ */
+ parseDataBox_(box, key = '') {
+ const StringUtils = shaka.util.StringUtils;
+ const BufferUtils = shaka.util.BufferUtils;
+
+ // Skip the 4-byte reserved field.
+ box.reader.skip(4);
+
+ const raw = box.reader.readBytes(
+ box.reader.getLength() - box.reader.getPosition(),
+ false);
+
+ // trkn (track number) and disk (disc number) are encoded as a packed
+ // binary struct regardless of the type flag, so handle them first.
+ if (key === 'trkn' || key === 'disk') {
+ if (raw.length >= 6) {
+ const view = BufferUtils.toDataView(raw);
+ const index = view.getUint16(2, /* littleEndian= */ false);
+ const total = view.getUint16(4, /* littleEndian= */ false);
+
+ return {
+ data: total > 0 ? `${index}/${total}` : `${index}`,
+ mimeType: null,
+ pictureType: null,
+ };
+ }
+ }
+
+ switch (box.flags) {
+ case 13:
+ return {
+ data: BufferUtils.toArrayBuffer(raw),
+ mimeType: 'image/jpeg',
+ pictureType: 3,
+ };
+ case 14:
+ return {
+ data: BufferUtils.toArrayBuffer(raw),
+ mimeType: 'image/png',
+ pictureType: 3,
+ };
+ case 21: {
+ const view = BufferUtils.toDataView(raw);
+ let value = 0;
+ switch (raw.length) {
+ case 1:
+ value = view.getInt8(0);
+ break;
+ case 2:
+ value = view.getInt16(0, /* littleEndian= */ false);
+ break;
+ case 3:
+ // DataView has no getInt24; assemble manually and sign-extend.
+ value = (raw[0] << 16) | (raw[1] << 8) | raw[2];
+ if (value & 0x800000) {
+ value |= ~0xFFFFFF; // sign-extend to 32 bits
+ }
+ break;
+ case 4:
+ value = view.getInt32(0, /* littleEndian= */ false);
+ break;
+ default:
+ // Sizes > 4 bytes are non-standard; accumulate as unsigned.
+ // Sign-extension is intentionally omitted here because iTunes
+ // never writes multi-word integers in ILST atoms.
+ value = 0;
+ for (const byte of raw) {
+ value = (value << 8) | byte;
+ }
+ }
+ return {
+ data: value,
+ mimeType: null,
+ pictureType: null,
+ };
+ }
+ default:
+ // Flag 1 (UTF-8) and any unknown type: treat as text.
+ return {
+ data: StringUtils.fromUTF8(raw).replace(/\0/g, ''),
+ mimeType: null,
+ pictureType: null,
+ };
+ }
+ }
+};
+
+/**
+ * Maps iTunes ILST four-char codes to their ID3v2 equivalents.
+ *
+ * @const {!Map}
+ * @private
+ */
+shaka.metadata.IlstUtils.ILST_TO_ID3_ = new Map()
+ .set('©nam', 'TIT2')
+ .set('©ART', 'TPE1')
+ .set('aART', 'TPE2')
+ .set('©alb', 'TALB')
+ .set('©gen', 'TCON')
+ .set('©day', 'TDRC')
+ .set('©wrt', 'TEXT')
+ .set('trkn', 'TRCK')
+ .set('disk', 'TPOS')
+ .set('©cmt', 'COMM')
+ .set('covr', 'APIC')
+ .set('cprt', 'TCOP')
+ .set('©too', 'TENC')
+ .set('tmpo', 'TBPM')
+ .set('cpil', 'TCMP');
+
+shaka.metadata.Metadata.registerParserByMime(
+ 'audio/mp4', () => new shaka.metadata.IlstUtils());
diff --git a/lib/metadata/metadata.js b/lib/metadata/metadata.js
new file mode 100644
index 000000000..ce81790b4
--- /dev/null
+++ b/lib/metadata/metadata.js
@@ -0,0 +1,108 @@
+/*! @license
+ * Shaka Player
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+goog.provide('shaka.metadata.Metadata');
+
+
+/**
+ * @export
+ */
+shaka.metadata.Metadata = class {
+ /**
+ * Returns all metadata frames found in the media data for the given MIME
+ * type.
+ *
+ * The method invokes every parser factory that has been registered for
+ * {@code mimeType} via {@link shaka.metadata.Metadata.registerParserByMime},
+ * collects all returned frames, and deduplicates them so that when multiple
+ * frames share the same {@code key} only the first occurrence is kept.
+ *
+ * The same MIME type may have more than one parser registered (e.g. both an
+ * ID3v2 and an ID3v1 parser for {@code audio/mpeg}). All registered parsers
+ * are run in registration order and their results are concatenated before
+ * deduplication.
+ *
+ * @param {!Uint8Array} data
+ * @param {string} mimeType
+ * @return {!Array}
+ * @export
+ */
+ static getMetadataFrames(data, mimeType) {
+ const factories =
+ shaka.metadata.Metadata.parsersByMime_.get(mimeType) || [];
+
+ const frames = [];
+ for (const factory of factories) {
+ frames.push(...factory().parse(data));
+ }
+
+ // Deduplicate: keep only the first frame for each key.
+ const seen = new Set();
+ const result = [];
+ for (const f of frames) {
+ if (!seen.has(f.key)) {
+ seen.add(f.key);
+ result.push(f);
+ }
+ }
+
+ return result;
+ }
+
+ /**
+ * Returns {@code true} when at least one parser has been registered for
+ * {@code mimeType}.
+ *
+ * @param {string} mimeType
+ * @return {boolean}
+ * @export
+ */
+ static supports(mimeType) {
+ const factories = shaka.metadata.Metadata.parsersByMime_.get(mimeType);
+ return factories != null && factories.length > 0;
+ }
+
+ /**
+ * Registers a metadata parser factory for a given MIME type.
+ *
+ * The same MIME type may be registered more than once; each additional call
+ * appends the factory to the list of parsers for that MIME type. When
+ * {@link shaka.metadata.Metadata.getMetadataFrames} is called, all
+ * registered parsers for the MIME type are invoked in registration order and
+ * their frames are merged (with first-occurrence-wins deduplication on
+ * {@code key}).
+ *
+ * @param {string} mimeType
+ * @param {shaka.extern.MetadataParser.Factory} parserFactory
+ * @export
+ */
+ static registerParserByMime(mimeType, parserFactory) {
+ const map = shaka.metadata.Metadata.parsersByMime_;
+ map.getOrInsertComputed(mimeType, () => []).push(parserFactory);
+ }
+
+ /**
+ * Unregisters all parser factories that have been registered for the given
+ * MIME type.
+ *
+ * @param {string} mimeType
+ * @export
+ */
+ static unregisterParserByMime(mimeType) {
+ shaka.metadata.Metadata.parsersByMime_.delete(mimeType);
+ }
+};
+
+
+/**
+ * Maps each MIME type to the ordered list of parser factories registered for
+ * it. A single MIME type may have multiple factories (e.g. ID3v2 + ID3v1 for
+ * {@code audio/mpeg}).
+ *
+ * @type {!Map>}
+ * @private
+ */
+shaka.metadata.Metadata.parsersByMime_ = new Map();
diff --git a/lib/metadata/vorbis_utils.js b/lib/metadata/vorbis_utils.js
new file mode 100644
index 000000000..fdd42e1ac
--- /dev/null
+++ b/lib/metadata/vorbis_utils.js
@@ -0,0 +1,245 @@
+/*! @license
+ * Shaka Player
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+goog.provide('shaka.metadata.VorbisUtils');
+
+goog.require('shaka.metadata.Metadata');
+goog.require('shaka.util.BufferUtils');
+goog.require('shaka.util.DataViewReader');
+goog.require('shaka.util.StringUtils');
+
+// cspell:ignore ALBUMARTIST TRACKNUMBER DISCNUMBER
+
+/**
+ * Metadata parser for Vorbis Comments (FLAC, OGG, Vorbis, Opus).
+ * @implements {shaka.extern.MetadataParser}
+ * @export
+ */
+shaka.metadata.VorbisUtils = class {
+ /**
+ * @override
+ * @export
+ */
+ parse(data) {
+ const isFlac = data.length >= 4 &&
+ data[0] == 0x66 && // f
+ data[1] == 0x4C && // L
+ data[2] == 0x61 && // a
+ data[3] == 0x43; // C
+
+ if (isFlac) {
+ const frames = [];
+ let offset = 4;
+
+ while (offset + 4 <= data.length) {
+ const header = data[offset];
+
+ const isLast = !!(header & 0x80);
+ const blockType = header & 0x7F;
+
+ const length =
+ (data[offset + 1] << 16) |
+ (data[offset + 2] << 8) |
+ data[offset + 3];
+
+ offset += 4;
+
+ if (offset + length > data.length) {
+ break;
+ }
+
+ const block = data.subarray(offset, offset + length);
+
+ // VORBIS_COMMENT block
+ if (blockType == 4) {
+ frames.push(...this.parseVorbisCommentBlock_(block));
+ }
+
+ // PICTURE block
+ if (blockType == 6) {
+ const frame = this.parseFlacPicture_(block);
+ if (frame) {
+ frames.push(frame);
+ }
+ }
+
+ offset += length;
+
+ if (isLast) {
+ break;
+ }
+ }
+
+ return frames;
+ }
+
+ const isOgg = data.length >= 4 &&
+ data[0] == 0x4F && // O
+ data[1] == 0x67 && // g
+ data[2] == 0x67 && // g
+ data[3] == 0x53; // S
+
+ if (isOgg) {
+ const text = shaka.util.StringUtils.fromUTF8(data);
+
+ let index = text.indexOf('OpusTags');
+ if (index < 0) {
+ index = text.indexOf('vorbis');
+ }
+
+ if (index >= 0) {
+ return this.parseVorbisCommentBlock_(data.subarray(index + 8));
+ }
+ }
+
+ return [];
+ }
+
+ /**
+ * Parse Vorbis comment structure.
+ *
+ * @param {!Uint8Array} data
+ * @return {!Array}
+ * @private
+ */
+ parseVorbisCommentBlock_(data) {
+ const frames = [];
+
+ try {
+ const reader = new shaka.util.DataViewReader(
+ data, shaka.util.DataViewReader.Endianness.LITTLE_ENDIAN);
+
+ // vendor_length
+ const vendorLength = reader.readUint32();
+ reader.skip(vendorLength);
+
+ // user_comment_list_length
+ const commentCount = reader.readUint32();
+
+ for (let i = 0; i < commentCount; i++) {
+ const len = reader.readUint32();
+
+ const str = shaka.util.StringUtils.fromUTF8(
+ reader.readBytes(len, /* clone= */ false));
+
+ const eq = str.indexOf('=');
+
+ if (eq < 0) {
+ continue;
+ }
+
+ const vorbisKey = str.substring(0, eq).toUpperCase();
+ const value = str.substring(eq + 1);
+
+ const key =
+ shaka.metadata.VorbisUtils.VORBIS_TO_ID3_MAP_[vorbisKey] ||
+ vorbisKey;
+
+ frames.push({
+ key,
+ data: value,
+ description: '',
+ mimeType: null,
+ pictureType: null,
+ });
+ }
+ } catch (e) {
+ // Malformed Vorbis comment block.
+ }
+
+ return frames;
+ }
+
+
+ /**
+ * Parse a FLAC PICTURE metadata block (type 6) and return it as an
+ * APIC-equivalent MetadataFrame so that callers can treat album art from
+ * FLAC files the same way they treat ID3v2 APIC frames.
+ *
+ * FLAC PICTURE block layout (all fields big-endian):
+ * [0-3] picture type (uint32 BE)
+ * [4-7] MIME length (uint32 BE)
+ * [8 … 8+mimeLen-1] MIME type string (UTF-8)
+ * [8+mimeLen … +3] description length (uint32 BE)
+ * […+descLen-1] description string (UTF-8)
+ * [… + 4] width (uint32 BE) – ignored
+ * [… + 4] height (uint32 BE) – ignored
+ * [… + 4] color depth (uint32 BE) – ignored
+ * [… + 4] color count (uint32 BE) – ignored
+ * [… + 4] data length (uint32 BE)
+ * [… + dataLen] raw image bytes
+ *
+ * @param {!Uint8Array} block Raw bytes of the PICTURE metadata block body
+ * (i.e. the 4-byte block header has already been stripped).
+ * @return {?shaka.extern.MetadataFrame}
+ * @private
+ */
+ parseFlacPicture_(block) {
+ const StringUtils = shaka.util.StringUtils;
+
+ try {
+ const reader = new shaka.util.DataViewReader(
+ block, shaka.util.DataViewReader.Endianness.BIG_ENDIAN);
+
+ const pictureType = reader.readUint32();
+
+ const mimeLength = reader.readUint32();
+ const mimeType = StringUtils.fromUTF8(
+ reader.readBytes(mimeLength, /* clone= */ true));
+
+ const descLength = reader.readUint32();
+ const description = StringUtils.fromUTF8(
+ reader.readBytes(descLength, /* clone= */ true));
+
+ // width, height, color depth, color count
+ reader.skip(16);
+
+ const dataLength = reader.readUint32();
+
+ const imageBytes = reader.readBytes(dataLength, /* clone= */ true);
+
+ return {
+ key: 'APIC',
+ data: shaka.util.BufferUtils.toArrayBuffer(imageBytes),
+ description,
+ mimeType,
+ pictureType,
+ };
+ } catch (e) {
+ return null;
+ }
+ }
+};
+
+/**
+ * @const {!Object}
+ * @private
+ */
+shaka.metadata.VorbisUtils.VORBIS_TO_ID3_MAP_ = {
+ 'TITLE': 'TIT2',
+ 'ARTIST': 'TPE1',
+ 'ALBUM': 'TALB',
+ 'ALBUMARTIST': 'TPE2',
+ 'TRACKNUMBER': 'TRCK',
+ 'DISCNUMBER': 'TPOS',
+ 'DATE': 'TDRC',
+ 'GENRE': 'TCON',
+ 'COMMENT': 'COMM',
+ 'DESCRIPTION': 'TIT3',
+ 'COPYRIGHT': 'TCOP',
+ 'COMPOSER': 'TCOM',
+ 'LYRICS': 'USLT',
+ 'ISRC': 'TSRC',
+};
+
+shaka.metadata.Metadata.registerParserByMime(
+ 'audio/flac', () => new shaka.metadata.VorbisUtils());
+shaka.metadata.Metadata.registerParserByMime(
+ 'audio/ogg', () => new shaka.metadata.VorbisUtils());
+shaka.metadata.Metadata.registerParserByMime(
+ 'audio/vorbis', () => new shaka.metadata.VorbisUtils());
+shaka.metadata.Metadata.registerParserByMime(
+ 'audio/opus', () => new shaka.metadata.VorbisUtils());
diff --git a/lib/player.js b/lib/player.js
index df6d0f77e..f656c0a10 100644
--- a/lib/player.js
+++ b/lib/player.js
@@ -32,6 +32,7 @@ goog.require('shaka.media.SegmentReference');
goog.require('shaka.media.SrcEqualsPlayhead');
goog.require('shaka.media.StreamingEngine');
goog.require('shaka.media.TimeRangesUtils');
+goog.require('shaka.metadata.Metadata');
goog.require('shaka.net.NetworkingEngine');
goog.require('shaka.net.NetworkingUtils');
goog.require('shaka.text.Cue');
@@ -63,6 +64,7 @@ goog.require('shaka.util.MimeUtils');
goog.require('shaka.util.Mutex');
goog.require('shaka.util.NumberUtils');
goog.require('shaka.util.ObjectUtils');
+goog.require('shaka.util.OperationManager');
goog.require('shaka.util.PlayerConfiguration');
goog.require('shaka.util.Stats');
goog.require('shaka.util.StreamUtils');
@@ -1097,6 +1099,12 @@ shaka.Player = class extends shaka.util.FakeEventTarget {
/** @private {?shaka.util.VideoFrameCallbackHandler} */
this.frameHandler_ = null;
+ /** @private {!shaka.util.OperationManager} */
+ this.operationManager_ = new shaka.util.OperationManager();
+
+ /** @private {?string} */
+ this.playbackObjectUrl_ = null;
+
// Even though |attach| will start in later interpreter cycles, it should be
// the LAST thing we do in the constructor because conceptually it relies on
// player having been initialized.
@@ -1676,6 +1684,8 @@ shaka.Player = class extends shaka.util.FakeEventTarget {
this.frameHandler_ = null;
}
+ await this.operationManager_.destroy();
+
if (this.video_) {
// The life cycle of tracks that created by addTextTrackAsync() and
// their associated resources should be the same as the loaded video.
@@ -1762,6 +1772,11 @@ shaka.Player = class extends shaka.util.FakeEventTarget {
this.networkingEngine_.clearCommonAccessTokenMap();
}
+ if (this.playbackObjectUrl_) {
+ URL.revokeObjectURL(this.playbackObjectUrl_);
+ this.playbackObjectUrl_ = null;
+ }
+
// Make sure that the app knows of the new buffering state.
this.updateBufferState_();
} finally {
@@ -3435,6 +3450,74 @@ shaka.Player = class extends shaka.util.FakeEventTarget {
// By setting |src| we are done "loading" with src=. We don't need to set
// the current time because |playhead| will do that for us.
let playbackUri = this.cmcdManager_.appendSrcData(this.assetUri_, mimeType);
+
+ // When processSrcEqualMetadata is enabled and the MIME type supports
+ // embedded metadata (e.g. audio/mpeg, audio/mp4), we pre-fetch the asset
+ // through Shaka's networking engine before handing it to the browser.
+ // This serves two purposes:
+ // 1. It gives us access to the raw bytes so we can extract ID3 frames
+ // and surface them as metadata events (see usage of |data| below),
+ // matching the behaviour of the MediaSource load path.
+ // 2. It lets us convert the response to a Blob URL, which avoids a second
+ // network round-trip when the browser later sets src= on the element.
+ let data;
+ if (this.mimeType_ &&
+ this.config_.streaming.processSrcEqualMetadata &&
+ shaka.metadata.Metadata.supports(this.mimeType_)) {
+ let wasAbortedByLiveCheck = false;
+ const type = shaka.net.NetworkingEngine.RequestType.MANIFEST;
+ const retryParams = this.config_.streaming.retryParameters;
+ const request =
+ shaka.net.NetworkingEngine.makeRequest([playbackUri], retryParams);
+ const pendingRequest = this.networkingEngine_.request(type, request);
+ this.operationManager_.manage(pendingRequest);
+ const headersListener = (event) => {
+ if (event.request === request) {
+ const headers = event.headers;
+
+ // ICY headers (icy-br, icy-name, etc.) indicate a live
+ // Shoutcast/Icecast stream, which has no Content-Length and cannot
+ // be buffered into a Blob. In that case we leave |data| undefined
+ // and fall back to the original URL, skipping metadata extraction
+ // entirely.
+ const isFinite = headers['content-length'] &&
+ !headers['icy-br'] &&
+ !headers['icy-description'] &&
+ !headers['icy-genre'] &&
+ !headers['icy-name'] &&
+ !headers['icy-url'];
+
+ if (!isFinite) {
+ wasAbortedByLiveCheck = true;
+ pendingRequest.abort();
+ }
+ }
+ };
+
+ this.addEventListener(
+ shaka.util.FakeEvent.EventName.DownloadHeadersReceived,
+ headersListener);
+
+ try {
+ const response = await pendingRequest.promise;
+ data = shaka.util.BufferUtils.toUint8(response.data);
+ // Replace the network URL with an object URL so the browser can load
+ // the already-downloaded bytes directly from memory, avoiding a
+ // duplicate fetch.
+ const blob = new Blob([response.data], {type: this.mimeType_});
+ playbackUri = URL.createObjectURL(blob);
+ this.playbackObjectUrl_ = playbackUri;
+ } catch (e) {
+ if (!wasAbortedByLiveCheck) {
+ throw e;
+ }
+ } finally {
+ this.removeEventListener(
+ shaka.util.FakeEvent.EventName.DownloadHeadersReceived,
+ headersListener);
+ }
+ }
+
// Apply temporal clipping using playRangeStart and playRangeEnd based
// in https://www.w3.org/TR/media-frags/
if (!playbackUri.includes('#t=') &&
@@ -3617,6 +3700,33 @@ shaka.Player = class extends shaka.util.FakeEventTarget {
}
}
+ // When loading via src=, the browser handles segment parsing natively and
+ // does not go through Shaka's streaming pipeline, so ID3 metadata embedded
+ // in the media file (e.g. in an MP3 or MP4 container) would never reach
+ // the normal metadata event path. To ensure that apps still receive
+ // metadata events consistently regardless of load mode, we pre-fetch and
+ // parse the asset ourselves when processSrcEqualMetadata is enabled (see
+ // the data fetch above), extract any ID3 frames, and register them as
+ // timeline regions that span the full duration of the content. This allows
+ // the RegionObserver to fire the same metadata events as it would in the
+ // MediaSource path.
+ if (this.mimeType_ && data) {
+ // |data| is only populated for finite VOD assets (non-ICY streams); for
+ // live/radio streams it remains undefined and we skip this block.
+ const frames =
+ shaka.metadata.Metadata.getMetadataFrames(data, this.mimeType_);
+ if (frames.length) {
+ // ID3 metadata in a standalone audio file has no inherent time
+ // range; it is considered valid for the entire duration of the asset.
+ const start = 0;
+ const end = this.video_.duration;
+ const metadataType = 'org.id3';
+ for (const frame of frames) {
+ this.addMetadataToRegionTimeline_(start, end, metadataType, frame);
+ }
+ }
+ }
+
await this.waitForPendingExtraTrackPromises_();
this.fullyLoaded_ = true;
}
diff --git a/lib/transmuxer/aac_transmuxer.js b/lib/transmuxer/aac_transmuxer.js
index 8097cded4..7c1817300 100644
--- a/lib/transmuxer/aac_transmuxer.js
+++ b/lib/transmuxer/aac_transmuxer.js
@@ -7,12 +7,12 @@
goog.provide('shaka.transmuxer.AacTransmuxer');
goog.require('shaka.media.Capabilities');
+goog.require('shaka.metadata.Id3Utils');
goog.require('shaka.transmuxer.ADTS');
goog.require('shaka.transmuxer.TransmuxerEngine');
goog.require('shaka.transmuxer.TransmuxerUtils');
goog.require('shaka.util.BufferUtils');
goog.require('shaka.util.Error');
-goog.require('shaka.util.Id3Utils');
goog.require('shaka.util.ManifestParserUtils');
goog.require('shaka.util.MimeUtils');
goog.require('shaka.util.Mp4Generator');
@@ -117,7 +117,7 @@ shaka.transmuxer.AacTransmuxer = class {
// either 0 or 1
// Layer bits (position 14 and 15) in header should be always 0 for ADTS
// More info https://wiki.multimedia.cx/index.php?title=ADTS
- const id3Data = shaka.util.Id3Utils.getID3Data(uint8ArrayData);
+ const id3Data = shaka.metadata.Id3Utils.getID3Data(uint8ArrayData);
let offset = id3Data.length;
for (; offset < uint8ArrayData.length; offset++) {
if (ADTS.probe(uint8ArrayData, offset)) {
@@ -127,7 +127,7 @@ shaka.transmuxer.AacTransmuxer = class {
let timestamp = reference.endTime * 1000;
- const frames = shaka.util.Id3Utils.getID3Frames(id3Data);
+ const frames = shaka.metadata.Id3Utils.getID3Frames(id3Data);
if (frames.length && reference) {
const metadataTimestamp = frames.find((frame) => {
return frame.description ===
diff --git a/lib/transmuxer/ac3_transmuxer.js b/lib/transmuxer/ac3_transmuxer.js
index de0327368..e61bb9c44 100644
--- a/lib/transmuxer/ac3_transmuxer.js
+++ b/lib/transmuxer/ac3_transmuxer.js
@@ -8,12 +8,12 @@ goog.provide('shaka.transmuxer.Ac3Transmuxer');
goog.require('shaka.device.DeviceFactory');
goog.require('shaka.media.Capabilities');
+goog.require('shaka.metadata.Id3Utils');
goog.require('shaka.transmuxer.Ac3');
goog.require('shaka.transmuxer.TransmuxerEngine');
goog.require('shaka.transmuxer.TransmuxerUtils');
goog.require('shaka.util.BufferUtils');
goog.require('shaka.util.Error');
-goog.require('shaka.util.Id3Utils');
goog.require('shaka.util.ManifestParserUtils');
goog.require('shaka.util.Mp4Generator');
@@ -116,7 +116,7 @@ shaka.transmuxer.Ac3Transmuxer = class {
const uint8ArrayData = shaka.util.BufferUtils.toUint8(data);
- const id3Data = shaka.util.Id3Utils.getID3Data(uint8ArrayData);
+ const id3Data = shaka.metadata.Id3Utils.getID3Data(uint8ArrayData);
let offset = id3Data.length;
for (; offset < uint8ArrayData.length; offset++) {
if (Ac3.probe(uint8ArrayData, offset)) {
@@ -126,7 +126,7 @@ shaka.transmuxer.Ac3Transmuxer = class {
let timestamp = reference.endTime * 1000;
- const frames = shaka.util.Id3Utils.getID3Frames(id3Data);
+ const frames = shaka.metadata.Id3Utils.getID3Frames(id3Data);
if (frames.length && reference) {
const metadataTimestamp = frames.find((frame) => {
return frame.description ===
diff --git a/lib/transmuxer/ec3_transmuxer.js b/lib/transmuxer/ec3_transmuxer.js
index 44cff5595..9df84d032 100644
--- a/lib/transmuxer/ec3_transmuxer.js
+++ b/lib/transmuxer/ec3_transmuxer.js
@@ -7,12 +7,12 @@
goog.provide('shaka.transmuxer.Ec3Transmuxer');
goog.require('shaka.media.Capabilities');
+goog.require('shaka.metadata.Id3Utils');
goog.require('shaka.transmuxer.Ec3');
goog.require('shaka.transmuxer.TransmuxerEngine');
goog.require('shaka.transmuxer.TransmuxerUtils');
goog.require('shaka.util.BufferUtils');
goog.require('shaka.util.Error');
-goog.require('shaka.util.Id3Utils');
goog.require('shaka.util.ManifestParserUtils');
goog.require('shaka.util.Mp4Generator');
@@ -110,7 +110,7 @@ shaka.transmuxer.Ec3Transmuxer = class {
const uint8ArrayData = shaka.util.BufferUtils.toUint8(data);
- const id3Data = shaka.util.Id3Utils.getID3Data(uint8ArrayData);
+ const id3Data = shaka.metadata.Id3Utils.getID3Data(uint8ArrayData);
let offset = id3Data.length;
for (; offset < uint8ArrayData.length; offset++) {
if (Ec3.probe(uint8ArrayData, offset)) {
@@ -120,7 +120,7 @@ shaka.transmuxer.Ec3Transmuxer = class {
let timestamp = reference.endTime * 1000;
- const frames = shaka.util.Id3Utils.getID3Frames(id3Data);
+ const frames = shaka.metadata.Id3Utils.getID3Frames(id3Data);
if (frames.length && reference) {
const metadataTimestamp = frames.find((frame) => {
return frame.description ===
diff --git a/lib/transmuxer/mp3_transmuxer.js b/lib/transmuxer/mp3_transmuxer.js
index 792416ab3..9f50db814 100644
--- a/lib/transmuxer/mp3_transmuxer.js
+++ b/lib/transmuxer/mp3_transmuxer.js
@@ -7,12 +7,12 @@
goog.provide('shaka.transmuxer.Mp3Transmuxer');
goog.require('shaka.media.Capabilities');
+goog.require('shaka.metadata.Id3Utils');
goog.require('shaka.transmuxer.MpegAudio');
goog.require('shaka.transmuxer.TransmuxerEngine');
goog.require('shaka.transmuxer.TransmuxerUtils');
goog.require('shaka.util.BufferUtils');
goog.require('shaka.util.Error');
-goog.require('shaka.util.Id3Utils');
goog.require('shaka.util.ManifestParserUtils');
goog.require('shaka.util.Mp4Generator');
@@ -110,7 +110,7 @@ shaka.transmuxer.Mp3Transmuxer = class {
const uint8ArrayData = shaka.util.BufferUtils.toUint8(data);
- const id3Data = shaka.util.Id3Utils.getID3Data(uint8ArrayData);
+ const id3Data = shaka.metadata.Id3Utils.getID3Data(uint8ArrayData);
let offset = id3Data.length;
for (; offset < uint8ArrayData.length; offset++) {
if (MpegAudio.probe(uint8ArrayData, offset)) {
diff --git a/lib/transmuxer/ts_transmuxer.js b/lib/transmuxer/ts_transmuxer.js
index 92998ef75..7d50a5df2 100644
--- a/lib/transmuxer/ts_transmuxer.js
+++ b/lib/transmuxer/ts_transmuxer.js
@@ -7,6 +7,7 @@
goog.provide('shaka.transmuxer.TsTransmuxer');
goog.require('shaka.media.Capabilities');
+goog.require('shaka.metadata.Id3Utils');
goog.require('shaka.transmuxer.AacTransmuxer');
goog.require('shaka.transmuxer.Ac3');
goog.require('shaka.transmuxer.ADTS');
@@ -19,7 +20,6 @@ goog.require('shaka.transmuxer.TransmuxerEngine');
goog.require('shaka.transmuxer.TransmuxerUtils');
goog.require('shaka.util.BufferUtils');
goog.require('shaka.util.Error');
-goog.require('shaka.util.Id3Utils');
goog.require('shaka.util.ManifestParserUtils');
goog.require('shaka.util.MimeUtils');
goog.require('shaka.util.Mp4Generator');
@@ -187,7 +187,7 @@ shaka.transmuxer.TsTransmuxer = class {
if (contentType == ContentType.AUDIO &&
!shaka.util.TsParser.probe(uint8ArrayData)) {
- const id3Data = shaka.util.Id3Utils.getID3Data(uint8ArrayData);
+ const id3Data = shaka.metadata.Id3Utils.getID3Data(uint8ArrayData);
let offset = id3Data.length;
for (; offset < uint8ArrayData.length; offset++) {
if (shaka.transmuxer.MpegAudio.probe(uint8ArrayData, offset)) {
diff --git a/lib/util/player_configuration.js b/lib/util/player_configuration.js
index ce7091a08..5f820ad16 100644
--- a/lib/util/player_configuration.js
+++ b/lib/util/player_configuration.js
@@ -257,6 +257,7 @@ shaka.util.PlayerConfiguration = class {
returnToEndOfLiveWindowWhenOutside: false,
stopFetchingOnPause: false,
clampAppendWindowToDuration: false,
+ processSrcEqualMetadata: true,
};
const networking = {
diff --git a/lib/util/ts_parser.js b/lib/util/ts_parser.js
index 39211c779..d343dca4f 100644
--- a/lib/util/ts_parser.js
+++ b/lib/util/ts_parser.js
@@ -8,8 +8,8 @@ goog.provide('shaka.util.TsParser');
goog.require('goog.asserts');
goog.require('shaka.log');
+goog.require('shaka.metadata.Id3Utils');
goog.require('shaka.util.ExpGolomb');
-goog.require('shaka.util.Id3Utils');
goog.require('shaka.util.Uint8ArrayUtils');
@@ -739,7 +739,7 @@ shaka.util.TsParser = class {
metadata.push({
cueTime: pes.pts ? pes.pts / timescale : null,
data: pes.data,
- frames: shaka.util.Id3Utils.getID3Frames(pes.data),
+ frames: shaka.metadata.Id3Utils.getID3Frames(pes.data),
dts: pes.dts,
pts: pes.pts,
});
diff --git a/project-words.txt b/project-words.txt
index 46449a5b8..af8824074 100644
--- a/project-words.txt
+++ b/project-words.txt
@@ -268,6 +268,8 @@ cmfv
cmsd
colr
containerless
+covr
+cpil
cuelist
cuepoints
cwip
@@ -310,11 +312,13 @@ hdcp
hdlr
hvcc
iamf
+ilst
imagetype
imsa
imsc
inband
isom
+isrc
keyids
keysystem
latm
@@ -409,8 +413,12 @@ subt
subviewer
talb
tblr
+tbpm
tbrl
+tdrc
tenc
+tcmp
+tcom
tcop
tfdt
tfhd
@@ -421,6 +429,8 @@ timecodes
timepoint
timepoints
tkhd
+tmpo
+tpos
traf
trak
traks
@@ -428,14 +438,18 @@ transquant
trck
trexs
trickmode
+trkn
trun
tsbd
+tsrc
ttml
txxx
tyer
+udta
ufid
unskippable
urlparam
+uslt
valu
vdms
vexu
@@ -521,15 +535,18 @@ cdnb
# other
Aegisub
+ALBUMARTIST
autoglottonym
avinfo
awesomplete
axinom
+BADENTRY
beatle
BROWSERSLIST
checkmark
crbug
*deadbeef*
+DISCNUMBER
doclet
doclets
enoent
@@ -539,24 +556,31 @@ googlers
halfling
heliocentrism
homebrewed
+Icecast
ismena
joeyparrish
Kartenberg
kbits
khtml
+libopus
malform
masterlist
mbps
msdk
+oggs
onservicecallback
productinfo
+REPLAYGAIN
RMKSA
RVFC
+SEEKTABLE
servernum
+Shoutcast
sintel
stardate
systeminfo
templatized
+TRACKNUMBER
UNLCK
vaage
webapis
diff --git a/shaka-player.uncompiled.js b/shaka-player.uncompiled.js
index b2cbd8eeb..7f0a51b23 100644
--- a/shaka-player.uncompiled.js
+++ b/shaka-player.uncompiled.js
@@ -37,6 +37,11 @@ goog.require('shaka.media.PreferenceBasedCriteria');
goog.require('shaka.media.PresentationTimeline');
goog.require('shaka.media.SegmentIndex');
goog.require('shaka.media.SegmentReference');
+goog.require('shaka.metadata.Id3Utils');
+goog.require('shaka.metadata.Id3V1Utils');
+goog.require('shaka.metadata.IlstUtils');
+goog.require('shaka.metadata.Metadata');
+goog.require('shaka.metadata.VorbisUtils');
goog.require('shaka.msf.MSFParser');
goog.require('shaka.net.DataUriPlugin');
goog.require('shaka.net.HttpFetchPlugin');
diff --git a/test/util/id3_utils_unit.js b/test/metadata/id3_utils_unit.js
similarity index 91%
rename from test/util/id3_utils_unit.js
rename to test/metadata/id3_utils_unit.js
index ece2a295b..b51e3ec0d 100644
--- a/test/util/id3_utils_unit.js
+++ b/test/metadata/id3_utils_unit.js
@@ -5,7 +5,7 @@
*/
describe('Id3Utils', () => {
- const Id3Utils = shaka.util.Id3Utils;
+ const Id3Utils = shaka.metadata.Id3Utils;
const Id3Generator = shaka.test.Id3Generator;
const BufferUtils = shaka.util.BufferUtils;
@@ -379,56 +379,6 @@ describe('Id3Utils', () => {
expect(result.length).toBe(0);
});
- it('parses ID3v1 tags', () => {
- const data = new Uint8Array(128);
-
- data[0] = 0x54;
- data[1] = 0x41;
- data[2] = 0x47;
-
- const write = (offset, text) => {
- for (let i = 0; i < text.length; i++) {
- data[offset + i] = text.charCodeAt(i);
- }
- };
-
- write(3, 'Title');
- write(33, 'Artist');
- write(63, 'Album');
- write(93, '2024');
-
- data[125] = 0;
- data[126] = 7;
- data[127] = 13;
-
- const frames = Id3Utils.getID3v1Frames(data);
-
- expect(frames).toEqual(jasmine.arrayContaining([
- jasmine.objectContaining({
- key: 'TIT2',
- data: 'Title',
- }),
- jasmine.objectContaining({
- key: 'TPE1',
- data: 'Artist',
- }),
- jasmine.objectContaining({
- key: 'TRCK',
- data: '7',
- }),
- jasmine.objectContaining({
- key: 'TCON',
- data: '13',
- }),
- ]));
- });
-
- it('returns empty array when no ID3v1 tag exists', () => {
- const frames = Id3Utils.getID3v1Frames(new Uint8Array(128));
-
- expect(frames).toEqual([]);
- });
-
it('parses Apple transportStreamTimestamp PRIV frame', () => {
const owner =
'com.apple.streaming.transportStreamTimestamp';
diff --git a/test/metadata/id3v1_utils_unit.js b/test/metadata/id3v1_utils_unit.js
new file mode 100644
index 000000000..53e098e7b
--- /dev/null
+++ b/test/metadata/id3v1_utils_unit.js
@@ -0,0 +1,59 @@
+/*! @license
+ * Shaka Player
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+describe('Id3V1Utils', () => {
+ const Id3V1Utils = shaka.metadata.Id3V1Utils;
+
+ it('parses ID3v1 tags', () => {
+ const data = new Uint8Array(128);
+
+ data[0] = 0x54;
+ data[1] = 0x41;
+ data[2] = 0x47;
+
+ const write = (offset, text) => {
+ for (let i = 0; i < text.length; i++) {
+ data[offset + i] = text.charCodeAt(i);
+ }
+ };
+
+ write(3, 'Title');
+ write(33, 'Artist');
+ write(63, 'Album');
+ write(93, '2024');
+
+ data[125] = 0;
+ data[126] = 7;
+ data[127] = 13;
+
+ const frames = new Id3V1Utils().parse(data);
+
+ expect(frames).toEqual(jasmine.arrayContaining([
+ jasmine.objectContaining({
+ key: 'TIT2',
+ data: 'Title',
+ }),
+ jasmine.objectContaining({
+ key: 'TPE1',
+ data: 'Artist',
+ }),
+ jasmine.objectContaining({
+ key: 'TRCK',
+ data: '7',
+ }),
+ jasmine.objectContaining({
+ key: 'TCON',
+ data: '13',
+ }),
+ ]));
+ });
+
+ it('returns empty array when no ID3v1 tag exists', () => {
+ const frames = new Id3V1Utils().parse(new Uint8Array(128));
+
+ expect(frames).toEqual([]);
+ });
+});
diff --git a/test/metadata/ilst_utils_unit.js b/test/metadata/ilst_utils_unit.js
new file mode 100644
index 000000000..6ae24201f
--- /dev/null
+++ b/test/metadata/ilst_utils_unit.js
@@ -0,0 +1,442 @@
+/*! @license
+ * Shaka Player
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+
+// cspell:ignore xyzz smpb
+
+describe('IlstUtils', () => {
+ const IlstUtils = shaka.metadata.IlstUtils;
+ const BufferUtils = shaka.util.BufferUtils;
+
+ // ---------------------------------------------------------------------------
+ // Binary-construction helpers
+ // ---------------------------------------------------------------------------
+
+ /**
+ * @param {number} n
+ * @return {!Array}
+ */
+ function uint32BE(n) {
+ return [
+ (n >>> 24) & 0xff,
+ (n >>> 16) & 0xff,
+ (n >>> 8) & 0xff,
+ (n >>> 0) & 0xff,
+ ];
+ }
+
+ /**
+ * Encode an ASCII string as a byte array.
+ * Works for ILST four-char codes that contain Latin-1 characters (e.g. ©).
+ *
+ * @param {string} str
+ * @return {!Array}
+ */
+ function ascii(str) {
+ return Array.from(str).map((c) => c.charCodeAt(0));
+ }
+
+ /**
+ * Build a basic MP4 box: size(4) + fourCC(4) + payload.
+ *
+ * @param {string} type Four-character box identifier.
+ * @param {!Array} payload
+ * @return {!Array}
+ */
+ function basicBox(type, payload) {
+ const size = 8 + payload.length;
+ return [...uint32BE(size), ...ascii(type), ...payload];
+ }
+
+ /**
+ * Build a fullBox: basicBox with version(1) + flags(3) prepended to payload.
+ *
+ * @param {string} type
+ * @param {number} version
+ * @param {number} flags 24-bit value.
+ * @param {!Array} payload
+ * @return {!Array}
+ */
+ function fullBox(type, version, flags, payload) {
+ return basicBox(type, [
+ version & 0xff,
+ (flags >>> 16) & 0xff,
+ (flags >>> 8) & 0xff,
+ flags & 0xff,
+ ...payload,
+ ]);
+ }
+
+ /**
+ * Build an ILST `data` sub-box.
+ *
+ * Layout: fullBox('data', 0, flags, [0,0,0,0 reserved, ...value])
+ *
+ * iTunes type flags:
+ * 1 = UTF-8 text
+ * 13 = JPEG image
+ * 14 = PNG image
+ * 21 = big-endian signed integer
+ *
+ * @param {number} flags
+ * @param {!Array} value
+ * @return {!Array}
+ */
+ function dataBox(flags, value) {
+ return fullBox('data', 0, flags, [0, 0, 0, 0, ...value]);
+ }
+
+ /**
+ * Build a complete ILST child box (e.g. ©nam):
+ * basicBox(type, dataBox(flags, value))
+ *
+ * @param {string} type Four-character ILST key.
+ * @param {number} flags
+ * @param {!Array} value
+ * @return {!Array}
+ */
+ function ilstChild(type, flags, value) {
+ return basicBox(type, dataBox(flags, value));
+ }
+
+ /**
+ * Build a freeform `----` ILST child box containing mean / name / data.
+ *
+ * @param {string} mean Reverse-domain namespace (e.g. 'com.apple.iTunes').
+ * @param {string} name Tag name (e.g. 'iTunSMPB').
+ * @param {number} flags
+ * @param {!Array} value
+ * @return {!Array}
+ */
+ function freeformChild(mean, name, flags, value) {
+ const meanBox = fullBox('mean', 0, 0, ascii(mean));
+ const nameBox = fullBox('name', 0, 0, ascii(name));
+ const dataBytes = dataBox(flags, value);
+ return basicBox('----', [...meanBox, ...nameBox, ...dataBytes]);
+ }
+
+ /**
+ * Wrap ILST children in the moov > udta > meta(full) > ilst structure
+ * expected by new IlstUtils().parse().
+ *
+ * Each argument is an Array representing one child box.
+ *
+ * @param {...!Array} children
+ * @return {!Uint8Array}
+ */
+ function buildMp4(...children) {
+ const ilstContent = children.reduce((acc, ch) => {
+ return acc.concat(ch);
+ }, []);
+ const ilst = basicBox('ilst', ilstContent);
+ const meta = fullBox('meta', 0, 0, ilst);
+ const udta = basicBox('udta', meta);
+ const moov = basicBox('moov', udta);
+ return new Uint8Array(moov);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Basic dispatch
+ // ---------------------------------------------------------------------------
+
+ it('empty data returns empty array', () => {
+ expect(new IlstUtils().parse(new Uint8Array([]))).toEqual([]);
+ });
+
+ it('empty ilst box returns empty array', () => {
+ const data = buildMp4(/* no children */);
+ expect(new IlstUtils().parse(data)).toEqual([]);
+ });
+
+ // ---------------------------------------------------------------------------
+ // UTF-8 text atoms (flags = 1)
+ // ---------------------------------------------------------------------------
+
+ it('parses a UTF-8 text atom and maps its key to the ID3 equivalent', () => {
+ const data = buildMp4(ilstChild('©nam', 1, ascii('Shaka')));
+
+ expect(new IlstUtils().parse(data)).toEqual([{
+ key: 'TIT2',
+ data: 'Shaka',
+ description: '',
+ mimeType: null,
+ pictureType: null,
+ }]);
+ });
+
+ it('strips embedded null characters from UTF-8 values', () => {
+ const data = buildMp4(ilstChild('©nam', 1, [...ascii('Shaka'), 0, 0]));
+ const frames = new IlstUtils().parse(data);
+ expect(frames[0].data).toBe('Shaka');
+ });
+
+ it('maps all standard ILST keys to their ID3 equivalents', () => {
+ const mapping = [
+ ['©nam', 'TIT2'],
+ ['©ART', 'TPE1'],
+ ['aART', 'TPE2'],
+ ['©alb', 'TALB'],
+ ['©gen', 'TCON'],
+ ['©day', 'TDRC'],
+ ['©wrt', 'TEXT'],
+ ['©cmt', 'COMM'],
+ ['covr', 'APIC'],
+ ['cprt', 'TCOP'],
+ ['©too', 'TENC'],
+ ['tmpo', 'TBPM'],
+ ['cpil', 'TCMP'],
+ ];
+
+ for (const [ilstKey, id3Key] of mapping) {
+ const data = buildMp4(ilstChild(ilstKey, 1, ascii('test')));
+ const frames = new IlstUtils().parse(data);
+ expect(frames[0].key)
+ .withContext(`${ilstKey} → ${id3Key}`)
+ .toBe(id3Key);
+ }
+ });
+
+ it('passes through unrecognised ILST keys unchanged', () => {
+ const data = buildMp4(ilstChild('xyzz', 1, ascii('value')));
+ const frames = new IlstUtils().parse(data);
+ expect(frames[0].key).toBe('xyzz');
+ });
+
+ it('treats atoms with unknown type flags as UTF-8 text', () => {
+ // flags = 99 is not defined by iTunes; the default branch returns text.
+ const data = buildMp4(ilstChild('©nam', 99, ascii('Shaka')));
+ const frames = new IlstUtils().parse(data);
+ expect(frames[0].data).toBe('Shaka');
+ });
+
+ // ---------------------------------------------------------------------------
+ // Cover art — JPEG (flags = 13) and PNG (flags = 14)
+ // ---------------------------------------------------------------------------
+
+ it('parses a JPEG cover art atom as an APIC frame with image/jpeg', () => {
+ const imageBytes = [0xff, 0xd8, 0xff, 0xe0, 0x01, 0x02];
+ const data = buildMp4(ilstChild('covr', 13, imageBytes));
+ const frames = new IlstUtils().parse(data);
+
+ expect(frames).toEqual([{
+ key: 'APIC',
+ data: BufferUtils.toArrayBuffer(new Uint8Array(imageBytes)),
+ description: '',
+ mimeType: 'image/jpeg',
+ pictureType: 3,
+ }]);
+ });
+
+ it('parses a PNG cover art atom as an APIC frame with image/png', () => {
+ const imageBytes = [0x89, 0x50, 0x4e, 0x47];
+ const data = buildMp4(ilstChild('covr', 14, imageBytes));
+ const frames = new IlstUtils().parse(data);
+
+ expect(frames[0].mimeType).toBe('image/png');
+ expect(frames[0].pictureType).toBe(3);
+ expect(frames[0].key).toBe('APIC');
+ });
+
+ // ---------------------------------------------------------------------------
+ // Integer atoms (flags = 21)
+ // ---------------------------------------------------------------------------
+
+ it('parses a 1-byte signed integer (flags = 21)', () => {
+ const data = buildMp4(ilstChild('tmpo', 21, [120]));
+ expect(new IlstUtils().parse(data)[0].data).toBe(120);
+ });
+
+ it('parses a 2-byte signed integer (flags = 21)', () => {
+ // 0x00C8 = 200
+ const data = buildMp4(ilstChild('tmpo', 21, [0x00, 0xc8]));
+ expect(new IlstUtils().parse(data)[0].data).toBe(200);
+ });
+
+ it('sign-extends a negative 3-byte integer (flags = 21)', () => {
+ // 0x800000 with sign extension = -8388608
+ const data = buildMp4(ilstChild('tmpo', 21, [0x80, 0x00, 0x00]));
+ expect(new IlstUtils().parse(data)[0].data).toBe(-8388608);
+ });
+
+ it('parses a positive 3-byte integer (flags = 21)', () => {
+ // 0x000003 = 3
+ const data = buildMp4(ilstChild('tmpo', 21, [0x00, 0x00, 0x03]));
+ expect(new IlstUtils().parse(data)[0].data).toBe(3);
+ });
+
+ it('parses a 4-byte negative signed integer (flags = 21)', () => {
+ // 0xFFFFFF9C = -100 as Int32
+ const data = buildMp4(ilstChild('tmpo', 21, [0xff, 0xff, 0xff, 0x9c]));
+ expect(new IlstUtils().parse(data)[0].data).toBe(-100);
+ });
+
+ it('parses a 4-byte positive signed integer (flags = 21)', () => {
+ // 0x000001F4 = 500
+ const data = buildMp4(ilstChild('tmpo', 21, [0x00, 0x00, 0x01, 0xf4]));
+ expect(new IlstUtils().parse(data)[0].data).toBe(500);
+ });
+
+ // ---------------------------------------------------------------------------
+ // Track and disc number atoms (trkn, disk)
+ // ---------------------------------------------------------------------------
+
+ it('parses trkn atom as "index/total" when total > 0', () => {
+ // Layout: 2 bytes padding, uint16BE index, uint16BE total, 2 bytes optional
+ const value = [0x00, 0x00, 0x00, 0x03, 0x00, 0x0c, 0x00, 0x00]; // 3/12
+ const data = buildMp4(ilstChild('trkn', 21, value));
+ const frames = new IlstUtils().parse(data);
+
+ expect(frames[0].key).toBe('TRCK');
+ expect(frames[0].data).toBe('3/12');
+ });
+
+ it('parses trkn atom as plain index string when total = 0', () => {
+ const value = [0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x00]; // 5
+ const data = buildMp4(ilstChild('trkn', 21, value));
+ const frames = new IlstUtils().parse(data);
+
+ expect(frames[0].key).toBe('TRCK');
+ expect(frames[0].data).toBe('5');
+ });
+
+ it('parses disk atom as "index/total" when total > 0', () => {
+ const value = [0x00, 0x00, 0x00, 0x02, 0x00, 0x03, 0x00, 0x00]; // 2/3
+ const data = buildMp4(ilstChild('disk', 21, value));
+ const frames = new IlstUtils().parse(data);
+
+ expect(frames[0].key).toBe('TPOS');
+ expect(frames[0].data).toBe('2/3');
+ });
+
+ it('falls through to integer parsing when trkn payload is < 6 bytes', () => {
+ // The trkn special case requires raw.length >= 6. With 4 bytes it falls
+ // through to the integer switch without throwing.
+ const data = buildMp4(ilstChild('trkn', 21, [0x00, 0x00, 0x00, 0x05]));
+ expect(() => new IlstUtils().parse(data)).not.toThrow();
+ });
+
+ // ---------------------------------------------------------------------------
+ // Freeform atoms (----)
+ // ---------------------------------------------------------------------------
+
+ // eslint-disable-next-line @stylistic/max-len
+ it('parses a freeform atom and exposes name as key, mean as description', () => {
+ const data = buildMp4(
+ freeformChild('com.apple.iTunes', 'iTunSMPB', 1, ascii('0 2112 840')),
+ );
+
+ expect(new IlstUtils().parse(data)).toEqual([{
+ key: 'iTunSMPB',
+ data: '0 2112 840',
+ description: 'Domain: com.apple.iTunes',
+ mimeType: null,
+ pictureType: null,
+ }]);
+ });
+
+ it('sets description to empty string when mean box is empty', () => {
+ const data = buildMp4(
+ freeformChild('', 'myTag', 1, ascii('hello')),
+ );
+ const frames = new IlstUtils().parse(data);
+ expect(frames[0].description).toBe('');
+ });
+
+ it('skips a freeform atom when the name box is absent', () => {
+ // Build ---- with only mean + data, no name.
+ const meanBox = fullBox('mean', 0, 0, ascii('com.apple.iTunes'));
+ const dataBytes = dataBox(1, ascii('value'));
+ const child = basicBox('----', [...meanBox, ...dataBytes]);
+ const data = buildMp4(child);
+
+ expect(new IlstUtils().parse(data)).toEqual([]);
+ });
+
+ it('skips a freeform atom when the data box is absent', () => {
+ // Build ---- with only mean + name.
+ const meanBox = fullBox('mean', 0, 0, ascii('com.apple.iTunes'));
+ const nameBox = fullBox('name', 0, 0, ascii('iTunSMPB'));
+ const child = basicBox('----', [...meanBox, ...nameBox]);
+ const data = buildMp4(child);
+
+ expect(new IlstUtils().parse(data)).toEqual([]);
+ });
+
+ // ---------------------------------------------------------------------------
+ // Multiple atoms
+ // ---------------------------------------------------------------------------
+
+ it('parses multiple atoms from a single ilst box in order', () => {
+ const data = buildMp4(
+ ilstChild('©nam', 1, ascii('Blade Runner 2049')),
+ ilstChild('©ART', 1, ascii('Hans Zimmer')),
+ ilstChild('©alb', 1, ascii('Blade Runner 2049 OST')),
+ );
+ const frames = new IlstUtils().parse(data);
+
+ expect(frames.length).toBe(3);
+ expect(frames.find((f) => f.key === 'TIT2').data).toBe('Blade Runner 2049');
+ expect(frames.find((f) => f.key === 'TPE1').data).toBe('Hans Zimmer');
+ expect(frames.find((f) => f.key === 'TALB').data)
+ .toBe('Blade Runner 2049 OST');
+ });
+
+ it('parses a mix of standard and freeform atoms', () => {
+ const data = buildMp4(
+ ilstChild('©nam', 1, ascii('Title')),
+ freeformChild('com.apple.iTunes', 'iTunSMPB', 1, ascii('smpb')),
+ );
+ const frames = new IlstUtils().parse(data);
+
+ expect(frames.length).toBe(2);
+ expect(frames.find((f) => f.key === 'TIT2')).toBeDefined();
+ expect(frames.find((f) => f.key === 'iTunSMPB')).toBeDefined();
+ });
+
+ // ---------------------------------------------------------------------------
+ // Robustness – malformed / truncated data
+ // ---------------------------------------------------------------------------
+
+ it('does not throw when an ilst child declares size < 8', () => {
+ // size = 4 is less than the minimum 8-byte box header; the parser must
+ // break out of the loop rather than attempting readBytes with a negative
+ // or enormous count.
+ const malformed = [...uint32BE(4), ...ascii('©nam')];
+ const data = buildMp4(malformed);
+
+ expect(() => new IlstUtils().parse(data)).not.toThrow();
+ expect(new IlstUtils().parse(data)).toEqual([]);
+ });
+
+ // eslint-disable-next-line @stylistic/max-len
+ it('clamps to available bytes when child size extends past the ilst boundary', () => {
+ // Build a legitimate data box, then lie about the outer child size so it
+ // would overrun the ilst payload. The fixed parser clamps to maxPayload.
+ const valueBytes = ascii('Shaka');
+ const realDataBox = dataBox(1, valueBytes);
+ const inflatedSize = 8 + realDataBox.length + 100; // claims 100 extra bytes
+
+ const fakeChild = [
+ ...uint32BE(inflatedSize),
+ ...ascii('©nam'),
+ ...realDataBox,
+ ];
+ const data = buildMp4(fakeChild);
+
+ expect(() => new IlstUtils().parse(data)).not.toThrow();
+ // The clamped payload is still a valid data box, so the frame parses.
+ const frames = new IlstUtils().parse(data);
+ expect(frames[0].key).toBe('TIT2');
+ expect(frames[0].data).toBe('Shaka');
+ });
+
+ it('does not throw when a freeform atom contains no sub-boxes', () => {
+ const emptyFreeform = basicBox('----', []);
+ const data = buildMp4(emptyFreeform);
+ expect(() => new IlstUtils().parse(data)).not.toThrow();
+ expect(new IlstUtils().parse(data)).toEqual([]);
+ });
+});
diff --git a/test/metadata/vorbis_utils_unit.js b/test/metadata/vorbis_utils_unit.js
new file mode 100644
index 000000000..c362decf6
--- /dev/null
+++ b/test/metadata/vorbis_utils_unit.js
@@ -0,0 +1,533 @@
+/*! @license
+ * Shaka Player
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+describe('VorbisUtils', () => {
+ const VorbisUtils = shaka.metadata.VorbisUtils;
+ const BufferUtils = shaka.util.BufferUtils;
+
+ // ---------------------------------------------------------------------------
+ // Binary-construction helpers
+ // ---------------------------------------------------------------------------
+
+ /**
+ * @param {number} n
+ * @return {!Array}
+ */
+ function uint32LE(n) {
+ return [
+ (n >>> 0) & 0xff,
+ (n >>> 8) & 0xff,
+ (n >>> 16) & 0xff,
+ (n >>> 24) & 0xff,
+ ];
+ }
+
+ /**
+ * @param {number} n
+ * @return {!Array}
+ */
+ function uint32BE(n) {
+ return [
+ (n >>> 24) & 0xff,
+ (n >>> 16) & 0xff,
+ (n >>> 8) & 0xff,
+ (n >>> 0) & 0xff,
+ ];
+ }
+
+ /**
+ * Encode an ASCII string as a plain byte array.
+ * @param {string} str
+ * @return {!Array}
+ */
+ function ascii(str) {
+ return Array.from(str).map((c) => c.charCodeAt(0));
+ }
+
+ /**
+ * Build a raw Vorbis comment block body (little-endian lengths).
+ *
+ * @param {string} vendor
+ * @param {!Array} comments e.g. ['TITLE=Shaka', 'ARTIST=Hans']
+ * @return {!Array}
+ */
+ function vorbisCommentBody(vendor, comments) {
+ const vendorBytes = ascii(vendor);
+ const out = [
+ ...uint32LE(vendorBytes.length),
+ ...vendorBytes,
+ ...uint32LE(comments.length),
+ ];
+ for (const c of comments) {
+ const cb = ascii(c);
+ out.push(...uint32LE(cb.length), ...cb);
+ }
+ return out;
+ }
+
+ /**
+ * Wrap a body into a FLAC metadata block header.
+ *
+ * @param {number} blockType 0–127
+ * @param {boolean} isLast
+ * @param {!Array} body
+ * @return {!Array}
+ */
+ function flacBlock(blockType, isLast, body) {
+ const header = (isLast ? 0x80 : 0x00) | (blockType & 0x7f);
+ const len = body.length;
+ return [
+ header,
+ (len >>> 16) & 0xff,
+ (len >>> 8) & 0xff,
+ len & 0xff,
+ ...body,
+ ];
+ }
+
+ /**
+ * Build a complete FLAC file from an array of pre-built block byte arrays.
+ *
+ * @param {!Array>} blocks
+ * @return {!Uint8Array}
+ */
+ function flac(blocks) {
+ return new Uint8Array([
+ 0x66, 0x4c, 0x61, 0x43, // fLaC
+ ...blocks.reduce((acc, block) => {
+ return acc.concat(block);
+ }, []),
+ ]);
+ }
+
+ /**
+ * Build the body of a FLAC PICTURE block (big-endian lengths).
+ *
+ * @param {number} pictureType
+ * @param {string} mimeType
+ * @param {string} description
+ * @param {!Array} imageData
+ * @return {!Array}
+ */
+ function flacPictureBody(pictureType, mimeType, description, imageData) {
+ const mimeBytes = ascii(mimeType);
+ const descBytes = ascii(description);
+ return [
+ ...uint32BE(pictureType),
+ ...uint32BE(mimeBytes.length),
+ ...mimeBytes,
+ ...uint32BE(descBytes.length),
+ ...descBytes,
+ ...uint32BE(0), // width – ignored
+ ...uint32BE(0), // height – ignored
+ ...uint32BE(0), // color depth – ignored
+ ...uint32BE(0), // color count – ignored
+ ...uint32BE(imageData.length),
+ ...imageData,
+ ];
+ }
+
+ /**
+ * Build a minimal OGG container carrying a Vorbis comment block.
+ *
+ * parseOgg_ looks for 'OpusTags' (8 bytes) or 'vorbis' (6 bytes) and then
+ * skips 8 bytes before handing the rest to parseVorbisCommentBlock_. This
+ * helper places the marker immediately after the OggS magic so the comment
+ * body starts exactly at byte 4 + 8 = 12.
+ *
+ * @param {string} codec
+ * @param {!Array} commentBodyBytes
+ * @return {!Uint8Array}
+ */
+ function ogg(codec, commentBodyBytes) {
+ const oggsMagic = [0x4f, 0x67, 0x67, 0x53]; // OggS
+
+ if (codec === 'opus') {
+ // 'OpusTags' is exactly 8 bytes; the comment body begins right after.
+ return new Uint8Array([
+ ...oggsMagic,
+ ...ascii('OpusTags'),
+ ...commentBodyBytes,
+ ]);
+ }
+
+ // 'vorbis' is 6 bytes. parseOgg_ skips 8 from the match index, so we add
+ // 2 padding bytes after the marker so the comment body aligns correctly.
+ return new Uint8Array([
+ ...oggsMagic,
+ ...ascii('vorbis'),
+ 0x00, 0x00, // 2 padding bytes to reach offset+8
+ ...commentBodyBytes,
+ ]);
+ }
+
+ // ---------------------------------------------------------------------------
+ // parse – dispatch
+ // ---------------------------------------------------------------------------
+
+ it('empty data returns empty output', () => {
+ expect(new VorbisUtils().parse(new Uint8Array([]))).toEqual([]);
+ });
+
+ it('non-FLAC non-OGG data returns empty output', () => {
+ const data = new Uint8Array([0x00, 0x01, 0x02, 0x03]);
+ expect(new VorbisUtils().parse(data)).toEqual([]);
+ });
+
+ // ---------------------------------------------------------------------------
+ // FLAC – VORBIS_COMMENT block (type 4)
+ // ---------------------------------------------------------------------------
+
+ it('parses a single comment from a FLAC VORBIS_COMMENT block', () => {
+ const body = vorbisCommentBody('', ['TITLE=Shaka']);
+ const data = flac([flacBlock(4, /* isLast= */ true, body)]);
+
+ expect(new VorbisUtils().parse(data)).toEqual([{
+ key: 'TIT2',
+ data: 'Shaka',
+ description: '',
+ mimeType: null,
+ pictureType: null,
+ }]);
+ });
+
+ it('parses multiple comments from a single VORBIS_COMMENT block', () => {
+ const body = vorbisCommentBody(
+ 'reference libFLAC 1.4.3',
+ ['TITLE=A Way of Life', 'ARTIST=Hans Zimmer', 'ALBUM=The Last Samurai'],
+ );
+ const data = flac([flacBlock(4, /* isLast= */ true, body)]);
+ const frames = new VorbisUtils().parse(data);
+
+ expect(frames).toEqual(jasmine.arrayContaining([
+ jasmine.objectContaining({key: 'TIT2', data: 'A Way of Life'}),
+ jasmine.objectContaining({key: 'TPE1', data: 'Hans Zimmer'}),
+ jasmine.objectContaining({key: 'TALB', data: 'The Last Samurai'}),
+ ]));
+ });
+
+ it('maps all supported Vorbis field names to their ID3 equivalents', () => {
+ const mapping = [
+ ['TITLE', 'TIT2'],
+ ['ARTIST', 'TPE1'],
+ ['ALBUM', 'TALB'],
+ ['ALBUMARTIST', 'TPE2'],
+ ['TRACKNUMBER', 'TRCK'],
+ ['DISCNUMBER', 'TPOS'],
+ ['DATE', 'TDRC'],
+ ['GENRE', 'TCON'],
+ ['COMMENT', 'COMM'],
+ ['DESCRIPTION', 'TIT3'],
+ ['COPYRIGHT', 'TCOP'],
+ ['COMPOSER', 'TCOM'],
+ ['LYRICS', 'USLT'],
+ ['ISRC', 'TSRC'],
+ ];
+
+ for (const [vorbisKey, id3Key] of mapping) {
+ const body = vorbisCommentBody('', [`${vorbisKey}=test`]);
+ const data = flac([flacBlock(4, /* isLast= */ true, body)]);
+ const frames = new VorbisUtils().parse(data);
+
+ expect(frames[0].key)
+ .withContext(`${vorbisKey} → ${id3Key}`)
+ .toBe(id3Key);
+ }
+ });
+
+ it('passes unknown Vorbis keys through unchanged in upper-case', () => {
+ const body = vorbisCommentBody('', ['REPLAYGAIN_TRACK_GAIN=-6.0 dB']);
+ const data = flac([flacBlock(4, /* isLast= */ true, body)]);
+ const frames = new VorbisUtils().parse(data);
+
+ expect(frames[0].key).toBe('REPLAYGAIN_TRACK_GAIN');
+ expect(frames[0].data).toBe('-6.0 dB');
+ });
+
+ // eslint-disable-next-line @stylistic/max-len
+ it('lower-case Vorbis keys are normalised to upper-case before mapping', () => {
+ const body = vorbisCommentBody('', ['title=Shaka']);
+ const data = flac([flacBlock(4, /* isLast= */ true, body)]);
+ const frames = new VorbisUtils().parse(data);
+
+ expect(frames[0].key).toBe('TIT2');
+ });
+
+ it('skips comment entries that have no "=" separator', () => {
+ const body = vorbisCommentBody('', ['BADENTRY', 'TITLE=Shaka']);
+ const data = flac([flacBlock(4, /* isLast= */ true, body)]);
+ const frames = new VorbisUtils().parse(data);
+
+ expect(frames.length).toBe(1);
+ expect(frames[0].key).toBe('TIT2');
+ });
+
+ it('value may contain "=" characters without being truncated', () => {
+ const body = vorbisCommentBody('', ['TITLE=A=B=C']);
+ const data = flac([flacBlock(4, /* isLast= */ true, body)]);
+ const frames = new VorbisUtils().parse(data);
+
+ expect(frames[0].data).toBe('A=B=C');
+ });
+
+ it('stops parsing FLAC blocks after the isLast block', () => {
+ // First block: isLast=true with TITLE. Second block would add ARTIST but
+ // must never be reached.
+ const firstBody = vorbisCommentBody('', ['TITLE=Shaka']);
+ const secondBody = vorbisCommentBody('', ['ARTIST=Hans Zimmer']);
+
+ const data = flac([
+ flacBlock(4, /* isLast= */ true, firstBody),
+ flacBlock(4, /* isLast= */ true, secondBody),
+ ]);
+
+ const frames = new VorbisUtils().parse(data);
+
+ expect(frames.length).toBe(1);
+ expect(frames[0].key).toBe('TIT2');
+ });
+
+ it('gracefully stops when a block length exceeds the available data', () => {
+ // Build a valid FLAC header with a block whose declared size is huge.
+ const data = new Uint8Array([
+ 0x66, 0x4c, 0x61, 0x43, // fLaC
+ 0x84, // type=4, isLast=true
+ 0x00, 0x10, 0x00, // length = 4096 (way past end of buffer)
+ 0x00, 0x00, 0x00, 0x00, // only 4 bytes of body
+ ]);
+
+ expect(new VorbisUtils().parse(data)).toEqual([]);
+ });
+
+ // eslint-disable-next-line @stylistic/max-len
+ it('returns empty when VORBIS_COMMENT body is too short for vendor length', () => {
+ // Body is only 2 bytes – not enough for the 4-byte vendor_length field.
+ const body = [0x00, 0x01];
+ const data = flac([flacBlock(4, /* isLast= */ true, body)]);
+
+ expect(new VorbisUtils().parse(data)).toEqual([]);
+ });
+
+ // eslint-disable-next-line @stylistic/max-len
+ it('returns empty when VORBIS_COMMENT body is truncated after vendor string', () => {
+ // vendor_length = 0, then only 2 bytes instead of the 4 needed for
+ // comment_count.
+ const body = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
+ const data = flac([flacBlock(4, /* isLast= */ true, body)]);
+
+ expect(new VorbisUtils().parse(data)).toEqual([]);
+ });
+
+ // eslint-disable-next-line @stylistic/max-len
+ it('stops mid-loop when a comment entry length exceeds remaining data', () => {
+ // comment_count = 2, first entry valid, second entry claims 255 bytes.
+ const firstEntry = [...uint32LE(7), ...ascii('TITLE=A')];
+ const brokenEntry = [...uint32LE(255)]; // no actual bytes follow
+
+ const body = [
+ ...uint32LE(0), // vendor_length = 0
+ ...uint32LE(2), // comment_count = 2
+ ...firstEntry,
+ ...brokenEntry,
+ ];
+ const data = flac([flacBlock(4, /* isLast= */ true, body)]);
+ const frames = new VorbisUtils().parse(data);
+
+ // Only the valid first entry should be returned.
+ expect(frames.length).toBe(1);
+ expect(frames[0].key).toBe('TIT2');
+ });
+
+ // ---------------------------------------------------------------------------
+ // FLAC – PICTURE block (type 6)
+ // ---------------------------------------------------------------------------
+
+ it('parses a FLAC PICTURE block as an APIC frame', () => {
+ const imageData = [0xff, 0xd8, 0xff, 0xe0, 0x01, 0x02]; // fake JPEG header
+ const body = flacPictureBody(3, 'image/jpeg', '', imageData);
+ const data = flac([flacBlock(6, /* isLast= */ true, body)]);
+ const frames = new VorbisUtils().parse(data);
+
+ expect(frames).toEqual([{
+ key: 'APIC',
+ mimeType: 'image/jpeg',
+ pictureType: 3,
+ description: '',
+ data: BufferUtils.toArrayBuffer(new Uint8Array(imageData)),
+ }]);
+ });
+
+ it('preserves the description field from a FLAC PICTURE block', () => {
+ const body = flacPictureBody(3, 'image/png', 'Front Cover', [0x89, 0x50]);
+ const data = flac([flacBlock(6, /* isLast= */ true, body)]);
+ const frames = new VorbisUtils().parse(data);
+
+ expect(frames[0].description).toBe('Front Cover');
+ });
+
+ it('preserves the pictureType value from a FLAC PICTURE block', () => {
+ // pictureType 4 = "Back cover"
+ const body = flacPictureBody(4, 'image/jpeg', '', [0x01]);
+ const data = flac([flacBlock(6, /* isLast= */ true, body)]);
+ const frames = new VorbisUtils().parse(data);
+
+ expect(frames[0].pictureType).toBe(4);
+ });
+
+ // eslint-disable-next-line @stylistic/max-len
+ it('returns null (skips) a PICTURE block that is too short for pictureType', () => {
+ // Only 2 bytes – not enough for the first uint32 BE.
+ const data = flac([flacBlock(6, /* isLast= */ true, [0x00, 0x00])]);
+
+ expect(new VorbisUtils().parse(data)).toEqual([]);
+ });
+
+ it('returns null (skips) a PICTURE block truncated after MIME type', () => {
+ // pictureType(4) + mime_length=10 but only 3 mime bytes follow.
+ const body = [
+ ...uint32BE(3), // pictureType
+ ...uint32BE(10), // mime_length = 10
+ ...ascii('ima'), // only 3 bytes instead of 10
+ ];
+ const data = flac([flacBlock(6, /* isLast= */ true, body)]);
+
+ expect(new VorbisUtils().parse(data)).toEqual([]);
+ });
+
+ it('returns null (skips) a PICTURE block truncated after description', () => {
+ const mimeBytes = ascii('image/jpeg');
+ const body = [
+ ...uint32BE(3),
+ ...uint32BE(mimeBytes.length),
+ ...mimeBytes,
+ ...uint32BE(5), // desc_length = 5
+ ...ascii('ab'), // only 2 bytes instead of 5
+ ];
+ const data = flac([flacBlock(6, /* isLast= */ true, body)]);
+
+ expect(new VorbisUtils().parse(data)).toEqual([]);
+ });
+
+ // eslint-disable-next-line @stylistic/max-len
+ it('returns null (skips) a PICTURE block truncated before data length field', () => {
+ const mimeBytes = ascii('image/jpeg');
+ const descBytes = ascii('');
+ // Provide everything up to and including the 16 spatial bytes, then stop.
+ const body = [
+ ...uint32BE(3),
+ ...uint32BE(mimeBytes.length),
+ ...mimeBytes,
+ ...uint32BE(descBytes.length),
+ ...descBytes,
+ ...uint32BE(0), ...uint32BE(0), ...uint32BE(0), ...uint32BE(0),
+ // data_length field (4 bytes) is missing
+ ];
+ const data = flac([flacBlock(6, /* isLast= */ true, body)]);
+
+ expect(new VorbisUtils().parse(data)).toEqual([]);
+ });
+
+ // eslint-disable-next-line @stylistic/max-len
+ it('returns null (skips) a PICTURE block where image data is truncated', () => {
+ const mimeBytes = ascii('image/jpeg');
+ const body = [
+ ...uint32BE(3),
+ ...uint32BE(mimeBytes.length),
+ ...mimeBytes,
+ ...uint32BE(0), // desc_length = 0
+ ...uint32BE(0), ...uint32BE(0), ...uint32BE(0), ...uint32BE(0),
+ ...uint32BE(100), // data_length = 100
+ 0x01, 0x02, // only 2 bytes of image data
+ ];
+ const data = flac([flacBlock(6, /* isLast= */ true, body)]);
+
+ expect(new VorbisUtils().parse(data)).toEqual([]);
+ });
+
+ // ---------------------------------------------------------------------------
+ // FLAC – both block types together
+ // ---------------------------------------------------------------------------
+
+ // eslint-disable-next-line @stylistic/max-len
+ it('parses both a VORBIS_COMMENT and a PICTURE block from the same FLAC', () => {
+ const commentBody = vorbisCommentBody('', ['TITLE=Shaka']);
+ const pictureBody = flacPictureBody(3, 'image/jpeg', '', [0x01, 0x02]);
+
+ const data = flac([
+ flacBlock(4, /* isLast= */ false, commentBody),
+ flacBlock(6, /* isLast= */ true, pictureBody),
+ ]);
+
+ const frames = new VorbisUtils().parse(data);
+
+ expect(frames.length).toBe(2);
+ expect(frames.find((f) => f.key === 'TIT2')).toBeDefined();
+ expect(frames.find((f) => f.key === 'APIC')).toBeDefined();
+ });
+
+ it('ignores unrecognised FLAC block types without errors', () => {
+ // Block type 2 (SEEKTABLE) has no handler and must be silently skipped.
+ const seekBody = new Array(18).fill(0xff);
+ const commentBody = vorbisCommentBody('', ['TITLE=Shaka']);
+
+ const data = flac([
+ flacBlock(2, /* isLast= */ false, seekBody),
+ flacBlock(4, /* isLast= */ true, commentBody),
+ ]);
+
+ const frames = new VorbisUtils().parse(data);
+
+ expect(frames.length).toBe(1);
+ expect(frames[0].key).toBe('TIT2');
+ });
+
+ // ---------------------------------------------------------------------------
+ // OGG – OpusTags
+ // ---------------------------------------------------------------------------
+
+ it('parses OGG Opus comments via the OpusTags marker', () => {
+ const body = vorbisCommentBody('', ['TITLE=Shaka', 'ARTIST=Hans Zimmer']);
+ const data = ogg('opus', body);
+ const frames = new VorbisUtils().parse(data);
+
+ expect(frames).toEqual(jasmine.arrayContaining([
+ jasmine.objectContaining({key: 'TIT2', data: 'Shaka'}),
+ jasmine.objectContaining({key: 'TPE1', data: 'Hans Zimmer'}),
+ ]));
+ });
+
+ it('returns empty array for OGG Opus with no comments', () => {
+ const body = vorbisCommentBody('reference libopus', []);
+ const data = ogg('opus', body);
+ const frames = new VorbisUtils().parse(data);
+
+ expect(frames).toEqual([]);
+ });
+
+ // ---------------------------------------------------------------------------
+ // OGG – vorbis marker
+ // ---------------------------------------------------------------------------
+
+ it('parses OGG Vorbis comments via the vorbis marker', () => {
+ const body = vorbisCommentBody('', ['ALBUM=The Last Samurai']);
+ const data = ogg('vorbis', body);
+ const frames = new VorbisUtils().parse(data);
+
+ expect(frames).toEqual([
+ jasmine.objectContaining({key: 'TALB', data: 'The Last Samurai'}),
+ ]);
+ });
+
+ it('returns empty for OGG data with no recognised marker', () => {
+ // Valid OggS magic but no OpusTags or vorbis string anywhere.
+ const data = new Uint8Array([
+ 0x4f, 0x67, 0x67, 0x53, // OggS
+ 0x00, 0x01, 0x02, 0x03,
+ ]);
+
+ expect(new VorbisUtils().parse(data)).toEqual([]);
+ });
+});