From 7dbe0e0553ab2d3810f2301a8b6fa2f59e3a802a Mon Sep 17 00:00:00 2001 From: Matthew Neil Date: Wed, 17 Jan 2018 20:20:51 -0500 Subject: [PATCH 1/2] add inherited BaseURL and alternative BaseURL support --- src/inheritAttributes.js | 237 +++++++++++++++++++++++++++++++-------- 1 file changed, 189 insertions(+), 48 deletions(-) diff --git a/src/inheritAttributes.js b/src/inheritAttributes.js index 12ab7606..b3188596 100644 --- a/src/inheritAttributes.js +++ b/src/inheritAttributes.js @@ -5,75 +5,216 @@ import { findChildren, getContent } from './utils/xml'; import resolveUrl from './resolveUrl'; import errors from './errors'; -export const rep = mpdAttributes => (period, periodIndex) => { - const adaptationSets = findChildren(period, 'AdaptationSet'); - - const representationsByAdaptationSet = adaptationSets.map(adaptationSet => { - const adaptationSetAttributes = getAttributes(adaptationSet); - - const role = findChildren(adaptationSet, 'Role')[0]; - const roleAttributes = { role: getAttributes(role) }; - - const attrs = shallowMerge({ periodIndex }, - mpdAttributes, - adaptationSetAttributes, - roleAttributes); - - const segmentTemplate = findChildren(adaptationSet, 'SegmentTemplate')[0]; - const segmentTimeline = - segmentTemplate && findChildren(segmentTemplate, 'SegmentTimeline')[0]; - const segmentList = findChildren(adaptationSet, 'SegmentList')[0]; - const segmentBase = findChildren(adaptationSet, 'SegmentBase')[0]; - - const segmentInfo = { - template: segmentTemplate && getAttributes(segmentTemplate), - timeline: segmentTimeline && - findChildren(segmentTimeline, 'S').map(s => getAttributes(s)), - list: segmentList && getAttributes(segmentList), - base: segmentBase && getAttributes(segmentBase) - }; +/** + * 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 {Node[]} baseUrlElements + * List of BaseURL nodes from the mpd + * @return {string[]} + * List of resolved urls + */ +export const buildBaseUrls = (referenceUrls, baseUrlElements) => { + if (!baseUrlElements.length) { + return referenceUrls; + } - const representations = findChildren(adaptationSet, 'Representation'); + return flatten( + baseUrlElements.map( + baseUrlElement => referenceUrls.map( + reference => resolveUrl(reference, getContent(baseUrlElement))))); +}; - const inherit = representation => { - // vtt tracks may use single file in BaseURL - const baseUrlElement = findChildren(representation, 'BaseURL')[0]; - const baseUrl = baseUrlElement ? getContent(baseUrlElement) : ''; - const attributes = shallowMerge(attrs, - getAttributes(representation), - { url: baseUrl }); +/** + * Contains all Segment information for its containing AdaptationSet + * + * @typedef {Object} SegmentInformation + * @property {Object|undefined} template + * Contains the attributes for the SegmentTemplate node + * @property {Object[]|undefined} timeline + * Contains a list of atrributes for each S node within the SegmentTimeline node + * @property {Object|undefined} list + * Contains the attributes for the SegmentList node + * @property {Object|undefined} base + * Contains the attributes for the SegmentBase node + */ + +/** + * Returns all available Segment information contained within the AdaptationSet node + * + * @param {Node} adaptationSet + * The AdaptationSet node to get Segment information from + * @return {SegmentInformation} + * The Segment information contained within the provided AdaptationSet + */ +export const getSegmentInformation = (adaptationSet) => { + const segmentTemplate = findChildren(adaptationSet, 'SegmentTemplate')[0]; + const segmentTimeline = + segmentTemplate && findChildren(segmentTemplate, 'SegmentTimeline')[0]; + const segmentList = findChildren(adaptationSet, 'SegmentList')[0]; + const segmentBase = findChildren(adaptationSet, 'SegmentBase')[0]; + + return { + template: segmentTemplate && getAttributes(segmentTemplate), + timeline: segmentTimeline && + findChildren(segmentTimeline, 'S').map(s => getAttributes(s)), + list: segmentList && getAttributes(segmentList), + base: segmentBase && getAttributes(segmentBase) + }; +}; - return { attributes, segmentInfo }; +/** + * Contains Segment information and attributes needed to construct a Playlist object + * from a Representation + * + * @typedef {Object} RepresentationInformation + * @property {SegmentInformation} segmentInfo + * Segment information for this Representation + * @property {Object} attributes + * Inherited attributes for this Representation + */ + +/** + * Maps a Representation node to an object containing Segment information and attributes + * + * @name inheritBaseUrlsCallback + * @function + * @param {Node} representation + * Representation node from the mpd + * @return {RepresentationInformation} + * Representation information needed to construct a Playlist object + */ + +/** + * Returns a callback for Array.prototype.map for mapping Representation nodes to + * Segment information and attributes using inherited BaseURL nodes. + * + * @param {Object} adaptationSetAttributes + * Contains attributes inherited by the AdaptationSet + * @param {string[]} adaptationSetBaseUrls + * Contains list of resolved base urls inherited by the AdaptationSet + * @param {SegmentInformation} segmentInfo + * Contains Segment information for the AdaptationSet + * @return {inheritBaseUrlsCallback} + * Callback map function + */ +export const inheritBaseUrls = +(adaptationSetAttributes, adaptationSetBaseUrls, segmentInfo) => (representation) => { + const repBaseUrlElements = findChildren(representation, 'BaseURL'); + const repBaseUrls = buildBaseUrls(adaptationSetBaseUrls, repBaseUrlElements); + + // vtt tracks may use single file in BaseURL + const url = repBaseUrlElements[0] ? getContent(repBaseUrlElements[0]) : ''; + + const attributes = shallowMerge(adaptationSetAttributes, + getAttributes(representation), + { url }); + + return repBaseUrls.map(baseUrl => { + return { + segmentInfo, + attributes: shallowMerge(attributes, { baseUrl }) }; - - return representations.map(inherit); }); +}; - return flatten(representationsByAdaptationSet); +/** + * Maps an AdaptationSet node to a list of Representation information objects + * + * @name toRepresentationsCallback + * @function + * @param {Node} adaptationSet + * AdaptationSet node from the mpd + * @return {RepresentationInformation[]} + * List of objects containing Representaion information + */ + +/** + * Returns a callback for Array.prototype.map for mapping AdaptationSet nodes to a list of + * Representation information objects + * + * @param {Object} periodAttributes + * Contains attributes inherited by the Period + * @param {string[]} periodBaseUrls + * Contains list of resolved base urls inherited by the Period + * @return {toRepresentationsCallback} + * Callback map function + */ +export const toRepresentations = +(periodAttributes, periodBaseUrls) => (adaptationSet) => { + const adaptationSetAttributes = getAttributes(adaptationSet); + const adaptationSetBaseUrls = buildBaseUrls(periodBaseUrls); + const role = findChildren(adaptationSet, 'Role')[0]; + const roleAttributes = { role: getAttributes(role) }; + const attrs = shallowMerge(periodAttributes, + adaptationSetAttributes, + roleAttributes); + const segmentInfo = getSegmentInformation(adaptationSet); + const representations = findChildren(adaptationSet, 'Representation'); + + return flatten( + representations.map(inheritBaseUrls(attrs, adaptationSetBaseUrls, segmentInfo))); }; -export const representationsByPeriod = (periods, mpdAttributes) => { - return periods.map(rep(mpdAttributes)); +/** + * Maps an Period node to a list of Representation inforamtion objects for all + * AdaptationSet nodes contained within the Period + * + * @name toAdaptationSetsCallback + * @function + * @param {Node} period + * Period node from the mpd + * @param {number} periodIndex + * Index of the Period within the mpd + * @return {RepresentationInformation[]} + * List of objects containing Representaion information + */ + +/** + * Returns a callback for Array.prototype.map for mapping Period nodes to a list of + * Representation information objects + * + * @param {Object} mpdAttributes + * Contains attributes inherited by the mpd + * @param {string[]} mpdBaseUrls + * Contains list of resolved base urls inherited by the mpd + * @return {toAdaptationSetsCallback} + * Callback map function + */ +export const toAdaptationSets = (mpdAttributes, mpdBaseUrls) => (period, periodIndex) => { + const periodBaseUrls = buildBaseUrls(mpdBaseUrls, findChildren(period, 'BaseURL')); + const periodAttributes = shallowMerge({ periodIndex }, mpdAttributes); + const adaptationSets = findChildren(period, 'AdaptationSet'); + + return flatten(adaptationSets.map(toRepresentations(periodAttributes, periodBaseUrls))); }; +/** + * Traverses the mpd xml tree to generate a list of Representation information objects + * that have inherited attributes from parent nodes + * + * @param {Node} mpd + * The root node of the mpd + * @param {string} manifestUri + * The uri of the source mpd + * @return {RepresentationInformation[]} + * List of objects containing Representation information + */ export const inheritAttributes = (mpd, manifestUri = '') => { const periods = findChildren(mpd, 'Period'); - if (!periods.length || - periods.length && - periods.length !== 1) { + if (periods.length !== 1) { // TODO add support for multiperiod throw new Error(errors.INVALID_NUMBER_OF_PERIOD); } const mpdAttributes = getAttributes(mpd); - const baseUrlElement = findChildren(mpd, 'BaseURL')[0]; - const baseUrl = baseUrlElement ? getContent(baseUrlElement) : ''; + const mpdBaseUrls = buildBaseUrls([ manifestUri ], findChildren(mpd, 'BaseURL')); - mpdAttributes.baseUrl = resolveUrl(manifestUri, baseUrl); mpdAttributes.sourceDuration = mpdAttributes.mediaPresentationDuration ? parseDuration(mpdAttributes.mediaPresentationDuration) : 0; - return flatten(representationsByPeriod(periods, mpdAttributes)); + return flatten(periods.map(toAdaptationSets(mpdAttributes, mpdBaseUrls))); }; From f424e1adbd5a4152e3ae649209b4e79495d2e81b Mon Sep 17 00:00:00 2001 From: Matthew Neil Date: Fri, 19 Jan 2018 17:34:22 -0500 Subject: [PATCH 2/2] add tests --- src/inheritAttributes.js | 17 +- src/toM3u8.js | 7 +- test/inheritAttributes.test.js | 416 ++++++++++++++++++++++++++++++++- test/toM3u8.test.js | 8 +- 4 files changed, 422 insertions(+), 26 deletions(-) diff --git a/src/inheritAttributes.js b/src/inheritAttributes.js index b3188596..9f160c79 100644 --- a/src/inheritAttributes.js +++ b/src/inheritAttributes.js @@ -21,9 +21,9 @@ export const buildBaseUrls = (referenceUrls, baseUrlElements) => { } return flatten( - baseUrlElements.map( - baseUrlElement => referenceUrls.map( - reference => resolveUrl(reference, getContent(baseUrlElement))))); + referenceUrls.map( + reference => baseUrlElements.map( + baseUrlElement => resolveUrl(reference, getContent(baseUrlElement))))); }; /** @@ -103,13 +103,7 @@ export const inheritBaseUrls = (adaptationSetAttributes, adaptationSetBaseUrls, segmentInfo) => (representation) => { const repBaseUrlElements = findChildren(representation, 'BaseURL'); const repBaseUrls = buildBaseUrls(adaptationSetBaseUrls, repBaseUrlElements); - - // vtt tracks may use single file in BaseURL - const url = repBaseUrlElements[0] ? getContent(repBaseUrlElements[0]) : ''; - - const attributes = shallowMerge(adaptationSetAttributes, - getAttributes(representation), - { url }); + const attributes = shallowMerge(adaptationSetAttributes, getAttributes(representation)); return repBaseUrls.map(baseUrl => { return { @@ -144,7 +138,8 @@ export const inheritBaseUrls = export const toRepresentations = (periodAttributes, periodBaseUrls) => (adaptationSet) => { const adaptationSetAttributes = getAttributes(adaptationSet); - const adaptationSetBaseUrls = buildBaseUrls(periodBaseUrls); + const adaptationSetBaseUrls = buildBaseUrls(periodBaseUrls, + findChildren(adaptationSet, 'BaseURL')); const role = findChildren(adaptationSet, 'Role')[0]; const roleAttributes = { role: getAttributes(role) }; const attrs = shallowMerge(periodAttributes, diff --git a/src/toM3u8.js b/src/toM3u8.js index b94bd696..e5ed41bc 100644 --- a/src/toM3u8.js +++ b/src/toM3u8.js @@ -16,10 +16,11 @@ export const formatAudioPlaylist = ({ attributes, segments }) => { export const formatVttPlaylist = ({ attributes, segments }) => { if (typeof segments === 'undefined') { + // vtt tracks may use single file in BaseURL segments = [{ - uri: attributes.url, + uri: attributes.baseUrl, timeline: attributes.periodIndex, - resolvedUri: attributes.url || '', + resolvedUri: attributes.baseUrl || '', duration: attributes.sourceDuration }]; } @@ -32,7 +33,7 @@ export const formatVttPlaylist = ({ attributes, segments }) => { uri: '', endList: true, timeline: attributes.periodIndex, - resolvedUri: attributes.url || '', + resolvedUri: attributes.baseUrl || '', segments }; }; diff --git a/test/inheritAttributes.test.js b/test/inheritAttributes.test.js index f0cc3f3d..888b3a87 100644 --- a/test/inheritAttributes.test.js +++ b/test/inheritAttributes.test.js @@ -1,8 +1,174 @@ -import { inheritAttributes } from '../src/inheritAttributes'; +import { + inheritAttributes, + buildBaseUrls, + getSegmentInformation +} from '../src/inheritAttributes'; import { stringToMpdXml } from '../src/stringToMpdXml'; import errors from '../src/errors'; import QUnit from 'qunit'; +QUnit.module('buildBaseUrls'); + +QUnit.test('returns reference urls when no BaseURL nodes', function(assert) { + const reference = ['https://example.com/', '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 node = [{ textContent: 'bar/' }]; + const expected = ['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 node = [{ textContent: 'bar/' }]; + const expected = ['https://example.com/bar/', 'https://foo.com/bar/']; + + assert.deepEqual(buildBaseUrls(reference, node), expected, + 'base url for each reference url'); +}); + +QUnit.test('multiple BaseURL nodes with single reference url', function(assert) { + const reference = ['https://example.com/']; + const nodes = [{ textContent: 'bar/' }, { textContent: 'baz/' }]; + const expected = ['https://example.com/bar/', '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 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/' + ]; + + assert.deepEqual(buildBaseUrls(reference, nodes), expected, 'creates all base urls'); +}); + +QUnit.test('absolute BaseURL overwrites reference', function(assert) { + const reference = ['https://example.com']; + const node = [{ textContent: 'https://foo.com/bar/' }]; + const expected = ['https://foo.com/bar/']; + + assert.deepEqual(buildBaseUrls(reference, node), expected, + 'absolute url overwrites reference'); +}); + +QUnit.module('getSegmentInformation'); + +QUnit.test('undefined Segment information when no Segment nodes', function(assert) { + const adaptationSet = { childNodes: [] }; + const expected = { + template: void 0, + timeline: void 0, + list: void 0, + base: void 0 + }; + + assert.deepEqual(getSegmentInformation(adaptationSet), expected, + 'undefined segment info'); +}); + +QUnit.test('gets SegmentTemplate attributes', function(assert) { + const adaptationSet = { + childNodes: [{ + tagName: 'SegmentTemplate', + attributes: [{ name: 'media', value: 'video.mp4' }], + childNodes: [] + }] + }; + const expected = { + template: { media: 'video.mp4' }, + timeline: void 0, + list: void 0, + base: void 0 + }; + + assert.deepEqual(getSegmentInformation(adaptationSet), expected, + 'SegmentTemplate info'); +}); + +QUnit.test('gets SegmentList attributes', function(assert) { + const adaptationSet = { + childNodes: [{ + tagName: 'SegmentList', + attributes: [{ name: 'duration', value: '10' }] + }] + }; + const expected = { + template: void 0, + timeline: void 0, + list: { duration: '10' }, + base: void 0 + }; + + assert.deepEqual(getSegmentInformation(adaptationSet), expected, + 'SegmentList info'); +}); + +QUnit.test('gets SegmentBase attributes', function(assert) { + const adaptationSet = { + childNodes: [{ + tagName: 'SegmentBase', + attributes: [{ name: 'duration', value: '10' }] + }] + }; + const expected = { + template: void 0, + timeline: void 0, + list: void 0, + base: { duration: '10' } + }; + + assert.deepEqual(getSegmentInformation(adaptationSet), expected, + 'SegmentBase info'); +}); + +QUnit.test('gets SegmentTemplate and SegmentTimeline attributes', function(assert) { + const adaptationSet = { + childNodes: [{ + tagName: 'SegmentTemplate', + attributes: [{ name: 'media', value: 'video.mp4' }], + childNodes: [{ + tagName: 'SegmentTimeline', + childNodes: [{ + tagName: 'S', + attributes: [{ name: 'd', value: '10' }] + }, { + tagName: 'S', + attributes: [{ name: 'd', value: '5' }] + }, { + tagName: 'S', + attributes: [{ name: 'd', value: '7' }] + }] + }] + }] + }; + const expected = { + template: { media: 'video.mp4' }, + timeline: [{ d: '10' }, { d: '5' }, { d: '7' }], + list: void 0, + base: void 0 + }; + + assert.deepEqual(getSegmentInformation(adaptationSet), expected, + 'SegmentTemplate and SegmentTimeline info'); +}); + QUnit.module('inheritAttributes'); QUnit.test('needs at least one Period', function(assert) { @@ -10,13 +176,13 @@ QUnit.test('needs at least one Period', function(assert) { new RegExp(errors.INVALID_NUMBER_OF_PERIOD)); }); -QUnit.test('end to end', function(assert) { +QUnit.test('end to end - basic', function(assert) { const actual = inheritAttributes(stringToMpdXml( ` - https://www.example.com/base + https://www.example.com/base/ - + + https://www.example.com/base/ + + foo/ + + bar/ + + + + buzz/ + + + + + https://example.com/en.vtt + + + + + ` + )); + + const expected = [{ + attributes: { + bandwidth: '5000000', + baseUrl: 'https://www.example.com/base/foo/bar/buzz/', + codecs: 'avc1.64001e', + height: '404', + id: 'test', + mediaPresentationDuration: 'PT30S', + mimeType: 'video/mp4', + periodIndex: 0, + role: { + value: 'main' + }, sourceDuration: 30, - url: 'https://example.com/en.vtt' + width: '720' + }, + segmentInfo: { + base: undefined, + list: undefined, + template: {}, + timeline: undefined + } + }, { + attributes: { + bandwidth: '256', + baseUrl: 'https://example.com/en.vtt', + id: 'en', + lang: 'en', + mediaPresentationDuration: 'PT30S', + mimeType: 'text/vtt', + periodIndex: 0, + role: {}, + sourceDuration: 30 }, segmentInfo: { base: undefined, @@ -84,3 +324,163 @@ QUnit.test('end to end', function(assert) { assert.equal(actual.length, 2); assert.deepEqual(actual, expected); }); + +QUnit.test('end to end - alternate BaseURLs', function(assert) { + const actual = inheritAttributes(stringToMpdXml( + ` + + https://www.example.com/base/ + https://www.test.com/base/ + + + segments/ + media/ + + + + + + + + https://example.com/en.vtt + + + + + ` + )); + + const expected = [{ + attributes: { + bandwidth: '5000000', + baseUrl: 'https://www.example.com/base/segments/', + codecs: 'avc1.64001e', + height: '404', + id: 'test', + mediaPresentationDuration: 'PT30S', + mimeType: 'video/mp4', + periodIndex: 0, + role: { + value: 'main' + }, + sourceDuration: 30, + width: '720' + }, + segmentInfo: { + base: undefined, + list: undefined, + template: {}, + timeline: undefined + } + }, { + attributes: { + bandwidth: '5000000', + baseUrl: 'https://www.example.com/base/media/', + codecs: 'avc1.64001e', + height: '404', + id: 'test', + mediaPresentationDuration: 'PT30S', + mimeType: 'video/mp4', + periodIndex: 0, + role: { + value: 'main' + }, + sourceDuration: 30, + width: '720' + }, + segmentInfo: { + base: undefined, + list: undefined, + template: {}, + timeline: undefined + } + }, { + attributes: { + bandwidth: '5000000', + baseUrl: 'https://www.test.com/base/segments/', + codecs: 'avc1.64001e', + height: '404', + id: 'test', + mediaPresentationDuration: 'PT30S', + mimeType: 'video/mp4', + periodIndex: 0, + role: { + value: 'main' + }, + sourceDuration: 30, + width: '720' + }, + segmentInfo: { + base: undefined, + list: undefined, + template: {}, + timeline: undefined + } + }, { + attributes: { + bandwidth: '5000000', + baseUrl: 'https://www.test.com/base/media/', + codecs: 'avc1.64001e', + height: '404', + id: 'test', + mediaPresentationDuration: 'PT30S', + mimeType: 'video/mp4', + periodIndex: 0, + role: { + value: 'main' + }, + sourceDuration: 30, + width: '720' + }, + segmentInfo: { + base: undefined, + list: undefined, + template: {}, + timeline: undefined + } + }, { + attributes: { + bandwidth: '256', + baseUrl: 'https://example.com/en.vtt', + id: 'en', + lang: 'en', + mediaPresentationDuration: 'PT30S', + mimeType: 'text/vtt', + periodIndex: 0, + role: {}, + sourceDuration: 30 + }, + segmentInfo: { + base: undefined, + list: undefined, + template: undefined, + timeline: undefined + } + }, { + attributes: { + bandwidth: '256', + baseUrl: 'https://example.com/en.vtt', + id: 'en', + lang: 'en', + mediaPresentationDuration: 'PT30S', + mimeType: 'text/vtt', + periodIndex: 0, + role: {}, + sourceDuration: 30 + }, + segmentInfo: { + base: undefined, + list: undefined, + template: undefined, + timeline: undefined + } + }]; + + assert.equal(actual.length, 6); + assert.deepEqual(actual, expected); +}); diff --git a/test/toM3u8.test.js b/test/toM3u8.test.js index fb5faee6..097afa79 100644 --- a/test/toM3u8.test.js +++ b/test/toM3u8.test.js @@ -43,7 +43,7 @@ QUnit.test('playlists', function(assert) { bandwidth: '20000', periodIndex: 1, mimeType: 'text/vtt', - url: 'https://www.example.com/vtt' + baseUrl: 'https://www.example.com/vtt' } }, { attributes: { @@ -52,7 +52,7 @@ QUnit.test('playlists', function(assert) { bandwidth: '10000', periodIndex: 1, mimeType: 'text/vtt', - url: 'https://www.example.com/vtt' + baseUrl: 'https://www.example.com/vtt' } }]; @@ -235,7 +235,7 @@ QUnit.test('playlists with segments', function(assert) { bandwidth: '20000', periodIndex: 1, mimeType: 'text/vtt', - url: 'https://www.example.com/vtt' + baseUrl: 'https://www.example.com/vtt' }, segments: [{ uri: '', @@ -263,7 +263,7 @@ QUnit.test('playlists with segments', function(assert) { bandwidth: '10000', periodIndex: 1, mimeType: 'text/vtt', - url: 'https://www.example.com/vtt' + baseUrl: 'https://www.example.com/vtt' }, segments: [{ uri: '',