diff --git a/README.md b/README.md index cdf8299..1d13cef 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,10 @@ const manifestUri = 'https://example.com/dash.xml'; const res = await fetch(manifestUri); const manifest = await res.text(); -var parsedManifest = mpdParser.parse(manifest, { manifestUri }); +// A callback function to handle events like errors or warnings +const eventHandler = ({ type, message }) => console.log(`${type}: ${message}`); + +var parsedManifest = mpdParser.parse(manifest, { manifestUri, eventHandler }); ``` If dealing with a live stream, then on subsequent calls to parse, the previously parsed @@ -63,6 +66,12 @@ The parser ouputs a plain javascript object with the following structure: ```js Manifest { allowCache: boolean, + contentSteering: { + defaultServiceLocation: string, + proxyServerURL: string, + queryBeforeStart: boolean, + serverURL: string + }, endList: boolean, mediaSequence: number, discontinuitySequence: number, diff --git a/package-lock.json b/package-lock.json index 9fc5e1e..154e33f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1525,27 +1525,27 @@ } }, "@sinonjs/commons": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.3.tgz", - "integrity": "sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ==", + "version": "1.8.6", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz", + "integrity": "sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==", "dev": true, "requires": { "type-detect": "4.0.8" } }, "@sinonjs/fake-timers": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-6.0.1.tgz", - "integrity": "sha512-MZPUxrmFubI36XS1DI3qmI0YdN1gks62JtFZvxR67ljjSNCeK6U08Zx4msEWOXuofgqUt6zPHSi1H9fbjR/NRA==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-7.1.2.tgz", + "integrity": "sha512-iQADsW4LBMISqZ6Ci1dupJL9pprqwcVFTcOsEmQOEhW+KLCVn/Y4Jrvg2k19fIHCp+iFprriYPTdRcQR8NbUPg==", "dev": true, "requires": { "@sinonjs/commons": "^1.7.0" } }, "@sinonjs/samsam": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-5.3.1.tgz", - "integrity": "sha512-1Hc0b1TtyfBu8ixF/tpfSHTVWKwCBLY4QJbkgnE7HcwyvT2xArDxb4K7dMgqRm3szI+LJbzmW/s4xxEhv6hwDg==", + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-6.1.3.tgz", + "integrity": "sha512-nhOb2dWPeb1sd3IQXL/dVPnKHDOAFfvichtBf4xV00/rU1QbPCQqKMbvIheIjqwVjh7qIgf2AHTHi391yMOMpQ==", "dev": true, "requires": { "@sinonjs/commons": "^1.6.0", @@ -1554,9 +1554,9 @@ } }, "@sinonjs/text-encoding": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz", - "integrity": "sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz", + "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", "dev": true }, "@textlint/ast-node-types": { @@ -3142,9 +3142,9 @@ "dev": true }, "diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz", + "integrity": "sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==", "dev": true }, "doctoc": { @@ -6087,7 +6087,7 @@ "lodash.get": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", - "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", "dev": true }, "lodash.ismatch": { @@ -6681,16 +6681,47 @@ "dev": true }, "nise": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/nise/-/nise-4.1.0.tgz", - "integrity": "sha512-eQMEmGN/8arp0xsvGoQ+B1qvSkR73B1nWSCh7nOt5neMCtwcQVYQGdzQMhcNscktTsWB54xnlSQFzOAPJD8nXA==", + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.4.tgz", + "integrity": "sha512-8+Ib8rRJ4L0o3kfmyVCL7gzrohyDe0cMFTBa2d364yIrEGMEoetznKJx899YxjybU6bL9SQkYPSBBs1gyYs8Xg==", "dev": true, "requires": { - "@sinonjs/commons": "^1.7.0", - "@sinonjs/fake-timers": "^6.0.0", + "@sinonjs/commons": "^2.0.0", + "@sinonjs/fake-timers": "^10.0.2", "@sinonjs/text-encoding": "^0.7.1", "just-extend": "^4.0.2", "path-to-regexp": "^1.7.0" + }, + "dependencies": { + "@sinonjs/commons": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", + "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + }, + "@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "requires": { + "@sinonjs/commons": "^3.0.0" + }, + "dependencies": { + "@sinonjs/commons": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.0.tgz", + "integrity": "sha512-jXBtWAF4vmdNmZgD5FoKsVLv3rPgDnLgPbU84LIJ3otV44vJlDRokVng5v8NFJdCf/da9legHcKaRuZs4L7faA==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + } + } + } } }, "node-releases": { @@ -7139,7 +7170,7 @@ "isarray": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", "dev": true } } @@ -8183,17 +8214,17 @@ "dev": true }, "sinon": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-9.2.4.tgz", - "integrity": "sha512-zljcULZQsJxVra28qIAL6ow1Z9tpattkCTEJR4RBP3TGc00FcttsP5pK284Nas5WjMZU5Yzy3kAIp3B3KRf5Yg==", + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-11.1.2.tgz", + "integrity": "sha512-59237HChms4kg7/sXhiRcUzdSkKuydDeTiamT/jesUVHshBgL8XAmhgFo0GfK6RruMDM/iRSij1EybmMog9cJw==", "dev": true, "requires": { - "@sinonjs/commons": "^1.8.1", - "@sinonjs/fake-timers": "^6.0.1", - "@sinonjs/samsam": "^5.3.1", - "diff": "^4.0.2", - "nise": "^4.0.4", - "supports-color": "^7.1.0" + "@sinonjs/commons": "^1.8.3", + "@sinonjs/fake-timers": "^7.1.2", + "@sinonjs/samsam": "^6.0.2", + "diff": "^5.0.0", + "nise": "^5.1.0", + "supports-color": "^7.2.0" }, "dependencies": { "has-flag": { diff --git a/package.json b/package.json index 6f29910..14c2989 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "karma": "^5.2.3", "rollup": "^2.38.0", "rollup-plugin-string": "^3.0.0", - "sinon": "^9.2.3", + "sinon": "^11.1.1", "videojs-generate-karma-config": "^8.0.1", "videojs-generate-rollup-config": "~7.0.0", "videojs-generator-verify": "~3.0.2", diff --git a/src/errors.js b/src/errors.js index 1fd0928..4e9fd73 100644 --- a/src/errors.js +++ b/src/errors.js @@ -1,5 +1,6 @@ export default { INVALID_NUMBER_OF_PERIOD: 'INVALID_NUMBER_OF_PERIOD', + INVALID_NUMBER_OF_CONTENT_STEERING: 'INVALID_NUMBER_OF_CONTENT_STEERING', DASH_EMPTY_MANIFEST: 'DASH_EMPTY_MANIFEST', DASH_INVALID_XML: 'DASH_INVALID_XML', NO_BASE_URL: 'NO_BASE_URL', diff --git a/src/index.js b/src/index.js index a63e4d5..818f1df 100644 --- a/src/index.js +++ b/src/index.js @@ -28,6 +28,7 @@ const parse = (manifestString, options = {}) => { return toM3u8({ dashPlaylists: playlists, locations: parsedManifestInfo.locations, + contentSteering: parsedManifestInfo.contentSteeringInfo, sidxMapping: options.sidxMapping, previousManifest: options.previousManifest, eventStream: parsedManifestInfo.eventStream diff --git a/src/inheritAttributes.js b/src/inheritAttributes.js index b77e9d0..5b82293 100644 --- a/src/inheritAttributes.js +++ b/src/inheritAttributes.js @@ -16,21 +16,21 @@ const keySystemsMap = { /** * Builds a list of urls that is the product of the reference urls and BaseURL values * - * @param {string[]} referenceUrls - * List of reference urls to resolve to + * @param {Object[]} references + * List of objects containing the reference URL as well as its attributes * @param {Node[]} baseUrlElements * List of BaseURL nodes from the mpd - * @return {string[]} - * List of resolved urls + * @return {Object[]} + * List of objects with resolved urls and attributes */ -export const buildBaseUrls = (referenceUrls, baseUrlElements) => { +export const buildBaseUrls = (references, baseUrlElements) => { if (!baseUrlElements.length) { - return referenceUrls; + return references; } - return flatten(referenceUrls.map(function(reference) { + return flatten(references.map(function(reference) { return baseUrlElements.map(function(baseUrlElement) { - return resolveUrl(reference, getContent(baseUrlElement)); + return merge(parseAttributes(baseUrlElement), { baseUrl: resolveUrl(reference.baseUrl, getContent(baseUrlElement)) }); }); })); }; @@ -140,8 +140,9 @@ export const getSegmentInformation = (adaptationSet) => { * * @param {Object} adaptationSetAttributes * Contains attributes inherited by the AdaptationSet - * @param {string[]} adaptationSetBaseUrls - * Contains list of resolved base urls inherited by the AdaptationSet + * @param {Object[]} adaptationSetBaseUrls + * List of objects containing resolved base URLs and attributes + * inherited by the AdaptationSet * @param {SegmentInformation} adaptationSetSegmentInfo * Contains Segment information for the AdaptationSet * @return {inheritBaseUrlsCallback} @@ -158,7 +159,7 @@ export const inheritBaseUrls = return repBaseUrls.map(baseUrl => { return { segmentInfo: merge(adaptationSetSegmentInfo, representationSegmentInfo), - attributes: merge(attributes, { baseUrl }) + attributes: merge(attributes, baseUrl) }; }); }; @@ -340,8 +341,9 @@ export const toEventStream = (period) => { * * @param {Object} periodAttributes * Contains attributes inherited by the Period - * @param {string[]} periodBaseUrls - * Contains list of resolved base urls inherited by the Period + * @param {Object[]} periodBaseUrls + * Contains list of objects with resolved base urls and attributes + * inherited by the Period * @param {string[]} periodSegmentInfo * Contains Segment Information at the period level * @return {toRepresentationsCallback} @@ -421,8 +423,9 @@ export const toRepresentations = * * @param {Object} mpdAttributes * Contains attributes inherited by the mpd - * @param {string[]} mpdBaseUrls - * Contains list of resolved base urls inherited by the mpd + * @param {Object[]} mpdBaseUrls + * Contains list of objects with resolved base urls and attributes + * inherited by the mpd * @return {toAdaptationSetsCallback} * Callback map function */ @@ -441,6 +444,41 @@ export const toAdaptationSets = (mpdAttributes, mpdBaseUrls) => (period, index) return flatten(adaptationSets.map(toRepresentations(periodAttributes, periodBaseUrls, periodSegmentInfo))); }; +/** + * Tranforms an array of content steering nodes into an object + * containing CDN content steering information from the MPD manifest. + * + * For more information on the DASH spec for Content Steering parsing, see: + * https://dashif.org/docs/DASH-IF-CTS-00XX-Content-Steering-Community-Review.pdf + * + * @param {Node[]} contentSteeringNodes + * Content steering nodes + * @param {Function} eventHandler + * The event handler passed into the parser options to handle warnings + * @return {Object} + * Object containing content steering data + */ +export const generateContentSteeringInformation = (contentSteeringNodes, eventHandler) => { + // If there are more than one ContentSteering tags, throw an error + if (contentSteeringNodes.length > 1) { + eventHandler({ type: 'warn', message: 'The MPD manifest should contain no more than one ContentSteering tag' }); + } + + // Return a null value if there are no ContentSteering tags + if (!contentSteeringNodes.length) { + return null; + } + + const infoFromContentSteeringTag = + merge({serverURL: getContent(contentSteeringNodes[0])}, parseAttributes(contentSteeringNodes[0])); + + // Converts `queryBeforeStart` to a boolean, as well as setting the default value + // to `false` if it doesn't exist + infoFromContentSteeringTag.queryBeforeStart = (infoFromContentSteeringTag.queryBeforeStart === 'true'); + + return infoFromContentSteeringTag; +}; + /** * Gets Period@start property for a given period. * @@ -518,7 +556,14 @@ export const inheritAttributes = (mpd, options = {}) => { const { manifestUri = '', NOW = Date.now(), - clientOffset = 0 + clientOffset = 0, + // TODO: For now, we are expecting an eventHandler callback function + // to be passed into the mpd parser as an option. + // In the future, we should enable stream parsing by using the Stream class from vhs-utils. + // This will support new features including a standardized event handler. + // See the m3u8 parser for examples of how stream parsing is currently used for HLS parsing. + // https://github.com/videojs/vhs-utils/blob/88d6e10c631e57a5af02c5a62bc7376cd456b4f5/src/stream.js#L9 + eventHandler = function() {} } = options; const periodNodes = findChildren(mpd, 'Period'); @@ -530,6 +575,7 @@ export const inheritAttributes = (mpd, options = {}) => { const mpdAttributes = parseAttributes(mpd); const mpdBaseUrls = buildBaseUrls([ manifestUri ], findChildren(mpd, 'BaseURL')); + const contentSteeringNodes = findChildren(mpd, 'ContentSteering'); // See DASH spec section 5.3.1.2, Semantics of MPD element. Default type to 'static'. mpdAttributes.type = mpdAttributes.type || 'static'; @@ -567,6 +613,14 @@ export const inheritAttributes = (mpd, options = {}) => { return { locations: mpdAttributes.locations, + contentSteeringInfo: generateContentSteeringInformation(contentSteeringNodes, eventHandler), + // TODO: There are occurences where this `representationInfo` array contains undesired + // duplicates. This generally occurs when there are multiple BaseURL nodes that are + // direct children of the MPD node. When we attempt to resolve URLs from a combination of the + // parent BaseURL and a child BaseURL, and the value does not resolve, + // we end up returning the child BaseURL multiple times. + // We need to determine a way to remove these duplicates in a safe way. + // See: https://github.com/videojs/mpd-parser/pull/17#discussion_r162750527 representationInfo: flatten(periods.map(toAdaptationSets(mpdAttributes, mpdBaseUrls))), eventStream: flatten(periods.map(toEventStream)) }; diff --git a/src/stringToMpdXml.js b/src/stringToMpdXml.js index 8bd1a20..d68f4ab 100644 --- a/src/stringToMpdXml.js +++ b/src/stringToMpdXml.js @@ -15,7 +15,7 @@ export const stringToMpdXml = (manifestString) => { mpd = xml && xml.documentElement.tagName === 'MPD' ? xml.documentElement : null; } catch (e) { - // ie 11 throwsw on invalid xml + // ie 11 throws on invalid xml } if (!mpd || mpd && diff --git a/src/toM3u8.js b/src/toM3u8.js index 132b96d..01ba625 100644 --- a/src/toM3u8.js +++ b/src/toM3u8.js @@ -392,6 +392,7 @@ export const flattenMediaGroupPlaylists = (mediaGroupObject) => { export const toM3u8 = ({ dashPlaylists, locations, + contentSteering, sidxMapping = {}, previousManifest, eventStream @@ -437,6 +438,10 @@ export const toM3u8 = ({ manifest.locations = locations; } + if (contentSteering) { + manifest.contentSteering = contentSteering; + } + if (type === 'dynamic') { manifest.suggestedPresentationDelay = suggestedPresentationDelay; } diff --git a/test/inheritAttributes.test.js b/test/inheritAttributes.test.js index 37dde40..8b9f514 100644 --- a/test/inheritAttributes.test.js +++ b/test/inheritAttributes.test.js @@ -9,6 +9,7 @@ import { import { stringToMpdXml } from '../src/stringToMpdXml'; import errors from '../src/errors'; import QUnit from 'qunit'; +import { stub } from 'sinon'; import { toPlaylists } from '../src/toPlaylists'; import decodeB64ToUint8Array from '@videojs/vhs-utils/es/decode-b64-to-uint8-array'; import { findChildren } from '../src/utils/xml'; @@ -16,23 +17,23 @@ import { findChildren } from '../src/utils/xml'; QUnit.module('buildBaseUrls'); QUnit.test('returns reference urls when no BaseURL nodes', function(assert) { - const reference = ['https://example.com/', 'https://foo.com/']; + const reference = [{ baseUrl: 'https://example.com/' }, { baseUrl: 'https://foo.com/' }]; assert.deepEqual(buildBaseUrls(reference, []), reference, 'returns reference urls'); }); QUnit.test('single reference url with single BaseURL node', function(assert) { - const reference = ['https://example.com']; + const reference = [{ baseUrl: 'https://example.com' }]; const node = [{ textContent: 'bar/' }]; - const expected = ['https://example.com/bar/']; + const expected = [{ baseUrl: 'https://example.com/bar/' }]; assert.deepEqual(buildBaseUrls(reference, node), expected, 'builds base url'); }); QUnit.test('multiple reference urls with single BaseURL node', function(assert) { - const reference = ['https://example.com/', 'https://foo.com/']; + const reference = [{ baseUrl: 'https://example.com/' }, { baseUrl: 'https://foo.com/' }]; const node = [{ textContent: 'bar/' }]; - const expected = ['https://example.com/bar/', 'https://foo.com/bar/']; + const expected = [{ baseUrl: 'https://example.com/bar/' }, { baseUrl: 'https://foo.com/bar/' }]; assert.deepEqual( buildBaseUrls(reference, node), expected, @@ -41,36 +42,38 @@ QUnit.test('multiple reference urls with single BaseURL node', function(assert) }); QUnit.test('multiple BaseURL nodes with single reference url', function(assert) { - const reference = ['https://example.com/']; + const reference = [{ baseUrl: 'https://example.com/' }]; const nodes = [{ textContent: 'bar/' }, { textContent: 'baz/' }]; - const expected = ['https://example.com/bar/', 'https://example.com/baz/']; + const expected = [{ baseUrl: 'https://example.com/bar/' }, { baseUrl: 'https://example.com/baz/' }]; assert.deepEqual(buildBaseUrls(reference, nodes), expected, 'base url for each node'); }); QUnit.test('multiple reference urls with multiple BaseURL nodes', function(assert) { - const reference = ['https://example.com/', 'https://foo.com/', 'http://example.com']; + const reference = [ + { baseUrl: 'https://example.com/' }, { baseUrl: 'https://foo.com/' }, { baseUrl: 'http://example.com' } + ]; const nodes = [{ textContent: 'bar/' }, { textContent: 'baz/' }, { textContent: 'buzz/' }]; const expected = [ - 'https://example.com/bar/', - 'https://example.com/baz/', - 'https://example.com/buzz/', - 'https://foo.com/bar/', - 'https://foo.com/baz/', - 'https://foo.com/buzz/', - 'http://example.com/bar/', - 'http://example.com/baz/', - 'http://example.com/buzz/' + { baseUrl: 'https://example.com/bar/' }, + { baseUrl: 'https://example.com/baz/' }, + { baseUrl: 'https://example.com/buzz/' }, + { baseUrl: 'https://foo.com/bar/' }, + { baseUrl: 'https://foo.com/baz/' }, + { baseUrl: 'https://foo.com/buzz/' }, + { baseUrl: 'http://example.com/bar/' }, + { baseUrl: 'http://example.com/baz/' }, + { baseUrl: 'http://example.com/buzz/' } ]; assert.deepEqual(buildBaseUrls(reference, nodes), expected, 'creates all base urls'); }); QUnit.test('absolute BaseURL overwrites reference', function(assert) { - const reference = ['https://example.com']; + const reference = [{ baseUrl: 'https://example.com' }]; const node = [{ textContent: 'https://foo.com/bar/' }]; - const expected = ['https://foo.com/bar/']; + const expected = [{ baseUrl: 'https://foo.com/bar/'}]; assert.deepEqual( buildBaseUrls(reference, node), expected, @@ -78,6 +81,40 @@ QUnit.test('absolute BaseURL overwrites reference', function(assert) { ); }); +QUnit.test('reference attributes are ignored when there is a BaseURL node', function(assert) { + const reference = [{ baseUrl: 'https://example.com', attributes: [{ name: 'test', value: 'wow' }] }]; + const node = [{ textContent: 'https://foo.com/bar/' }]; + const expected = [{ baseUrl: 'https://foo.com/bar/' }]; + + assert.deepEqual( + buildBaseUrls(reference, node), expected, + 'baseURL attributes are not included' + ); +}); + +QUnit.test('BasURL attributes are still added with a reference', function(assert) { + const reference = [{ baseUrl: 'https://example.com' }]; + const node = [{ textContent: 'https://foo.com/bar/', attributes: [{ name: 'test', value: 'wow' }] }]; + + const expected = [{ baseUrl: 'https://foo.com/bar/', test: 'wow' }]; + + assert.deepEqual( + buildBaseUrls(reference, node), expected, + 'baseURL attributes are included' + ); +}); + +QUnit.test('attributes are replaced when both reference and BaseURL have the same attributes', function(assert) { + const reference = [{ baseUrl: 'https://example.com', attributes: [{ name: 'test', value: 'old' }] }]; + const node = [{ textContent: 'https://foo.com/bar/', attributes: [{ name: 'test', value: 'new' }] }]; + const expected = [{ baseUrl: 'https://foo.com/bar/', test: 'new' }]; + + assert.deepEqual( + buildBaseUrls(reference, node), expected, + 'baseURL attributes are included' + ); +}); + QUnit.module('getPeriodStart'); QUnit.test('gets period start when available', function(assert) { @@ -546,6 +583,7 @@ QUnit.test('end to end - basic', function(assert) { `), { NOW }); const expected = { + contentSteeringInfo: null, eventStream: [], locations: undefined, representationInfo: [{ @@ -621,6 +659,7 @@ QUnit.test('end to end - basic dynamic', function(assert) { `), { NOW }); const expected = { + contentSteeringInfo: null, eventStream: [], locations: undefined, representationInfo: [{ @@ -666,6 +705,300 @@ QUnit.test('end to end - basic dynamic', function(assert) { assert.deepEqual(actual, expected); }); +QUnit.test('end to end - content steering - non resolvable base URLs', function(assert) { + const NOW = Date.now(); + + const actual = inheritAttributes(stringToMpdXml(` + + https://example.com/app/url + https://cdn1.example.com/ + https://cdn2.example.com/ + + + + + + + + + + https://example.com/en.vtt + + + + +`), { NOW, manifestUri: 'https://www.test.com' }); + + // Note that we expect to see the `contentSteeringInfo` object set with the + // proper values. We also expect to see the `serviceLocation` property set to + // the correct values inside of the correct representations. + const expected = { + contentSteeringInfo: { + defaultServiceLocation: 'beta', + proxyServerURL: 'http://127.0.0.1:3455/steer', + queryBeforeStart: false, + serverURL: 'https://example.com/app/url' + }, + eventStream: [], + locations: undefined, + representationInfo: [ + { + attributes: { + NOW, + bandwidth: 5000000, + baseUrl: 'https://cdn1.example.com/', + clientOffset: 0, + codecs: 'avc1.64001e', + height: 404, + id: 'test', + mimeType: 'video/mp4', + periodStart: 0, + role: { + value: 'main' + }, + serviceLocation: 'alpha', + sourceDuration: 0, + type: 'dyanmic', + width: 720 + }, + segmentInfo: { + template: {} + } + }, + { + attributes: { + NOW, + bandwidth: 5000000, + baseUrl: 'https://cdn2.example.com/', + clientOffset: 0, + codecs: 'avc1.64001e', + height: 404, + id: 'test', + mimeType: 'video/mp4', + periodStart: 0, + role: { + value: 'main' + }, + serviceLocation: 'beta', + sourceDuration: 0, + type: 'dyanmic', + width: 720 + }, + segmentInfo: { + template: {} + } + }, + { + attributes: { + NOW, + bandwidth: 256, + baseUrl: 'https://example.com/en.vtt', + clientOffset: 0, + id: 'en', + lang: 'en', + mimeType: 'text/vtt', + periodStart: 0, + role: {}, + sourceDuration: 0, + type: 'dyanmic' + }, + segmentInfo: {} + }, + { + attributes: { + NOW, + bandwidth: 256, + baseUrl: 'https://example.com/en.vtt', + clientOffset: 0, + id: 'en', + lang: 'en', + mimeType: 'text/vtt', + periodStart: 0, + role: {}, + sourceDuration: 0, + type: 'dyanmic' + }, + segmentInfo: {} + } + ] + }; + + assert.equal(actual.representationInfo.length, 4); + assert.deepEqual(actual, expected); +}); + +QUnit.test('end to end - content steering - resolvable base URLs', function(assert) { + const NOW = Date.now(); + + const actual = inheritAttributes(stringToMpdXml(` + + https://example.com/app/url + https://cdn1.example.com/ + https://cdn2.example.com/ + + + + + + + + + + /video + + + + +`), { NOW, manifestUri: 'https://www.test.com' }); + + // Note that we expect to see the `contentSteeringInfo` object set with the + // proper values. We also expect to see the `serviceLocation` property set to + // the correct values inside of the correct representations. + // + // Also note that some of the representations have '/video' appended + // to the end of the baseUrls + const expected = { + contentSteeringInfo: { + defaultServiceLocation: 'beta', + proxyServerURL: 'http://127.0.0.1:3455/steer', + queryBeforeStart: false, + serverURL: 'https://example.com/app/url' + }, + eventStream: [], + locations: undefined, + representationInfo: [ + { + attributes: { + NOW, + bandwidth: 5000000, + baseUrl: 'https://cdn1.example.com/', + clientOffset: 0, + codecs: 'avc1.64001e', + height: 404, + id: 'test', + mimeType: 'video/mp4', + periodStart: 0, + role: { + value: 'main' + }, + serviceLocation: 'alpha', + sourceDuration: 0, + type: 'dyanmic', + width: 720 + }, + segmentInfo: { + template: {} + } + }, + { + attributes: { + NOW, + bandwidth: 5000000, + baseUrl: 'https://cdn2.example.com/', + clientOffset: 0, + codecs: 'avc1.64001e', + height: 404, + id: 'test', + mimeType: 'video/mp4', + periodStart: 0, + role: { + value: 'main' + }, + serviceLocation: 'beta', + sourceDuration: 0, + type: 'dyanmic', + width: 720 + }, + segmentInfo: { + template: {} + } + }, + { + attributes: { + NOW, + bandwidth: 256, + baseUrl: 'https://cdn1.example.com/video', + clientOffset: 0, + id: 'en', + lang: 'en', + mimeType: 'text/vtt', + periodStart: 0, + role: {}, + sourceDuration: 0, + type: 'dyanmic' + }, + segmentInfo: {} + }, + { + attributes: { + NOW, + bandwidth: 256, + baseUrl: 'https://cdn2.example.com/video', + clientOffset: 0, + id: 'en', + lang: 'en', + mimeType: 'text/vtt', + periodStart: 0, + role: {}, + sourceDuration: 0, + type: 'dyanmic' + }, + segmentInfo: {} + } + ] + }; + + assert.equal(actual.representationInfo.length, 4); + assert.deepEqual(actual, expected); +}); + +QUnit.test('Too many content steering tags sends a warning to the eventHandler', function(assert) { + const handlerStub = stub(); + const NOW = Date.now(); + + inheritAttributes(stringToMpdXml(` + + https://example.com/app/url + https://example.com/app/url + https://cdn1.example.com/ + https://cdn2.example.com/ + + + + + + + + + + /video + + + + + `), { NOW, manifestUri: 'https://www.test.com', eventHandler: handlerStub }); + + assert.ok(handlerStub.calledWith({ + type: 'warn', + message: 'The MPD manifest should contain no more than one ContentSteering tag' + })); +}); + QUnit.test('end to end - basic multiperiod', function(assert) { const NOW = Date.now(); @@ -703,6 +1036,7 @@ QUnit.test('end to end - basic multiperiod', function(assert) { `), { NOW }); const expected = { + contentSteeringInfo: null, eventStream: [], locations: undefined, representationInfo: [{ @@ -790,6 +1124,7 @@ QUnit.test('end to end - inherits BaseURL from all levels', function(assert) { `), { NOW }); const expected = { + contentSteeringInfo: null, eventStream: [], locations: undefined, representationInfo: [{ @@ -867,6 +1202,7 @@ QUnit.test('end to end - alternate BaseURLs', function(assert) { `), { NOW }); const expected = { + contentSteeringInfo: null, eventStream: [], locations: undefined, representationInfo: [{ @@ -1035,6 +1371,7 @@ QUnit.test( `), { NOW }); const expected = { + contentSteeringInfo: null, eventStream: [], locations: undefined, representationInfo: [{ @@ -1148,6 +1485,7 @@ QUnit.test( `), { NOW }); const expected = { + contentSteeringInfo: null, eventStream: [], locations: undefined, representationInfo: [{ @@ -1272,6 +1610,7 @@ QUnit.test( `), { NOW }); const expected = { + contentSteeringInfo: null, eventStream: [], locations: undefined, representationInfo: [{ @@ -2116,6 +2455,7 @@ QUnit.test('keySystem info for representation - lowercase UUIDs', function(asser // inconsistent quoting because of quote-props const expected = { + contentSteeringInfo: null, eventStream: [], locations: undefined, representationInfo: [{ @@ -2203,6 +2543,7 @@ QUnit.test('keySystem info for representation - uppercase UUIDs', function(asser // inconsistent quoting because of quote-props const expected = { + contentSteeringInfo: null, eventStream: [], locations: undefined, representationInfo: [{ @@ -2372,6 +2713,7 @@ QUnit.test('gets eventStream from inheritAttributes', function(assert) { `); const expected = { + contentSteeringInfo: null, eventStream: [ { end: 15, @@ -2481,6 +2823,7 @@ QUnit.test('gets eventStream from inheritAttributes with data in Event tags', fu `); const expected = { + contentSteeringInfo: null, eventStream: [ { end: 15, diff --git a/test/toM3u8.test.js b/test/toM3u8.test.js index 6e57475..2440371 100644 --- a/test/toM3u8.test.js +++ b/test/toM3u8.test.js @@ -213,6 +213,246 @@ QUnit.test('playlists', function(assert) { assert.deepEqual(toM3u8({ dashPlaylists }), expected); }); +QUnit.test('playlists with content steering', function(assert) { + const contentSteering = { + defaultServiceLocation: 'beta', + proxyServerURL: 'http://127.0.0.1:3455/steer', + queryBeforeStart: false, + serverURL: 'https://example.com/app/url' + }; + + const dashPlaylists = [{ + attributes: { + bandwidth: 5000000, + baseUrl: 'https://cdn1.example.com/', + clientOffset: 0, + codecs: 'avc1.64001e', + duration: 0, + height: 404, + id: 'test', + mimeType: 'video/mp4', + periodStart: 0, + role: { + value: 'main' + }, + serviceLocation: 'alpha', + sourceDuration: 0, + type: 'dyanmic', + width: 720 + }, + segments: [ + { + duration: 0, + map: { + resolvedUri: 'https://cdn1.example.com/', + uri: '' + }, + number: 1, + presentationTime: 0, + resolvedUri: 'https://cdn1.example.com/', + timeline: 0, + uri: '' + } + ] + }, { + attributes: { + bandwidth: 5000000, + baseUrl: 'https://cdn2.example.com/', + clientOffset: 0, + codecs: 'avc1.64001e', + duration: 0, + height: 404, + id: 'test', + mimeType: 'video/mp4', + periodStart: 0, + role: { + value: 'main' + }, + serviceLocation: 'beta', + sourceDuration: 0, + type: 'dyanmic', + width: 720 + }, + segments: [ + { + duration: 0, + map: { + resolvedUri: 'https://cdn2.example.com/', + uri: '' + }, + number: 1, + presentationTime: 0, + resolvedUri: 'https://cdn2.example.com/', + timeline: 0, + uri: '' + } + ] + }, { + attributes: { + bandwidth: 256, + baseUrl: 'https://example.com/en.vtt', + clientOffset: 0, + id: 'en', + lang: 'en', + mimeType: 'text/vtt', + periodStart: 0, + role: {}, + sourceDuration: 0, + type: 'dyanmic' + } + }, { + attributes: { + bandwidth: 256, + baseUrl: 'https://example.com/en.vtt', + clientOffset: 0, + id: 'en', + lang: 'en', + mimeType: 'text/vtt', + periodStart: 0, + role: {}, + sourceDuration: 0, + type: 'dyanmic' + } + }]; + + const expected = { + allowCache: true, + contentSteering: { + defaultServiceLocation: 'beta', + proxyServerURL: 'http://127.0.0.1:3455/steer', + queryBeforeStart: false, + serverURL: 'https://example.com/app/url' + }, + discontinuityStarts: [], + duration: 0, + endList: true, + mediaGroups: { + AUDIO: {}, + ['CLOSED-CAPTIONS']: {}, + SUBTITLES: { + subs: { + en: { + autoselect: false, + default: false, + language: 'en', + playlists: [ + { + attributes: { + BANDWIDTH: 256, + NAME: 'en', + ['PROGRAM-ID']: 1 + }, + discontinuitySequence: 0, + discontinuityStarts: [], + endList: false, + mediaSequence: 0, + resolvedUri: 'https://example.com/en.vtt', + segments: [ + { + duration: 0, + number: 0, + resolvedUri: 'https://example.com/en.vtt', + timeline: 0, + uri: 'https://example.com/en.vtt' + } + ], + targetDuration: 0, + timeline: 0, + timelineStarts: [ + { + start: 0, + timeline: 0 + }, + { + start: 0, + timeline: 0 + } + ], + uri: '' + } + ], + uri: '' + } + } + }, + VIDEO: {} + }, + playlists: [ + { + attributes: { + AUDIO: 'audio', + BANDWIDTH: 5000000, + CODECS: 'avc1.64001e', + NAME: 'test', + ['PROGRAM-ID']: 1, + RESOLUTION: { + height: 404, + width: 720 + }, + SUBTITLES: 'subs' + }, + discontinuitySequence: 0, + discontinuityStarts: [ + 1 + ], + endList: false, + mediaSequence: 0, + resolvedUri: '', + segments: [ + { + duration: 0, + map: { + resolvedUri: 'https://cdn1.example.com/', + uri: '' + }, + number: 0, + presentationTime: 0, + resolvedUri: 'https://cdn1.example.com/', + timeline: 0, + uri: '' + }, + { + discontinuity: true, + duration: 0, + map: { + resolvedUri: 'https://cdn2.example.com/', + uri: '' + }, + number: 1, + presentationTime: 0, + resolvedUri: 'https://cdn2.example.com/', + timeline: 0, + uri: '' + } + ], + targetDuration: 0, + timeline: 0, + timelineStarts: [ + { + start: 0, + timeline: 0 + }, + { + start: 0, + timeline: 0 + } + ], + uri: '' + } + ], + segments: [], + timelineStarts: [ + { + start: 0, + timeline: 0 + } + ], + uri: '' + }; + + assert.deepEqual(toM3u8({ dashPlaylists, contentSteering }), expected); +}); + QUnit.test('playlists with segments', function(assert) { const dashPlaylists = [{ attributes: { diff --git a/test/toPlaylists.test.js b/test/toPlaylists.test.js index f41be73..fd0528e 100644 --- a/test/toPlaylists.test.js +++ b/test/toPlaylists.test.js @@ -85,6 +85,181 @@ QUnit.test('segment base', function(assert) { assert.deepEqual(toPlaylists(representations), playlists); }); +QUnit.test('playlist with content steering BaseURLs', function(assert) { + const representations = [ + { + attributes: { + bandwidth: 5000000, + baseUrl: 'https://cdn1.example.com/', + clientOffset: 0, + codecs: 'avc1.64001e', + height: 404, + id: 'test', + mimeType: 'video/mp4', + periodStart: 0, + role: { + value: 'main' + }, + serviceLocation: 'alpha', + sourceDuration: 0, + type: 'dyanmic', + width: 720 + }, + segmentInfo: { + template: {} + } + }, + { + attributes: { + bandwidth: 5000000, + baseUrl: 'https://cdn2.example.com/', + clientOffset: 0, + codecs: 'avc1.64001e', + height: 404, + id: 'test', + mimeType: 'video/mp4', + periodStart: 0, + role: { + value: 'main' + }, + serviceLocation: 'beta', + sourceDuration: 0, + type: 'dyanmic', + width: 720 + }, + segmentInfo: { + template: {} + } + }, + { + attributes: { + bandwidth: 256, + baseUrl: 'https://example.com/en.vtt', + clientOffset: 0, + id: 'en', + lang: 'en', + mimeType: 'text/vtt', + periodStart: 0, + role: {}, + sourceDuration: 0, + type: 'dyanmic' + }, + segmentInfo: {} + }, + { + attributes: { + bandwidth: 256, + baseUrl: 'https://example.com/en.vtt', + clientOffset: 0, + id: 'en', + lang: 'en', + mimeType: 'text/vtt', + periodStart: 0, + role: {}, + sourceDuration: 0, + type: 'dyanmic' + }, + segmentInfo: {} + } + ]; + + const playlists = [{ + attributes: { + bandwidth: 5000000, + baseUrl: 'https://cdn1.example.com/', + clientOffset: 0, + codecs: 'avc1.64001e', + duration: 0, + height: 404, + id: 'test', + mimeType: 'video/mp4', + periodStart: 0, + role: { + value: 'main' + }, + serviceLocation: 'alpha', + sourceDuration: 0, + type: 'dyanmic', + width: 720 + }, + segments: [ + { + duration: 0, + map: { + resolvedUri: 'https://cdn1.example.com/', + uri: '' + }, + number: 1, + presentationTime: 0, + resolvedUri: 'https://cdn1.example.com/', + timeline: 0, + uri: '' + } + ] + }, { + attributes: { + bandwidth: 5000000, + baseUrl: 'https://cdn2.example.com/', + clientOffset: 0, + codecs: 'avc1.64001e', + duration: 0, + height: 404, + id: 'test', + mimeType: 'video/mp4', + periodStart: 0, + role: { + value: 'main' + }, + serviceLocation: 'beta', + sourceDuration: 0, + type: 'dyanmic', + width: 720 + }, + segments: [ + { + duration: 0, + map: { + resolvedUri: 'https://cdn2.example.com/', + uri: '' + }, + number: 1, + presentationTime: 0, + resolvedUri: 'https://cdn2.example.com/', + timeline: 0, + uri: '' + } + ] + }, { + attributes: { + bandwidth: 256, + baseUrl: 'https://example.com/en.vtt', + clientOffset: 0, + id: 'en', + lang: 'en', + mimeType: 'text/vtt', + periodStart: 0, + role: {}, + sourceDuration: 0, + type: 'dyanmic' + } + }, { + attributes: { + bandwidth: 256, + baseUrl: 'https://example.com/en.vtt', + clientOffset: 0, + id: 'en', + lang: 'en', + mimeType: 'text/vtt', + periodStart: 0, + role: {}, + sourceDuration: 0, + type: 'dyanmic' + } + }]; + + assert.deepEqual(toPlaylists(representations), playlists); +}); + QUnit.test('segment base with sidx', function(assert) { const representations = [{ attributes: {