diff --git a/build/types/complete b/build/types/complete index 5ae17fd65..0d42a0a19 100644 --- a/build/types/complete +++ b/build/types/complete @@ -6,6 +6,7 @@ +@fairplay +@networking +@manifests ++@metadata +@polyfill +@polyfillForUI +@queue diff --git a/build/types/core b/build/types/core index e1e6cb225..25a5a6d58 100644 --- a/build/types/core +++ b/build/types/core @@ -82,6 +82,9 @@ +../../lib/transmuxer/transmuxer_proxy.js +../../lib/transmuxer/transmuxer_utils.js ++../../lib/metadata/id3_utils.js ++../../lib/metadata/metadata.js + +../../lib/util/abortable_operation.js +../../lib/util/array_utils.js +../../lib/util/buffer_utils.js @@ -101,7 +104,6 @@ +../../lib/util/functional.js +../../lib/util/i_destroyable.js +../../lib/util/i_releasable.js -+../../lib/util/id3_utils.js +../../lib/util/iterables.js +../../lib/util/language_utils.js +../../lib/util/lazy.js diff --git a/build/types/metadata b/build/types/metadata new file mode 100644 index 000000000..dc002ef94 --- /dev/null +++ b/build/types/metadata @@ -0,0 +1,5 @@ +# Metadata + ++../../lib/metadata/id3v1_utils.js ++../../lib/metadata/ilst_utils.js ++../../lib/metadata/vorbis_utils.js diff --git a/demo/common/assets.js b/demo/common/assets.js index f9fadfa36..dbe609b08 100644 --- a/demo/common/assets.js +++ b/demo/common/assets.js @@ -744,6 +744,13 @@ shakaAssets.testAssets = [ .addFeature(shakaAssets.Feature.HLS) .addFeature(shakaAssets.Feature.LIVE) .addFeature(shakaAssets.Feature.MP4), + new ShakaDemoAssetInfo( + /* name= */ 'Raw MP3 with ID3 metadata (src=)', + /* iconUri= */ '', + /* manifestUri= */ 'https://raw.githubusercontent.com/shaka-project/shaka-player-history/refs/heads/main/sources/bach/John_Lewis_Grant_-_01_-_Bach_Prelude___Fugue_1.mp3', + /* source= */ shakaAssets.Source.SHAKA) + .addFeature(shakaAssets.Feature.AUDIO_ONLY) + .addFeature(shakaAssets.Feature.CONTAINERLESS), // End Shaka assets }}} // Axinom assets {{{ diff --git a/demo/config.js b/demo/config.js index 418b2ff98..b31d69cf7 100644 --- a/demo/config.js +++ b/demo/config.js @@ -725,7 +725,10 @@ shakaDemo.Config = class { 'streaming.returnToEndOfLiveWindowWhenOutside') .addBoolInput_( 'Stop fetching new segments on pause', - 'streaming.stopFetchingOnPause'); + 'streaming.stopFetchingOnPause') + .addBoolInput_( + 'Process metadata when using src=', + 'streaming.processSrcEqualMetadata'); this.addRetrySection_('streaming', 'Streaming Retry Parameters'); this.addLiveSyncSection_(); } diff --git a/externs/shaka/metadata_parser.js b/externs/shaka/metadata_parser.js new file mode 100644 index 000000000..385a0fa44 --- /dev/null +++ b/externs/shaka/metadata_parser.js @@ -0,0 +1,80 @@ +/*! @license + * Shaka Player + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + + +/** + * @externs + */ + + +/** + * Parses binary media data and returns the metadata frames it contains. + * + * A metadata parser is responsible for extracting tag information from a + * specific container/codec format (e.g. ID3v2, Vorbis comments, MP4 ILST). + * Parsers are registered with {@link shaka.metadata.Metadata} via + * {@link shaka.metadata.Metadata.registerParserByMime} and are looked up at + * runtime by MIME type. + * + *

Key naming convention

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