mirror of
https://github.com/shaka-project/shaka-player.git
synced 2026-06-13 15:46:46 +03:00
feat: Add metadata extraction support for src= playback (#10112)
Co-authored-by: absidue <48293849+absidue@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
e19c24d5c6
commit
87a7d86daf
@@ -6,6 +6,7 @@
|
||||
+@fairplay
|
||||
+@networking
|
||||
+@manifests
|
||||
+@metadata
|
||||
+@polyfill
|
||||
+@polyfillForUI
|
||||
+@queue
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Metadata
|
||||
|
||||
+../../lib/metadata/id3v1_utils.js
|
||||
+../../lib/metadata/ilst_utils.js
|
||||
+../../lib/metadata/vorbis_utils.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 {{{
|
||||
|
||||
+4
-1
@@ -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_();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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());
|
||||
@@ -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();
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 ===
|
||||
|
||||
@@ -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 ===
|
||||
|
||||
@@ -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 ===
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -257,6 +257,7 @@ shaka.util.PlayerConfiguration = class {
|
||||
returnToEndOfLiveWindowWhenOutside: false,
|
||||
stopFetchingOnPause: false,
|
||||
clampAppendWindowToDuration: false,
|
||||
processSrcEqualMetadata: true,
|
||||
};
|
||||
|
||||
const networking = {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 0–127
|
||||
* @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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user