From 95bbf72f426f9df899193f6083197a77191c0c4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Velad=20Galv=C3=A1n?= Date: Tue, 11 Oct 2022 18:29:53 +0200 Subject: [PATCH] feat: Parse ID3 metadata (#4409) Co-authored-by: Alvaro Velad --- build/types/core | 2 + externs/mux.js | 43 +- externs/shaka/ads.js | 2 +- externs/shaka/player.js | 47 +- lib/ads/server_side_ad_manager.js | 6 +- lib/media/media_source_engine.js | 41 +- lib/media/streaming_engine.js | 22 +- lib/media/transmuxer.js | 9 +- lib/player.js | 15 +- lib/util/id3_utils.js | 300 ++++++++++++ lib/util/ts_parser.js | 427 ++++++++++++++++++ test/media/media_source_engine_integration.js | 37 +- test/media/streaming_engine_integration.js | 1 + test/media/streaming_engine_unit.js | 50 ++ test/test/assets/id3-metadata.aac | Bin 0 -> 7131 bytes test/test/assets/id3-metadata.ts | Bin 0 -> 45308 bytes test/test/util/id3_generator.js | 100 ++++ test/test/util/stream_generator.js | 38 ++ test/test/util/test_scheme.js | 23 + test/util/id3_utils_unit.js | 116 +++++ test/util/ts_parser_unit.js | 51 +++ 21 files changed, 1257 insertions(+), 73 deletions(-) create mode 100644 lib/util/id3_utils.js create mode 100644 lib/util/ts_parser.js create mode 100644 test/test/assets/id3-metadata.aac create mode 100644 test/test/assets/id3-metadata.ts create mode 100644 test/test/util/id3_generator.js create mode 100644 test/util/id3_utils_unit.js create mode 100644 test/util/ts_parser_unit.js diff --git a/build/types/core b/build/types/core index 11a4331663..b3e9ae7546 100644 --- a/build/types/core +++ b/build/types/core @@ -76,6 +76,7 @@ +../../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 @@ -101,6 +102,7 @@ +../../lib/util/switch_history.js +../../lib/util/text_parser.js +../../lib/util/timer.js ++../../lib/util/ts_parser.js +../../lib/util/uint8array_utils.js +../../lib/util/xml_utils.js diff --git a/externs/mux.js b/externs/mux.js index a285a99487..49eecd8f81 100644 --- a/externs/mux.js +++ b/externs/mux.js @@ -81,15 +81,13 @@ muxjs.mp4.Transmuxer = class { * @typedef {{ * initSegment: !Uint8Array, * data: !Uint8Array, - * captions: !Array, - * metadata: !Array + * captions: !Array * }} * * @description Transmuxed data from mux.js. * @property {!Uint8Array} initSegment * @property {!Uint8Array} data * @property {!Array} captions - * @property {!Array} metadata * @exportDoc */ muxjs.mp4.Transmuxer.Segment; @@ -170,42 +168,3 @@ muxjs.mp4.ParsedClosedCaptions; */ muxjs.mp4.ClosedCaption; - -/** - * @typedef {{ - * cueTime: number, - * data: !Uint8Array, - * dispatchType: string, - * dts: number, - * frames: !Array., - * pts: number - * }} - * - * @description metadata parsed from mux.js. - * @property {number} cueTime - * @property {number} data - * @property {number} dispatchType - * @property {number} dts - * @property {string} frames - * @property {string} pts - */ -muxjs.mp4.Metadata; - - -/** - * @typedef {{ - * data: string, - * description: string, - * id: string, - * key: string, - * value: string - * }} - * - * @description metadata parsed from mux.js. - * @property {number} data - * @property {number} description - * @property {number} id - * @property {number} key - * @property {string} value - */ -muxjs.mp4.MetadataFrame; diff --git a/externs/shaka/ads.js b/externs/shaka/ads.js index af83b7c046..ef9803c703 100644 --- a/externs/shaka/ads.js +++ b/externs/shaka/ads.js @@ -120,7 +120,7 @@ shaka.extern.IAdManager = class extends EventTarget { onHlsTimedMetadata(metadata, timestampOffset) {} /** - * @param {shaka.extern.ID3Metadata} value + * @param {shaka.extern.MetadataFrame} value */ onCueMetadataChange(value) {} }; diff --git a/externs/shaka/player.js b/externs/shaka/player.js index 3414a40a8e..e657f13c21 100644 --- a/externs/shaka/player.js +++ b/externs/shaka/player.js @@ -437,20 +437,63 @@ shaka.extern.DrmSupportType; */ shaka.extern.SupportType; - /** - * @typedef {!Object.} + * @typedef {{ + * cueTime: ?number, + * data: !Uint8Array, + * frames: !Array., + * dts: ?number, + * pts: ?number + * }} * * @description * ID3 metadata in format defined by * https://id3.org/id3v2.3.0#Declared_ID3v2_frames * The content of the field. * + * @property {?number} cueTime + * @property {!Uint8Array} data + * @property {!Array.} frames + * @property {?number} dts + * @property {?number} pts + * * @exportDoc */ shaka.extern.ID3Metadata; +/** + * @typedef {{ + * type: string, + * size: number, + * data: Uint8Array + * }} + * + * @description metadata raw frame. + * @property {string} type + * @property {number} size + * @property {Uint8Array} data + * @exportDoc + */ +shaka.extern.MetadataRawFrame; + + +/** + * @typedef {{ + * key: string, + * data: (ArrayBuffer|string), + * description: string + * }} + * + * @description metadata frame parsed. + * @property {string} key + * @property {ArrayBuffer|string} data + * @property {string} description + * @exportDoc + */ +shaka.extern.MetadataFrame; + + /** * @typedef {{ * schemeIdUri: string, diff --git a/lib/ads/server_side_ad_manager.js b/lib/ads/server_side_ad_manager.js index de8bbd16d8..1cc2ce6c43 100644 --- a/lib/ads/server_side_ad_manager.js +++ b/lib/ads/server_side_ad_manager.js @@ -237,7 +237,7 @@ shaka.ads.ServerSideAdManager = class { } /** - * @param {shaka.extern.ID3Metadata} value + * @param {shaka.extern.MetadataFrame} value */ onCueMetadataChange(value) { // Native HLS over Safari/iOS/iPadOS @@ -246,9 +246,9 @@ shaka.ads.ServerSideAdManager = class { // done through timed metadata. Timed metadata is carried as part of the // DAI stream content and carries ad break timing information used by the // SDK to track ad breaks. - if (value['key'] && value['data']) { + if (value.key && value.data) { const metadata = {}; - metadata[value['key']] = value['data']; + metadata[value.key] = value.data; this.streamManager_.onTimedMetadata(metadata); } } diff --git a/lib/media/media_source_engine.js b/lib/media/media_source_engine.js index f61e7b3edc..e7f2544b7b 100644 --- a/lib/media/media_source_engine.js +++ b/lib/media/media_source_engine.js @@ -14,15 +14,18 @@ goog.require('shaka.media.SegmentReference'); goog.require('shaka.media.TimeRangesUtils'); goog.require('shaka.media.Transmuxer'); goog.require('shaka.text.TextEngine'); +goog.require('shaka.util.BufferUtils'); goog.require('shaka.util.Destroyer'); goog.require('shaka.util.Error'); goog.require('shaka.util.EventManager'); goog.require('shaka.util.Functional'); goog.require('shaka.util.IDestroyable'); +goog.require('shaka.util.Id3Utils'); goog.require('shaka.util.ManifestParserUtils'); goog.require('shaka.util.MimeUtils'); goog.require('shaka.util.Platform'); goog.require('shaka.util.PublicPromise'); +goog.require('shaka.util.TsParser'); goog.require('shaka.lcevc.Dil'); @@ -568,6 +571,35 @@ shaka.media.MediaSourceEngine = class { return; } + const uint8ArrayData = shaka.util.BufferUtils.toUint8(data); + if (shaka.util.TsParser.probe(uint8ArrayData)) { + const metadata = new shaka.util.TsParser().parse(uint8ArrayData) + .getMetadata(); + if (metadata.length) { + const timestampOffset = + this.sourceBuffers_[contentType].timestampOffset; + this.onMetadata_(metadata, timestampOffset, + reference ? reference.endTime : null); + } + } else { + const containerType = shaka.util.MimeUtils.getContainerType( + this.sourceBufferTypes_[contentType]); + if (containerType === 'aac') { + const frames = shaka.util.Id3Utils.getID3Frames(uint8ArrayData); + if (frames.length && reference) { + /** @private {shaka.extern.ID3Metadata} */ + const metadata = { + cueTime: reference.startTime, + data: uint8ArrayData, + frames: frames, + dts: reference.startTime, + pts: reference.startTime, + }; + this.onMetadata_([metadata], /* offset= */ 0, reference.endTime); + } + } + } + if (this.transmuxers_[contentType]) { const transmuxedData = await this.transmuxers_[contentType].transmux(data); @@ -576,15 +608,6 @@ shaka.media.MediaSourceEngine = class { if (!this.textEngine_) { this.reinitText('text/vtt', this.sequenceMode_); } - - if (transmuxedData.metadata) { - const timestampOffset = - this.sourceBuffers_[contentType].timestampOffset; - this.onMetadata_( - transmuxedData.metadata, - timestampOffset, - reference ? reference.endTime : null); - } // This doesn't work for native TS support (ex. Edge/Chromecast), // since no transmuxing is needed for native TS. if (transmuxedData.captions && transmuxedData.captions.length) { diff --git a/lib/media/streaming_engine.js b/lib/media/streaming_engine.js index bbea75c032..c74e2920c3 100644 --- a/lib/media/streaming_engine.js +++ b/lib/media/streaming_engine.js @@ -24,6 +24,7 @@ goog.require('shaka.util.Destroyer'); goog.require('shaka.util.Error'); goog.require('shaka.util.FakeEvent'); goog.require('shaka.util.IDestroyable'); +goog.require('shaka.util.Id3Utils'); goog.require('shaka.util.ManifestParserUtils'); goog.require('shaka.util.MimeUtils'); goog.require('shaka.util.Mp4Parser'); @@ -1814,6 +1815,21 @@ shaka.media.StreamingEngine = class { // A special scheme in DASH used to signal manifest updates. if (schemeId == 'urn:mpeg:dash:event:2012') { this.playerInterface_.onManifestUpdate(); + } else if (schemeId == 'https://aomedia.org/emsg/ID3') { + // See https://aomediacodec.github.io/id3-emsg/ + const frames = shaka.util.Id3Utils.getID3Frames(messageData); + if (frames.length && reference) { + /** @private {shaka.extern.ID3Metadata} */ + const metadata = { + cueTime: reference.startTime, + data: messageData, + frames: frames, + dts: reference.startTime, + pts: reference.startTime, + }; + this.playerInterface_.onMetadata( + [metadata], /* offset= */ 0, reference.endTime); + } } else { /** @type {shaka.extern.EmsgInfo} */ const emsg = { @@ -2191,7 +2207,8 @@ shaka.media.StreamingEngine = class { * !shaka.util.ManifestParserUtils.ContentType), * onInitSegmentAppended: function(!number,!shaka.media.InitSegmentReference), * beforeAppendSegment: function( - * shaka.util.ManifestParserUtils.ContentType,!BufferSource):Promise + * shaka.util.ManifestParserUtils.ContentType,!BufferSource):Promise, + * onMetadata: !function(!Array., number, ?number) * }} * * @property {function():number} getPresentationTime @@ -2224,6 +2241,9 @@ shaka.media.StreamingEngine = class { * @property {!function(shaka.util.ManifestParserUtils.ContentType, * !BufferSource):Promise} beforeAppendSegment * A function called just before appending to the source buffer. + * @property + * {!function(!Array., number, ?number)} onMetadata + * Called when an ID3 is found in a EMSG. */ shaka.media.StreamingEngine.PlayerInterface; diff --git a/lib/media/transmuxer.js b/lib/media/transmuxer.js index 5119c2e255..b1f144112c 100644 --- a/lib/media/transmuxer.js +++ b/lib/media/transmuxer.js @@ -42,9 +42,6 @@ shaka.media.Transmuxer = class { /** @private {!Array.} */ this.captions_ = []; - /** @private {!Array.} */ - this.metadata_ = []; - /** @private {boolean} */ this.isTransmuxing_ = false; @@ -152,8 +149,7 @@ shaka.media.Transmuxer = class { * Transmux from Transport stream to MP4, using the mux.js library. * @param {BufferSource} data * @return {!Promise.<{data: !Uint8Array, - * captions: !Array., - * metadata: !Array.}>} + * captions: !Array.}>} */ transmux(data) { goog.asserts.assert(!this.isTransmuxing_, @@ -162,7 +158,6 @@ shaka.media.Transmuxer = class { this.transmuxPromise_ = new shaka.util.PublicPromise(); this.transmuxedData_ = []; this.captions_ = []; - this.metadata_ = []; const dataArray = shaka.util.BufferUtils.toUint8(data); this.muxTransmuxer_.push(dataArray); @@ -194,7 +189,6 @@ shaka.media.Transmuxer = class { */ onTransmuxed_(segment) { this.captions_ = segment.captions; - this.metadata_ = segment.metadata; this.transmuxedData_.push( shaka.util.Uint8ArrayUtils.concat(segment.initSegment, segment.data)); } @@ -209,7 +203,6 @@ shaka.media.Transmuxer = class { const output = { data: shaka.util.Uint8ArrayUtils.concat(...this.transmuxedData_), captions: this.captions_, - metadata: this.metadata_, }; this.transmuxPromise_.resolve(output); diff --git a/lib/player.js b/lib/player.js index 8ee87352fe..2d79ad13a0 100644 --- a/lib/player.js +++ b/lib/player.js @@ -328,7 +328,7 @@ goog.requireType('shaka.routing.Payload'); * the cue applies. * @property {string} metadataType * Type of metadata. Eg: org.id3 or org.mp4ra - * @property {shaka.extern.ID3Metadata} payload + * @property {shaka.extern.MetadataFrame} payload * The metadata itself * @exportDoc */ @@ -2706,11 +2706,11 @@ shaka.Player = class extends shaka.util.FakeEventTarget { */ processTimedMetadataMediaSrc_(metadata, offset, segmentEndTime) { for (const sample of metadata) { - if (sample['data'] && sample['cueTime'] && sample['frames']) { - const start = sample['cueTime'] + offset; + if (sample.data && sample.cueTime && sample.frames) { + const start = sample.cueTime + offset; const end = segmentEndTime; - const metadataType = 'ID3'; - for (const frame of sample['frames']) { + const metadataType = 'org.id3'; + for (const frame of sample.frames) { const payload = frame; this.dispatchMetadataEvent_(start, end, metadataType, payload); } @@ -2729,7 +2729,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget { * @param {number} startTime * @param {?number} endTime * @param {string} metadataType - * @param {shaka.extern.ID3Metadata} payload + * @param {shaka.extern.MetadataFrame} payload * @private */ dispatchMetadataEvent_(startTime, endTime, metadataType, payload) { @@ -3101,6 +3101,9 @@ shaka.Player = class extends shaka.util.FakeEventTarget { beforeAppendSegment: (contentType, segment) => { return this.drmEngine_.parseInbandPssh(contentType, segment); }, + onMetadata: (metadata, offset, endTime) => { + this.processTimedMetadataMediaSrc_(metadata, offset, endTime); + }, }; return new shaka.media.StreamingEngine(this.manifest_, playerInterface); diff --git a/lib/util/id3_utils.js b/lib/util/id3_utils.js new file mode 100644 index 0000000000..86824c2f58 --- /dev/null +++ b/lib/util/id3_utils.js @@ -0,0 +1,300 @@ +/*! @license + * Shaka Player + * Copyright 2022 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.provide('shaka.util.Id3Utils'); + +goog.require('shaka.log'); +goog.require('shaka.util.BufferUtils'); +goog.require('shaka.util.StringUtils'); + + +/** + * @summary A set of Id3Utils utility functions. + * @export + */ +shaka.util.Id3Utils = class { + /** + * @param {Uint8Array} data + * @param {number} offset + * @return {boolean} + * @private + */ + static isHeader_(data, offset) { + /* + * http://id3.org/id3v2.3.0 + * [0] = 'I' + * [1] = 'D' + * [2] = '3' + * [3,4] = {Version} + * [5] = {Flags} + * [6-9] = {ID3 Size} + * + * An ID3v2 tag can be detected with the following pattern: + * $49 44 33 yy yy xx zz zz zz zz + * Where yy is less than $FF, xx is the 'flags' byte and zz is less than $80 + */ + if (offset + 10 <= data.length) { + // look for 'ID3' identifier + if (data[offset] === 0x49 && + data[offset + 1] === 0x44 && + data[offset + 2] === 0x33) { + // check version is within range + if (data[offset + 3] < 0xff && data[offset + 4] < 0xff) { + // check size is within range + if (data[offset + 6] < 0x80 && + data[offset + 7] < 0x80 && + data[offset + 8] < 0x80 && + data[offset + 9] < 0x80) { + return true; + } + } + } + } + + return false; + } + + /** + * @param {Uint8Array} data + * @param {number} offset + * @return {boolean} + * @private + */ + static isFooter_(data, offset) { + /* + * The footer is a copy of the header, but with a different identifier + */ + if (offset + 10 <= data.length) { + // look for '3DI' identifier + if (data[offset] === 0x33 && + data[offset + 1] === 0x44 && + data[offset + 2] === 0x49) { + // check version is within range + if (data[offset + 3] < 0xff && data[offset + 4] < 0xff) { + // check size is within range + if (data[offset + 6] < 0x80 && + data[offset + 7] < 0x80 && + data[offset + 8] < 0x80 && + data[offset + 9] < 0x80) { + return true; + } + } + } + } + + return false; + } + + /** + * @param {Uint8Array} data + * @param {number} offset + * @return {number} + * @private + */ + static readSize_(data, offset) { + let size = 0; + size = (data[offset] & 0x7f) << 21; + size |= (data[offset + 1] & 0x7f) << 14; + size |= (data[offset + 2] & 0x7f) << 7; + size |= data[offset + 3] & 0x7f; + return size; + } + + /** + * @param {Uint8Array} data + * @return {shaka.extern.MetadataRawFrame} + * @private + */ + static getFrameData_(data) { + /* + * Frame ID $xx xx xx xx (four characters) + * Size $xx xx xx xx + * Flags $xx xx + */ + const type = String.fromCharCode(data[0], data[1], data[2], data[3]); + const size = shaka.util.Id3Utils.readSize_(data, 4); + + // skip frame id, size, and flags + const offset = 10; + + return { + type, + size, + data: data.subarray(offset, offset + size), + }; + } + + /** + * @param {shaka.extern.MetadataRawFrame} frame + * @return {?shaka.extern.MetadataFrame} + * @private + */ + static decodeFrame_(frame) { + const BufferUtils = shaka.util.BufferUtils; + const StringUtils = shaka.util.StringUtils; + + const metadataFrame = { + key: frame.type, + description: '', + data: '', + }; + + if (frame.type === 'TXXX') { + /* + * Format: + * [0] = {Text Encoding} + * [1-?] = {Description}\0{Value} + */ + if (frame.size < 2) { + return null; + } + if (frame.data[0] !== shaka.util.Id3Utils.UTF8_encoding) { + shaka.log.warning('Ignore frame with unrecognized character ' + + 'encoding'); + return null; + } + const descriptionEndIndex = frame.data.subarray(1).indexOf(0); + + if (descriptionEndIndex === -1) { + return null; + } + const description = StringUtils.fromUTF8( + BufferUtils.toUint8(frame.data, 1, descriptionEndIndex)); + const data = StringUtils.fromUTF8( + BufferUtils.toUint8(frame.data, 2 + descriptionEndIndex)) + .replace(/\0*$/, ''); + + metadataFrame.description = description; + metadataFrame.data = data; + return metadataFrame; + } else if (frame.type === 'WXXX') { + /* + * Format: + * [0] = {Text Encoding} + * [1-?] = {Description}\0{URL} + */ + if (frame.size < 2) { + return null; + } + if (frame.data[0] !== shaka.util.Id3Utils.UTF8_encoding) { + shaka.log.warning('Ignore frame with unrecognized character ' + + 'encoding'); + return null; + } + const descriptionEndIndex = frame.data.subarray(1).indexOf(0); + + if (descriptionEndIndex === -1) { + return null; + } + const description = StringUtils.fromUTF8( + BufferUtils.toUint8(frame.data, 1, descriptionEndIndex)); + const data = StringUtils.fromUTF8( + BufferUtils.toUint8(frame.data, 2 + descriptionEndIndex)) + .replace(/\0*$/, ''); + + metadataFrame.description = description; + metadataFrame.data = data; + return metadataFrame; + } else if (frame.type === 'PRIV') { + /* + * Format: \0 + */ + if (frame.size < 2) { + return null; + } + const textEndIndex = frame.data.indexOf(0); + if (textEndIndex === -1) { + return null; + } + const text = StringUtils.fromUTF8( + BufferUtils.toUint8(frame.data, 0, textEndIndex)); + const data = BufferUtils.toArrayBuffer( + frame.data.subarray(text.length + 1)); + metadataFrame.description = text; + metadataFrame.data = data; + return metadataFrame; + } else if (frame.type[0] === 'T') { + /* + * Format: + * [0] = {Text Encoding} + * [1-?] = {Value} + */ + if (frame.size < 2) { + return null; + } + if (frame.data[0] !== shaka.util.Id3Utils.UTF8_encoding) { + shaka.log.warning('Ignore frame with unrecognized character ' + + 'encoding'); + return null; + } + const text = StringUtils.fromUTF8(frame.data.subarray(1)) + .replace(/\0*$/, ''); + metadataFrame.data = text; + return metadataFrame; + } else if (frame.type[0] === 'W') { + /* + * Format: + * [0-?] = {URL} + */ + const url = StringUtils.fromUTF8(frame.data) + .replace(/\0*$/, ''); + metadataFrame.data = url; + return metadataFrame; + } else if (frame.data) { + shaka.log.warning('Unrecognized ID3 frame type:', frame.type); + metadataFrame.data = BufferUtils.toArrayBuffer(frame.data); + return metadataFrame; + } + + return null; + } + + /** + * Returns an array of ID3 frames found in all the ID3 tags in the id3Data + * @param {Uint8Array} id3Data - The ID3 data containing one or more ID3 tags + * @return {!Array.} + * @export + */ + static getID3Frames(id3Data) { + const Id3Utils = shaka.util.Id3Utils; + let offset = 0; + const frames = []; + while (Id3Utils.isHeader_(id3Data, offset)) { + const size = Id3Utils.readSize_(id3Data, offset + 6); + + if ((id3Data[offset + 5] >> 6) & 1) { + // skip extended header + offset += 10; + } + // skip past ID3 header + offset += 10; + + const end = offset + size; + // loop through frames in the ID3 tag + while (offset + 10 < end) { + const frameData = Id3Utils.getFrameData_(id3Data.subarray(offset)); + const frame = Id3Utils.decodeFrame_(frameData); + if (frame) { + frames.push(frame); + } + + // skip frame header and frame data + offset += frameData.size + 10; + } + + if (Id3Utils.isFooter_(id3Data, offset)) { + offset += 10; + } + } + return frames; + } +}; + +/** + * UTF8 encoding byte + * @const {number} + */ +shaka.util.Id3Utils.UTF8_encoding = 0x03; diff --git a/lib/util/ts_parser.js b/lib/util/ts_parser.js new file mode 100644 index 0000000000..8f77dc7ad8 --- /dev/null +++ b/lib/util/ts_parser.js @@ -0,0 +1,427 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.provide('shaka.util.TsParser'); + +goog.require('shaka.log'); +goog.require('shaka.util.Id3Utils'); + + +/** + * @see https://en.wikipedia.org/wiki/MPEG_transport_stream + * @export + */ +shaka.util.TsParser = class { + /** */ + constructor() { + /** @private {?number} */ + this.pmtId_ = null; + + /** @private {boolean} */ + this.pmtParsed_ = false; + + /** @private {?number} */ + this.videoPid_ = null; + + /** @private {?string} */ + this.videoCodec_ = null; + + /** @private {!Array.} */ + this.videoData_ = []; + + /** @private {?number} */ + this.audioPid_ = null; + + /** @private {?string} */ + this.audioCodec_ = null; + + /** @private {!Array.} */ + this.audioData_ = []; + + /** @private {?number} */ + this.id3Pid_ = null; + + /** @private {!Array.} */ + this.id3Data_ = []; + } + + /** + * Parse the given data + * + * @param {Uint8Array} data + * @return {!shaka.util.TsParser} + */ + parse(data) { + const packetLength = shaka.util.TsParser.PacketLength_; + + // A TS fragment should contain at least 3 TS packets, a PAT, a PMT, and + // one PID. + if (data.length < 3 * packetLength) { + return this; + } + const syncOffset = Math.max(0, shaka.util.TsParser.syncOffset(data)); + + const length = data.length - (data.length + syncOffset) % packetLength; + + let unknownPIDs = false; + + // loop through TS packets + for (let start = syncOffset; start < length; start += packetLength) { + if (data[start] === 0x47) { + const payloadUnitStartIndicator = !!(data[start + 1] & 0x40); + // pid is a 13-bit field starting at the last 5 bits of TS[1] + const pid = ((data[start + 1] & 0x1f) << 8) + data[start + 2]; + const adaptationFieldControl = (data[start + 3] & 0x30) >> 4; + + // if an adaption field is present, its length is specified by the + // fifth byte of the TS packet header. + let offset; + if (adaptationFieldControl > 1) { + offset = start + 5 + data[start + 4]; + // continue if there is only adaptation field + if (offset === start + packetLength) { + continue; + } + } else { + offset = start + 4; + } + switch (pid) { + case 0: + if (payloadUnitStartIndicator) { + offset += data[offset] + 1; + } + + this.pmtId_ = this.getPmtId_(data, offset); + break; + case 17: + case 0x1fff: + break; + case this.pmtId_: { + if (payloadUnitStartIndicator) { + offset += data[offset] + 1; + } + + const parsedPIDs = this.parsePMT(data, offset); + + // only update track id if track PID found while parsing PMT + // this is to avoid resetting the PID to -1 in case + // track PID transiently disappears from the stream + // this could happen in case of transient missing audio samples + // for example + // NOTE this is only the PID of the track as found in TS, + // but we are not using this for MP4 track IDs. + if (this.videoPid_ == null) { + this.videoPid_ = parsedPIDs.video; + this.videoCodec_ = parsedPIDs.videoCodec; + } + if (this.audioPid_ == null) { + this.audioPid_ = parsedPIDs.audio; + this.audioCodec_ = parsedPIDs.audioCodec; + } + if (this.id3Pid_ == null) { + this.id3Pid_ = parsedPIDs.id3; + } + + if (unknownPIDs && !this.pmtParsed_) { + shaka.log.debug('reparse from beginning'); + unknownPIDs = false; + // we set it to -188, the += 188 in the for loop will reset + // start to 0 + start = syncOffset - packetLength; + } + this.pmtParsed_ = true; + break; + } + case this.videoPid_: + this.videoData_.push(data.subarray(offset, start + packetLength)); + break; + case this.audioPid_: + this.audioData_.push(data.subarray(offset, start + packetLength)); + break; + case this.id3Pid_: + this.id3Data_.push(data.subarray(offset, start + packetLength)); + break; + default: + unknownPIDs = true; + break; + } + } else { + shaka.log.warning('Found TS packet that do not start with 0x47'); + } + } + return this; + } + + /** + * Get the PMT ID from the PAT + * + * @param {Uint8Array} data + * @param {number} offset + * @return {number} + * @private + */ + getPmtId_(data, offset) { + // skip the PSI header and parse the first PMT entry + return ((data[offset + 10] & 0x1f) << 8) | data[offset + 11]; + } + + /** + * Parse PMT + * + * @param {Uint8Array} data + * @param {number} offset + * @return {!shaka.util.TsParser.PMT} + */ + parsePMT(data, offset) { + const result = { + audio: -1, + video: -1, + id3: -1, + audioCodec: 'aac', + videoCodec: 'avc', + }; + const sectionLength = ((data[offset + 1] & 0x0f) << 8) | data[offset + 2]; + const tableEnd = offset + 3 + sectionLength - 4; + // to determine where the table is, we have to figure out how + // long the program info descriptors are + const programInfoLength = + ((data[offset + 10] & 0x0f) << 8) | data[offset + 11]; + // advance the offset to the first entry in the mapping table + offset += 12 + programInfoLength; + while (offset < tableEnd) { + const pid = ((data[offset + 1] & 0x1f) << 8) | data[offset + 2]; + switch (data[offset]) { + // SAMPLE-AES AAC + case 0xcf: + break; + // ISO/IEC 13818-7 ADTS AAC (MPEG-2 lower bit-rate audio) + case 0x0f: + if (result.audio === -1) { + result.audio = pid; + } + break; + // Packetized metadata (ID3) + case 0x15: + if (result.id3 === -1) { + result.id3 = pid; + } + break; + // SAMPLE-AES AVC + case 0xdb: + break; + // ITU-T Rec. H.264 and ISO/IEC 14496-10 (lower bit-rate video) + case 0x1b: + if (result.video === -1) { + result.video = pid; + } + break; + // ISO/IEC 11172-3 (MPEG-1 audio) + // or ISO/IEC 13818-3 (MPEG-2 halved sample rate audio) + case 0x03: + case 0x04: + if (result.audio === -1) { + result.audio = pid; + result.audioCodec = 'mp3'; + } + break; + // HEVC + case 0x24: + if (result.video === -1) { + result.video = pid; + result.videoCodec = 'hvc'; + } + break; + default: + // shaka.log.warning('Unknown stream type:', data[offset]); + break; + } + // move to the next table entry + // skip past the elementary stream descriptors, if present + offset += (((data[offset + 3] & 0x0f) << 8) | data[offset + 4]) + 5; + } + return result; + } + + /** + * Parse PES + * + * @param {Uint8Array} data + * @return {?shaka.util.TsParser.PES} + */ + parsePES(data) { + const startPrefix = (data[0] << 16) | (data[1] << 8) | data[2]; + // In certain live streams, the start of a TS fragment has ts packets + // that are frame data that is continuing from the previous fragment. This + // is to check that the pes data is the start of a new pes data + if (startPrefix !== 1) { + return null; + } + /** @type {shaka.util.TsParser.PES} */ + const pes = { + data: new Uint8Array(0), + // get the packet length, this will be 0 for video + packetLength: 6 + ((data[4] << 8) | data[5]), + pts: null, + dts: null, + }; + + // PES packets may be annotated with a PTS value, or a PTS value + // and a DTS value. Determine what combination of values is + // available to work with. + const ptsDtsFlags = data[7]; + + // PTS and DTS are normally stored as a 33-bit number. Javascript + // performs all bitwise operations on 32-bit integers but javascript + // supports a much greater range (52-bits) of integer using standard + // mathematical operations. + // We construct a 31-bit value using bitwise operators over the 31 + // most significant bits and then multiply by 4 (equal to a left-shift + // of 2) before we add the final 2 least significant bits of the + // timestamp (equal to an OR.) + if (ptsDtsFlags & 0xC0) { + // the PTS and DTS are not written out directly. For information + // on how they are encoded, see + // http://dvd.sourceforge.net/dvdinfo/pes-hdr.html + pes.pts = + (data[9] & 0x0e) * 536870912 + // 1 << 29 + (data[10] & 0xff) * 4194304 + // 1 << 22 + (data[11] & 0xfe) * 16384 + // 1 << 14 + (data[12] & 0xff) * 128 + // 1 << 7 + (data[13] & 0xfe) / 2; + + pes.dts = pes.pts; + if (ptsDtsFlags & 0x40) { + pes.dts = + (data[14] & 0x0e) * 536870912 + // 1 << 29 + (data[15] & 0xff) * 4194304 + // 1 << 22 + (data[16] & 0xfe) * 16384 + // 1 << 14 + (data[17] & 0xff) * 128 + // 1 << 7 + (data[18] & 0xfe) / 2; + } + } + // the data section starts immediately after the PES header. + // pes_header_data_length specifies the number of header bytes + // that follow the last byte of the field. + pes.data = data.subarray(9 + data[8]); + + return pes; + } + + /** + * Return the ID3 metadata + * + * @return {!Array.} + */ + getMetadata() { + const metadata = []; + for (const data of this.id3Data_) { + const pes = this.parsePES(data); + if (pes) { + metadata.push({ + cueTime: pes.pts ? pes.pts / 90000 : null, + data: pes.data, + frames: shaka.util.Id3Utils.getID3Frames(pes.data), + dts: pes.dts, + pts: pes.pts, + }); + } + } + return metadata; + } + + /** + * Check if the passed data corresponds to an MPEG2-TS + * + * @param {Uint8Array} data + * @return {boolean} + */ + static probe(data) { + const syncOffset = shaka.util.TsParser.syncOffset(data); + if (syncOffset < 0) { + return false; + } else { + if (syncOffset > 0) { + shaka.log.warning('MPEG2-TS detected but first sync word found @ ' + + 'offset ' + syncOffset + ', junk ahead ?'); + } + return true; + } + } + + /** + * Returns the synchronization offset + * + * @param {Uint8Array} data + * @return {number} + */ + static syncOffset(data) { + const packetLength = shaka.util.TsParser.PacketLength_; + // scan 1000 first bytes + const scanwindow = Math.min(1000, data.length - 3 * packetLength); + let i = 0; + while (i < scanwindow) { + // a TS fragment should contain at least 3 TS packets, a PAT, a PMT, and + // one PID, each starting with 0x47 + if (data[i] === 0x47 && + data[i + packetLength] === 0x47 && + data[i + 2 * packetLength] === 0x47) { + return i; + } else { + i++; + } + } + return -1; + } +}; + + +/** + * @const {number} + * @private + */ +shaka.util.TsParser.PacketLength_ = 188; + + +/** + * @typedef {{ + * audio: number, + * video: number, + * id3: number, + * audioCodec: string, + * videoCodec: string + * }} + * + * @summary PMT. + * @property {number} audio + * Audio PID + * @property {number} video + * Video PID + * @property {number} id3 + * ID3 PID + * @property {string} audioCodec + * Audio codec + * @property {string} videoCodec + * Video codec + */ +shaka.util.TsParser.PMT; + + +/** + * @typedef {{ + * data: Uint8Array, + * packetLength: number, + * pts: ?number, + * dts: ?number + * }} + * + * @summary PES. + * @property {Uint8Array} data + * @property {number} packetLength + * @property {?number} pts + * @property {?number} dts + */ +shaka.util.TsParser.PES; + diff --git a/test/media/media_source_engine_integration.js b/test/media/media_source_engine_integration.js index b5c78b4020..fa893c38e0 100644 --- a/test/media/media_source_engine_integration.js +++ b/test/media/media_source_engine_integration.js @@ -26,6 +26,9 @@ describe('MediaSourceEngine', () => { */ let textDisplayer; + /** @type {!jasmine.Spy} */ + let onMetadata; + beforeAll(() => { video = shaka.test.UiUtils.createVideoElement(); document.body.appendChild(video); @@ -37,10 +40,13 @@ describe('MediaSourceEngine', () => { textDisplayer = new shaka.test.FakeTextDisplayer(); + onMetadata = jasmine.createSpy('onMetadata'); + mediaSourceEngine = new shaka.media.MediaSourceEngine( video, new shaka.media.ClosedCaptionParser(), - textDisplayer); + textDisplayer, + shaka.test.Util.spyFunc(onMetadata)); const config = shaka.util.PlayerConfiguration.createDefault().mediaSource; mediaSourceEngine.configure(config); @@ -434,4 +440,33 @@ describe('MediaSourceEngine', () => { expect(textDisplayer.appendSpy).toHaveBeenCalled(); }); + + it('extracts ID3 metadata from TS', async () => { + metadata = shaka.test.TestScheme.DATA['id3-metadata_ts']; + generators = shaka.test.TestScheme.GENERATORS['id3-metadata_ts']; + + const audioType = ContentType.AUDIO; + const initObject = new Map(); + initObject.set(audioType, getFakeStream(metadata.audio)); + await mediaSourceEngine.init(initObject, /* forceTransmuxTS= */ false); + await append(ContentType.AUDIO, 0); + + expect(onMetadata).toHaveBeenCalled(); + }); + + it('extracts ID3 metadata from AAC', async () => { + if (!MediaSource.isTypeSupported('audio/aac')) { + return; + } + metadata = shaka.test.TestScheme.DATA['id3-metadata_aac']; + generators = shaka.test.TestScheme.GENERATORS['id3-metadata_aac']; + + const audioType = ContentType.AUDIO; + const initObject = new Map(); + initObject.set(audioType, getFakeStream(metadata.audio)); + await mediaSourceEngine.init(initObject, /* forceTransmuxTS= */ false); + await append(ContentType.AUDIO, 0); + + expect(onMetadata).toHaveBeenCalled(); + }); }); diff --git a/test/media/streaming_engine_integration.js b/test/media/streaming_engine_integration.js index 5248707175..cb2ce06c8f 100644 --- a/test/media/streaming_engine_integration.js +++ b/test/media/streaming_engine_integration.js @@ -251,6 +251,7 @@ describe('StreamingEngine', () => { onSegmentAppended: () => playhead.notifyOfBufferingChange(), onInitSegmentAppended: () => {}, beforeAppendSegment: () => Promise.resolve(), + onMetadata: () => {}, }; streamingEngine = new shaka.media.StreamingEngine( /** @type {shaka.extern.Manifest} */(manifest), playerInterface); diff --git a/test/media/streaming_engine_unit.js b/test/media/streaming_engine_unit.js index 0d6b1dd694..d26a247e60 100644 --- a/test/media/streaming_engine_unit.js +++ b/test/media/streaming_engine_unit.js @@ -74,6 +74,8 @@ describe('StreamingEngine', () => { let streamingEngine; /** @type {!jasmine.Spy} */ let beforeAppendSegment; + /** @type {!jasmine.Spy} */ + let onMetadata; /** @type {function(function(), number)} */ let realSetTimeout; @@ -432,6 +434,7 @@ describe('StreamingEngine', () => { onManifestUpdate = jasmine.createSpy('onManifestUpdate'); onSegmentAppended = jasmine.createSpy('onSegmentAppended'); beforeAppendSegment = jasmine.createSpy('beforeAppendSegment'); + onMetadata = jasmine.createSpy('onMetadata'); getBandwidthEstimate = jasmine.createSpy('getBandwidthEstimate'); getBandwidthEstimate.and.returnValue(1e3); @@ -461,6 +464,7 @@ describe('StreamingEngine', () => { onSegmentAppended: Util.spyFunc(onSegmentAppended), onInitSegmentAppended: () => {}, beforeAppendSegment: Util.spyFunc(beforeAppendSegment), + onMetadata: Util.spyFunc(onMetadata), }; streamingEngine = new shaka.media.StreamingEngine( /** @type {shaka.extern.Manifest} */(manifest), playerInterface); @@ -2848,6 +2852,52 @@ describe('StreamingEngine', () => { expect(onEvent).not.toHaveBeenCalled(); expect(onManifestUpdate).toHaveBeenCalled(); }); + + it('triggers metadata event', async () => { + // This is an 'emsg' box that contains a scheme of + // https://aomedia.org/emsg/ID to indicate a ID3 metadata. + segmentData[ContentType.VIDEO].segments[0] = + Uint8ArrayUtils.fromHex(( + // 105 bytes emsg box v0, flags 0 + '00 00 00 69 65 6d 73 67 00 00 00 00' + + + // scheme id uri (13 bytes) 'https://aomedia.org/emsg/ID3' + '68 74 74 70 73 3a 2f 2f 61 6f 6d 65 64 69 61 2e' + + '6f 72 67 2f 65 6d 73 67 2f 49 44 33 00' + + + // value (1 byte) '' + '00' + + + // timescale (4 bytes) 49 + '00 00 00 31' + + + // presentation time delta (4 bytes) 8 + '00 00 00 08' + + + // event duration (4 bytes) 255 + '00 00 00 ff' + + + // id (4 bytes) 51 + '00 00 00 33' + + + // message data (47 bytes) + '49 44 33 03 00 40 00 00 00 1b 00 00 00 06 00 00' + + '00 00 00 02 54 58 58 58 00 00 00 07 e0 00 03 00' + + '53 68 61 6b 61 33 44 49 03 00 40 00 00 00 1b' + ).replaceAll(' ', '')); + + videoStream.emsgSchemeIdUris = ['https://aomedia.org/emsg/ID3']; + + // Here we go! + streamingEngine.switchVariant(variant); + streamingEngine.switchTextStream(textStream); + await streamingEngine.start(); + playing = true; + await runTest(); + + expect(onEvent).not.toHaveBeenCalled(); + expect(onMetadata).toHaveBeenCalled(); + }); }); describe('embedded emsg boxes with non zero timestamps', () => { diff --git a/test/test/assets/id3-metadata.aac b/test/test/assets/id3-metadata.aac new file mode 100644 index 0000000000000000000000000000000000000000..220a47823b06abf80a586958723522c48fdc3b7a GIT binary patch literal 7131 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 bw#Cno{Ou%}CxFoOEV-Op4Qc&%bN7D$QP$+ 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
1?pM)bc+* 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⁢01+qsoCi_0r*G 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 z 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 literal 0 HcmV?d00001 diff --git a/test/test/util/id3_generator.js b/test/test/util/id3_generator.js new file mode 100644 index 0000000000..2775897609 --- /dev/null +++ b/test/test/util/id3_generator.js @@ -0,0 +1,100 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @summary + * A helper class used to generate ID3 metadata. + */ +shaka.test.Id3Generator = class { + /** + * Generate an ID3 from a frames. + * + * @param {!Uint8Array} frames + * @param {boolean=} extendedHeader + * @return {!Uint8Array} + */ + static generateId3(frames, extendedHeader = false) { + const Id3Generator = shaka.test.Id3Generator; + const Uint8ArrayUtils = shaka.util.Uint8ArrayUtils; + let result = Uint8ArrayUtils.concat( + Id3Generator.stringToInts_('ID3'), + new Uint8Array([ + 0x03, 0x00, // version 3.0 of ID3v2 (aka ID3v.2.3.0) + 0x40, // flags. include an extended header + 0x00, 0x00, 0x00, 0x00, // size. set later + // extended header + 0x00, 0x00, 0x00, 0x06, // extended header size. no CRC + 0x00, 0x00, // extended flags + 0x00, 0x00, 0x00, 0x02, // size of padding + ]), + frames); + if (!extendedHeader) { + result = Uint8ArrayUtils.concat( + Id3Generator.stringToInts_('ID3'), + new Uint8Array([ + 0x03, 0x00, // version 3.0 of ID3v2 (aka ID3v.2.3.0) + 0x00, // flags + 0x00, 0x00, 0x00, 0x00, // size. set later + ]), + frames); + } + + // size is stored as a sequence of four 7-bit integers with the + // high bit of each byte set to zero + const size = result.length - 10; + + result[6] = (size >>> 21) & 0x7f; + result[7] = (size >>> 14) & 0x7f; + result[8] = (size >>> 7) & 0x7f; + result[9] = size & 0x7f; + + return result; + } + + /** + * Generate an ID3 frame from a type and value. + * + * @param {string} type + * @param {!Uint8Array} value + * @return {!Uint8Array} + */ + static generateId3Frame(type, value) { + goog.asserts.assert(type, 'type must be non-null'); + goog.asserts.assert(type.length == 4, 'type must contain 4 characters'); + const Id3Generator = shaka.test.Id3Generator; + const Uint8ArrayUtils = shaka.util.Uint8ArrayUtils; + const result = Uint8ArrayUtils.concat( + Id3Generator.stringToInts_(type), + new Uint8Array([ + 0x00, 0x00, 0x00, 0x00, // size + 0xe0, 0x00, // flags + ]), + value); + + // set the size + const size = result.length - 10; + + result[4] = (size >>> 21) & 0x7f; + result[5] = (size >>> 14) & 0x7f; + result[6] = (size >>> 7) & 0x7f; + result[7] = size & 0x7f; + + return result; + } + + /** + * @param {string} string + * @return {!Uint8Array} + * @private + */ + static stringToInts_(string) { + const result = []; + for (let i = 0; i < string.length; i++) { + result[i] = string.charCodeAt(i); + } + return new Uint8Array(result); + } +}; diff --git a/test/test/util/stream_generator.js b/test/test/util/stream_generator.js index 471a57d99f..3e9d9d786f 100644 --- a/test/test/util/stream_generator.js +++ b/test/test/util/stream_generator.js @@ -83,6 +83,44 @@ shaka.test.TSVodStreamGenerator = class { } }; +/** + * @summary + * Simulates an HLS, video-on-demand, aac stream. The StreamGenerator assumes + * the stream contains a single segment. + * + * @implements {shaka.test.IStreamGenerator} + */ +shaka.test.AACVodStreamGenerator = class { + /** @param {string} segmentUri The URI of the segment. */ + constructor(segmentUri) { + /** @private {string} */ + this.segmentUri_ = segmentUri; + + /** @private {ArrayBuffer} */ + this.segment_ = null; + } + + /** @override */ + async init() { + const segment = await shaka.test.Util.fetch(this.segmentUri_); + this.segment_ = segment; + } + + /** @override */ + getInitSegment(time) { + goog.asserts.assert(false, 'getInitSegment not implemented for HLS VOD.'); + return new ArrayBuffer(0); + } + + /** @override */ + getSegment(position, wallClockTime) { + goog.asserts.assert( + this.segment_, + 'init() must be called before getSegment().'); + return this.segment_; + } +}; + /** * @summary * Simulates a DASH, video-on-demand, MP4 stream. The StreamGenerator loops a diff --git a/test/test/util/test_scheme.js b/test/test/util/test_scheme.js index a443075900..3abeba54c8 100644 --- a/test/test/util/test_scheme.js +++ b/test/test/util/test_scheme.js @@ -175,6 +175,9 @@ shaka.test.TestScheme = class { if (metadata.segmentUri.includes('.ts')) { return new shaka.test.TSVodStreamGenerator(metadata.segmentUri); } + if (metadata.segmentUri.includes('.aac')) { + return new shaka.test.AACVodStreamGenerator(metadata.segmentUri); + } return new shaka.test.Mp4VodStreamGenerator( metadata.initSegmentUri, metadata.mdhdOffset, metadata.segmentUri, metadata.tfdtOffset, metadata.segmentDuration); @@ -652,6 +655,26 @@ shaka.test.TestScheme.DATA = { }, duration: 30, }, + + 'id3-metadata_ts': { + audio: { + segmentUri: '/base/test/test/assets/id3-metadata.ts', + mimeType: 'video/mp2t', + codecs: 'mp4a.40.5', + segmentDuration: 4.99, + }, + duration: 4.99, + }, + + 'id3-metadata_aac': { + audio: { + segmentUri: '/base/test/test/assets/id3-metadata.aac', + mimeType: 'audio/aac', + codecs: '', + segmentDuration: 9.98458, + }, + duration: 9.98458, + }, }; diff --git a/test/util/id3_utils_unit.js b/test/util/id3_utils_unit.js new file mode 100644 index 0000000000..ae4d5e114f --- /dev/null +++ b/test/util/id3_utils_unit.js @@ -0,0 +1,116 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +describe('Id3Utils', () => { + const Id3Utils = shaka.util.Id3Utils; + const Id3Generator = shaka.test.Id3Generator; + const BufferUtils = shaka.util.BufferUtils; + + it('no valid data produces empty output', () => { + expect(Id3Utils.getID3Frames(new Uint8Array([]))).toEqual([]); + }); + + it('parse a TXXX frame', () => { + const txxxValue = new Uint8Array([3, 65, 0, 83, 104, 97, 107, 97]); + const txxxFrame = Id3Generator.generateId3Frame('TXXX', txxxValue); + const txxxID3 = Id3Generator.generateId3(txxxFrame); + const expectedID3 = [ + { + key: 'TXXX', + description: 'A', + data: 'Shaka', + }, + ]; + expect(Id3Utils.getID3Frames(txxxID3)).toEqual(expectedID3); + }); + + it('parse a TXXX frame with extended header', () => { + const txxxValue = new Uint8Array([3, 65, 0, 83, 104, 97, 107, 97]); + const txxxFrame = Id3Generator.generateId3Frame('TXXX', txxxValue); + const txxxID3 = Id3Generator.generateId3(txxxFrame, true); + const expectedID3 = [ + { + key: 'TXXX', + description: 'A', + data: 'Shaka', + }, + ]; + expect(Id3Utils.getID3Frames(txxxID3)).toEqual(expectedID3); + }); + + it('parse a TCOP frame', () => { + const tcopValue = new Uint8Array( + [3, 83, 104, 97, 107, 97, 32, 50, 48, 49, 54]); + const tcopFrame = Id3Generator.generateId3Frame('TCOP', tcopValue); + const tcopID3 = Id3Generator.generateId3(tcopFrame); + const expectedID3 = [ + { + key: 'TCOP', + description: '', + data: 'Shaka 2016', + }, + ]; + expect(Id3Utils.getID3Frames(tcopID3)).toEqual(expectedID3); + }); + + it('parse a WXXX frame', () => { + const wxxxValue = new Uint8Array( + [3, 65, 0, 103, 111, 111, 103, 108, 101, 46, 99, 111, 109]); + const wxxxFrame = Id3Generator.generateId3Frame('WXXX', wxxxValue); + const wxxxID3 = Id3Generator.generateId3(wxxxFrame); + const expectedID3 = [ + { + key: 'WXXX', + description: 'A', + data: 'google.com', + }, + ]; + expect(Id3Utils.getID3Frames(wxxxID3)).toEqual(expectedID3); + }); + + it('parse a WCOP frame', () => { + const wcopValue = new Uint8Array( + [103, 111, 111, 103, 108, 101, 46, 99, 111, 109]); + const wcopFrame = Id3Generator.generateId3Frame('WCOP', wcopValue); + const wcopID3 = Id3Generator.generateId3(wcopFrame); + const expectedID3 = [ + { + key: 'WCOP', + description: '', + data: 'google.com', + }, + ]; + expect(Id3Utils.getID3Frames(wcopID3)).toEqual(expectedID3); + }); + + it('parse a PRIV frame', () => { + const privValue = new Uint8Array([65, 0, 83, 104, 97, 107]); + const privFrame = Id3Generator.generateId3Frame('PRIV', privValue); + const privID3 = Id3Generator.generateId3(privFrame); + const expectedID3 = [ + { + key: 'PRIV', + description: 'A', + data: BufferUtils.toArrayBuffer(new Uint8Array([83, 104, 97, 107])), + }, + ]; + expect(Id3Utils.getID3Frames(privID3)).toEqual(expectedID3); + }); + + it('parse an unknown frame', () => { + const unknownValue = new Uint8Array([83, 104, 97, 107]); + const unknownFrame = Id3Generator.generateId3Frame('XXXX', unknownValue); + const unknownID3 = Id3Generator.generateId3(unknownFrame); + const expectedID3 = [ + { + key: 'XXXX', + description: '', + data: BufferUtils.toArrayBuffer(new Uint8Array([83, 104, 97, 107])), + }, + ]; + expect(Id3Utils.getID3Frames(unknownID3)).toEqual(expectedID3); + }); +}); diff --git a/test/util/ts_parser_unit.js b/test/util/ts_parser_unit.js new file mode 100644 index 0000000000..3744a6796a --- /dev/null +++ b/test/util/ts_parser_unit.js @@ -0,0 +1,51 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +describe('TsParser', () => { + const Util = shaka.test.Util; + const BufferUtils = shaka.util.BufferUtils; + + it('probes a TS segment', async () => { + const responses = await Promise.all([ + Util.fetch('/base/test/test/assets/video.ts'), + Util.fetch('/base/test/test/assets/audio.ts'), + ]); + const videoSegment = BufferUtils.toUint8(responses[0]); + const audioSegment = BufferUtils.toUint8(responses[1]); + expect(shaka.util.TsParser.probe(videoSegment)).toBeTruthy(); + expect(shaka.util.TsParser.probe(audioSegment)).toBeTruthy(); + }); + + it('probes a non TS segment', async () => { + const responses = await Promise.all([ + Util.fetch('/base/test/test/assets/small.mp4'), + ]); + const nonTsSegment = BufferUtils.toUint8(responses[0]); + expect(shaka.util.TsParser.probe(nonTsSegment)).toBeFalsy(); + }); + + it('parses a TS segment', async () => { + const responses = await Promise.all([ + Util.fetch('/base/test/test/assets/video.ts'), + Util.fetch('/base/test/test/assets/audio.ts'), + ]); + const videoSegment = BufferUtils.toUint8(responses[0]); + const audioSegment = BufferUtils.toUint8(responses[1]); + expect(new shaka.util.TsParser().parse(videoSegment)).toBeDefined(); + expect(new shaka.util.TsParser().parse(audioSegment)).toBeDefined(); + }); + + it('parses a TS segment with metadata', async () => { + const responses = await Promise.all([ + Util.fetch('/base/test/test/assets/id3-metadata.ts'), + ]); + const tsSegment = BufferUtils.toUint8(responses[0]); + const metadata = new shaka.util.TsParser().parse(tsSegment) + .getMetadata(); + expect(metadata).toBeTruthy(); + expect(metadata.length).toBe(2); + }); +});