feat: Add metadata extraction support for src= playback (#10112)

Co-authored-by: absidue <48293849+absidue@users.noreply.github.com>
This commit is contained in:
Álvaro Velad Galván
2026-06-05 16:20:49 +02:00
committed by GitHub
parent e19c24d5c6
commit 87a7d86daf
27 changed files with 2056 additions and 148 deletions
+1
View File
@@ -6,6 +6,7 @@
+@fairplay
+@networking
+@manifests
+@metadata
+@polyfill
+@polyfillForUI
+@queue
+3 -1
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
# Metadata
+../../lib/metadata/id3v1_utils.js
+../../lib/metadata/ilst_utils.js
+../../lib/metadata/vorbis_utils.js
+7
View File
@@ -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 {{{
+4 -1
View File
@@ -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_();
}
+80
View File
@@ -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.
*
* <h3>Key naming convention</h3>
* Every {@link shaka.extern.MetadataFrame} produced by a parser MUST use the
* corresponding <strong>ID3v2.4 four-character frame ID</strong> 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:
* <ul>
* <li>Track title {@code TIT2}</li>
* <li>Lead artist {@code TPE1}</li>
* <li>Album name {@code TALB}</li>
* <li>Genre {@code TCON}</li>
* <li>Recording year / date {@code TDRC}</li>
* <li>Track number {@code TRCK}</li>
* <li>Disc number {@code TPOS}</li>
* <li>Comment {@code COMM}</li>
* <li>Attached picture / cover art {@code APIC}</li>
* </ul>
*
* 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:
* <ul>
* <li>Return an empty array (never {@code null}) when no frames are
* found or the data is not recognised.</li>
* <li>Not throw; all errors should be swallowed and result in an empty
* or partial return value.</li>
* <li>Use ID3v2.4 four-character frame IDs as {@code key} values
* wherever a mapping exists (see class-level documentation).</li>
* </ul>
*
* @param {!Uint8Array} data Raw bytes of the media segment or file.
* @return {!Array<!shaka.extern.MetadataFrame>}
* @exportDoc
*/
parse(data) {}
};
/**
* A factory function that creates a {@link shaka.extern.MetadataParser}
* instance.
*
* @typedef {function():!shaka.extern.MetadataParser}
* @exportDoc
*/
shaka.extern.MetadataParser.Factory;
+9
View File
@@ -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.
* <br>
* Defaults to <code>false</code>.
* @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.
* <br>
* Defaults to <code>true</code>.
* @exportDoc
*/
shaka.extern.StreamingConfiguration;
+3 -3
View File
@@ -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} */
@@ -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<!shaka.extern.MetadataFrame>}
* @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());
}
+84
View File
@@ -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());
}
+286
View File
@@ -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<string, string>}
* @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());
+108
View File
@@ -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<!shaka.extern.MetadataFrame>}
* @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<string, !Array<shaka.extern.MetadataParser.Factory>>}
* @private
*/
shaka.metadata.Metadata.parsersByMime_ = new Map();
+245
View File
@@ -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<!shaka.extern.MetadataFrame>}
* @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<string, string>}
* @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());
+110
View File
@@ -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;
}
+3 -3
View File
@@ -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 ===
+3 -3
View File
@@ -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 ===
+3 -3
View File
@@ -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 ===
+2 -2
View File
@@ -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)) {
+2 -2
View File
@@ -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)) {
+1
View File
@@ -257,6 +257,7 @@ shaka.util.PlayerConfiguration = class {
returnToEndOfLiveWindowWhenOutside: false,
stopFetchingOnPause: false,
clampAppendWindowToDuration: false,
processSrcEqualMetadata: true,
};
const networking = {
+2 -2
View File
@@ -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,
});
+24
View File
@@ -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
+5
View File
@@ -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');
@@ -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';
+59
View File
@@ -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([]);
});
});
+442
View File
@@ -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<number>}
*/
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<number>}
*/
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<number>} payload
* @return {!Array<number>}
*/
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<number>} payload
* @return {!Array<number>}
*/
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<number>} value
* @return {!Array<number>}
*/
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<number>} value
* @return {!Array<number>}
*/
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<number>} value
* @return {!Array<number>}
*/
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<number> representing one child box.
*
* @param {...!Array<number>} 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([]);
});
});
+533
View File
@@ -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<number>}
*/
function uint32LE(n) {
return [
(n >>> 0) & 0xff,
(n >>> 8) & 0xff,
(n >>> 16) & 0xff,
(n >>> 24) & 0xff,
];
}
/**
* @param {number} n
* @return {!Array<number>}
*/
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<number>}
*/
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<string>} comments e.g. ['TITLE=Shaka', 'ARTIST=Hans']
* @return {!Array<number>}
*/
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 0127
* @param {boolean} isLast
* @param {!Array<number>} body
* @return {!Array<number>}
*/
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<!Array<number>>} 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<number>} imageData
* @return {!Array<number>}
*/
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<number>} 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([]);
});
});