diff --git a/src/dash/DashAdapter.js b/src/dash/DashAdapter.js index d74fd5efbc..9ad936d2f7 100644 --- a/src/dash/DashAdapter.js +++ b/src/dash/DashAdapter.js @@ -37,6 +37,7 @@ import ManifestInfo from './vo/ManifestInfo'; import Event from './vo/Event'; import FactoryMaker from '../core/FactoryMaker'; import DashManifestModel from './models/DashManifestModel'; +import PatchManifestModel from './models/PatchManifestModel'; /** * @module DashAdapter @@ -45,6 +46,7 @@ import DashManifestModel from './models/DashManifestModel'; function DashAdapter() { let instance, dashManifestModel, + patchManifestModel, voPeriods, voAdaptations, currentMediaInfo, @@ -57,6 +59,7 @@ function DashAdapter() { function setup() { dashManifestModel = DashManifestModel(context).getInstance(); + patchManifestModel = PatchManifestModel(context).getInstance(); reset(); } @@ -633,6 +636,48 @@ function DashAdapter() { return dashManifestModel.getManifestUpdatePeriod(manifest, latencyOfLastUpdate); } + /** + * Returns the publish time from the manifest + * @param {object} manifest + * @returns {Date|null} publishTime + * @memberOf module:DashAdapter + * @instance + */ + function getPublishTime(manifest) { + return dashManifestModel.getPublishTime(manifest); + } + + /** + * Returns the patch location of the MPD if one exists and it is still valid + * @param {object} manifest + * @returns {(String|null)} patch location + * @memberOf module:DashAdapter + * @instance + */ + function getPatchLocation(manifest) { + const patchLocation = dashManifestModel.getPatchLocation(manifest); + const publishTime = dashManifestModel.getPublishTime(manifest); + + // short-circuit when no patch location or publish time exists + if (!patchLocation || !publishTime) { + return null; + } + + // if a ttl is provided, ensure patch location has not expired + if (patchLocation.hasOwnProperty('ttl') && publishTime) { + // attribute describes number of seconds as a double + const ttl = parseFloat(patchLocation.ttl) * 1000; + + // check if the patch location has expired, if so do not consider it + if (publishTime.getTime() + ttl <= new Date().getTime()) { + return null; + } + } + + // the patch location exists and, if a ttl applies, has not expired + return patchLocation.__text; + } + /** * Checks if the manifest has a DVB profile * @param {object} manifest @@ -645,6 +690,15 @@ function DashAdapter() { return dashManifestModel.hasProfile(manifest, PROFILE_DVB); } + /** + * Checks if the manifest is actually just a patch manifest + * @param {object} manifest + * @return {boolean} + */ + function getIsPatch(manifest) { + return patchManifestModel.getIsPatch(manifest); + } + /** * * @param {object} node @@ -757,6 +811,132 @@ function DashAdapter() { currentMediaInfo = {}; } + /** + * Checks if the supplied manifest is compatible for application of the supplied patch + * @param {object} manifest + * @param {object} patch + * @return {boolean} + */ + function isPatchValid(manifest, patch) { + let manifestId = dashManifestModel.getId(manifest); + let patchManifestId = patchManifestModel.getMpdId(patch); + let manifestPublishTime = dashManifestModel.getPublishTime(manifest); + let patchPublishTime = patchManifestModel.getPublishTime(patch); + let originalManifestPublishTime = patchManifestModel.getOriginalPublishTime(patch); + + // Patches are considered compatible if the following are true + // - MPD@id == Patch@mpdId + // - MPD@publishTime == Patch@originalPublishTime + // - MPD@publishTime < Patch@publishTime + // - All values in comparison exist + return !!(manifestId && patchManifestId && (manifestId == patchManifestId) && + manifestPublishTime && originalManifestPublishTime && (manifestPublishTime.getTime() == originalManifestPublishTime.getTime()) && + patchPublishTime && (manifestPublishTime.getTime() < patchPublishTime.getTime())); + } + + /** + * Takes a given patch and applies it to the provided manifest, assumes patch is valid for manifest + * @param {object} manifest + * @param {object} patch + */ + function applyPatchToManifest(manifest, patch) { + // get all operations from the patch and apply them in document order + patchManifestModel.getPatchOperations(patch) + .forEach((operation) => { + let result = operation.getMpdTarget(manifest); + + // operation supplies a path that doesn't match mpd, skip + if (result === null) { + return; + } + + let {name, target, leaf} = result; + + // short circuit for attribute selectors + if (operation.xpath.findsAttribute()) { + switch (operation.action) { + case 'add': + case 'replace': + // add and replace are just setting the value + target[name] = operation.value; + break; + case 'remove': + // remove is deleting the value + delete target[name]; + break; + } + return; + } + + // determine the relative insert position prior to possible removal + let relativePosition = (target[name + '_asArray'] || []).indexOf(leaf); + let insertBefore = (operation.position === 'prepend' || operation.position === 'before'); + + // perform removal operation first, we have already capture the appropriate relative position + if (operation.action === 'remove' || operation.action === 'replace') { + // note that we ignore the 'ws' attribute of patch operations as it does not effect parsed mpd operations + + // purge the directly named entity + delete target[name]; + + // if we did have a positional reference we need to purge from array set and restore X2JS proper semantics + if (relativePosition != -1) { + let targetArray = target[name + '_asArray']; + targetArray.splice(relativePosition, 1); + if (targetArray.length > 1) { + target[name] = targetArray; + } else if (targetArray.length == 1) { + // xml parsing semantics, singular asArray must be non-array in the unsuffixed key + target[name] = targetArray[0]; + } else { + // all nodes of this type deleted, remove entry + delete target[name + '_asArray']; + } + } + } + + // Perform any add/replace operations now, technically RFC5261 only allows a single element to take the + // place of a replaced element while the add case allows an arbitrary number of children. + // Due to the both operations requiring the same insertion logic they have been combined here and we will + // not enforce single child operations for replace, assertions should be made at patch parse time if necessary + if (operation.action === 'add' || operation.action === 'replace') { + // value will be an object with element name keys pointing to arrays of objects + Object.keys(operation.value).forEach((insert) => { + let insertNodes = operation.value[insert]; + + let updatedNodes = target[insert + '_asArray'] || []; + if (updatedNodes.length === 0 && target[insert]) { + updatedNodes.push(target[insert]); + } + + if (updatedNodes.length === 0) { + // no original nodes for this element type + updatedNodes = insertNodes; + } else { + // compute the position we need to insert at, default to end of set + let position = updatedNodes.length; + if (insert == name && relativePosition != -1) { + // if the inserted element matches the operation target (not leaf) and there is a relative position we + // want the inserted position to be set such that our insertion is relative to original position + // since replace has modified the array length we reduce the insert point by 1 + position = relativePosition + (insertBefore ? 0 : 1) + (operation.action == 'replace' ? -1 : 0); + } else { + // otherwise we are in an add append/prepend case or replace case that removed the target name completely + position = insertBefore ? 0 : updatedNodes.length; + } + + // we dont have to perform element removal for the replace case as that was done above + updatedNodes.splice.apply(updatedNodes, [position, 0].concat(insertNodes)); + } + + // now we properly reset the element keys on the target to match parsing semantics + target[insert + '_asArray'] = updatedNodes; + target[insert] = updatedNodes.length == 1 ? updatedNodes[0] : updatedNodes; + }); + } + }); + } + // #endregion PUBLIC FUNCTIONS // #region PRIVATE FUNCTIONS @@ -983,8 +1163,11 @@ function DashAdapter() { getDuration: getDuration, getRegularPeriods: getRegularPeriods, getLocation: getLocation, + getPatchLocation: getPatchLocation, getManifestUpdatePeriod: getManifestUpdatePeriod, + getPublishTime, getIsDVB: getIsDVB, + getIsPatch: getIsPatch, getBaseURLsFromElement: getBaseURLsFromElement, getRepresentationSortFunction: getRepresentationSortFunction, getCodec: getCodec, @@ -992,6 +1175,8 @@ function DashAdapter() { getVoPeriods: getVoPeriods, getPeriodById, setCurrentMediaInfo: setCurrentMediaInfo, + isPatchValid: isPatchValid, + applyPatchToManifest: applyPatchToManifest, reset: reset }; diff --git a/src/dash/constants/DashConstants.js b/src/dash/constants/DashConstants.js index 7b8e793996..6cd85535df 100644 --- a/src/dash/constants/DashConstants.js +++ b/src/dash/constants/DashConstants.js @@ -131,6 +131,10 @@ class DashConstants { this.SERVICE_DESCRIPTION_SCOPE = 'Scope'; this.SERVICE_DESCRIPTION_LATENCY = 'Latency'; this.SERVICE_DESCRIPTION_PLAYBACK_RATE = 'PlaybackRate'; + this.PATCH_LOCATION = 'PatchLocation'; + this.PUBLISH_TIME = 'publishTime'; + this.ORIGINAL_PUBLISH_TIME = 'originalPublishTime'; + this.ORIGINAL_MPD_ID = 'mpdId'; } constructor () { diff --git a/src/dash/models/DashManifestModel.js b/src/dash/models/DashManifestModel.js index 56a8e6ea2c..f345c82e54 100644 --- a/src/dash/models/DashManifestModel.js +++ b/src/dash/models/DashManifestModel.js @@ -341,6 +341,10 @@ function DashManifestModel() { return isDynamic; } + function getId(manifest) { + return (manifest && manifest[DashConstants.ID]) || null; + } + function hasProfile(manifest, profile) { let has = false; @@ -378,6 +382,10 @@ function DashManifestModel() { return isNaN(delay) ? delay : Math.max(delay - latencyOfLastUpdate, 1); } + function getPublishTime(manifest) { + return manifest && manifest.hasOwnProperty(DashConstants.PUBLISH_TIME) ? new Date(manifest[DashConstants.PUBLISH_TIME]) : null; + } + function getRepresentationCount(adaptation) { return adaptation && Array.isArray(adaptation.Representation_asArray) ? adaptation.Representation_asArray.length : 0; } @@ -748,6 +756,10 @@ function DashManifestModel() { if (manifest.hasOwnProperty(DashConstants.MAX_SEGMENT_DURATION)) { mpd.maxSegmentDuration = manifest.maxSegmentDuration; } + + if (manifest.hasOwnProperty(DashConstants.PUBLISH_TIME)) { + mpd.publishTime = new Date(manifest.publishTime); + } } return mpd; @@ -1040,6 +1052,18 @@ function DashManifestModel() { return undefined; } + function getPatchLocation(manifest) { + if (manifest && manifest.hasOwnProperty(DashConstants.PATCH_LOCATION)) { + // only include support for single patch location currently + manifest.PatchLocation = manifest.PatchLocation_asArray[0]; + + return manifest.PatchLocation; + } + + // no patch location provided + return undefined; + } + function getSuggestedPresentationDelay(mpd) { return mpd && mpd.hasOwnProperty(DashConstants.SUGGESTED_PRESENTATION_DELAY) ? mpd.suggestedPresentationDelay : null; } @@ -1135,10 +1159,12 @@ function DashManifestModel() { getLabelsForAdaptation: getLabelsForAdaptation, getContentProtectionData: getContentProtectionData, getIsDynamic: getIsDynamic, + getId: getId, hasProfile: hasProfile, getDuration: getDuration, getBandwidth: getBandwidth, getManifestUpdatePeriod: getManifestUpdatePeriod, + getPublishTime: getPublishTime, getRepresentationCount: getRepresentationCount, getBitrateListForAdaptation: getBitrateListForAdaptation, getRepresentationFor: getRepresentationFor, @@ -1154,6 +1180,7 @@ function DashManifestModel() { getBaseURLsFromElement: getBaseURLsFromElement, getRepresentationSortFunction: getRepresentationSortFunction, getLocation: getLocation, + getPatchLocation: getPatchLocation, getSuggestedPresentationDelay: getSuggestedPresentationDelay, getAvailabilityStartTime: getAvailabilityStartTime, getServiceDescriptions: getServiceDescriptions, diff --git a/src/dash/models/PatchManifestModel.js b/src/dash/models/PatchManifestModel.js new file mode 100644 index 0000000000..a194282993 --- /dev/null +++ b/src/dash/models/PatchManifestModel.js @@ -0,0 +1,143 @@ +/** + * The copyright in this software is being made available under the BSD License, + * included below. This software may be subject to other third party and contributor + * rights, including patent rights, and no such rights are granted under this license. + * + * Copyright (c) 2013, Dash Industry Forum. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation and/or + * other materials provided with the distribution. + * * Neither the name of Dash Industry Forum nor the names of its + * contributors may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS AS IS AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT + * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, + * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ +import DashConstants from '../constants/DashConstants'; +import FactoryMaker from '../../core/FactoryMaker'; +import Debug from '../../core/Debug'; +import SimpleXPath from '../vo/SimpleXPath'; +import PatchOperation from '../vo/PatchOperation'; + +function PatchManifestModel() { + let instance, + logger; + + const context = this.context; + + function setup() { + logger = Debug(context).getInstance().getLogger(instance); + } + + function getIsPatch(patch) { + return patch && patch.hasOwnProperty(DashConstants.ORIGINAL_MPD_ID) || false; + } + + function getPublishTime(patch) { + return patch && patch.hasOwnProperty(DashConstants.PUBLISH_TIME) ? new Date(patch[DashConstants.PUBLISH_TIME]) : null; + } + + function getOriginalPublishTime(patch) { + return patch && patch.hasOwnProperty(DashConstants.ORIGINAL_PUBLISH_TIME) ? new Date(patch[DashConstants.ORIGINAL_PUBLISH_TIME]) : null; + } + + function getMpdId(patch) { + return (patch && patch[DashConstants.ORIGINAL_MPD_ID]) || null; + } + + function getPatchOperations(patch) { + if (!patch) { + return []; + } + + // Go through the patch operations in order and parse their actions out for usage + return (patch.__children || []).map((nodeContainer) => { + let action = Object.keys(nodeContainer)[0]; + + // we only look add add/remove/replace actions + if (action !== 'add' && action !== 'remove' && action !== 'replace') { + logger.warn(`Ignoring node of invalid action: ${action}`); + return null; + } + + let node = nodeContainer[action]; + let selector = node.sel; + + // add action can have special targeting via the 'type' attribute + if (action === 'add' && node.type) { + if (!node.type.startsWith('@')) { + logger.warn(`Ignoring add action for prefixed namespace declaration: ${node.type}=${node.__text}`); + return null; + } + + // for our purposes adding/replacing attribute are equivalent and we can normalize + // our processing logic by appending the attribute to the selector path + selector = `${selector}/${node.type}`; + } + + let xpath = new SimpleXPath(selector); + if (!xpath.isValid()) { + logger.warn(`Ignoring action with invalid selector: ${action} - ${selector}`); + return null; + } + + let value = null; + if (xpath.findsAttribute()) { + value = node.__text || ''; + } else if (action !== 'remove') { + value = node.__children.reduce((groups, child) => { + // note that this is informed by xml2js parse structure for the __children array + // which will be something like this for each child: + // { + // "": { } + // } + let key = Object.keys(child)[0]; + // we also ignore + if (key !== '#text') { + groups[key] = groups[key] || []; + groups[key].push(child[key]); + } + return groups; + }, {}); + } + + let operation = new PatchOperation(action, xpath, value); + + if (action === 'add') { + operation.position = node.pos; + } + + return operation; + }).filter((operation) => !!operation); + } + + instance = { + getIsPatch: getIsPatch, + getPublishTime: getPublishTime, + getOriginalPublishTime: getOriginalPublishTime, + getMpdId: getMpdId, + getPatchOperations: getPatchOperations + }; + + setup(); + + return instance; +} + +PatchManifestModel.__dashjs_factory_name = 'PatchManifestModel'; +export default FactoryMaker.getSingletonFactory(PatchManifestModel); diff --git a/src/dash/parser/DashParser.js b/src/dash/parser/DashParser.js index df41063c7f..50a9f4a036 100644 --- a/src/dash/parser/DashParser.js +++ b/src/dash/parser/DashParser.js @@ -66,7 +66,7 @@ function DashParser(config) { emptyNodeForm: 'object', stripWhitespaces: false, enableToStringFunc: true, - ignoreRoot: true, + ignoreRoot: false, matchers: matchers }); @@ -95,7 +95,22 @@ function DashParser(config) { } const jsonTime = window.performance.now(); - objectIron.run(manifest); + + // handle full MPD and Patch ironing separately + if (manifest.Patch) { + manifest = manifest.Patch; // drop root reference + // apply iron to patch operations individually + if (manifest.add_asArray) { + manifest.add_asArray.forEach((operand) => objectIron.run(operand)); + } + if (manifest.replace_asArray) { + manifest.replace_asArray.forEach((operand) => objectIron.run(operand)); + } + // note that we don't need to iron remove as they contain no children + } else { + manifest = manifest.MPD; // drop root reference + objectIron.run(manifest); + } const ironedTime = window.performance.now(); logger.info('Parsing complete: ( xml2json: ' + (jsonTime - startTime).toPrecision(3) + 'ms, objectiron: ' + (ironedTime - jsonTime).toPrecision(3) + 'ms, total: ' + ((ironedTime - startTime) / 1000).toPrecision(3) + 's)'); diff --git a/src/dash/parser/objectiron.js b/src/dash/parser/objectiron.js index d9c3cc65f7..62403e1893 100644 --- a/src/dash/parser/objectiron.js +++ b/src/dash/parser/objectiron.js @@ -89,7 +89,7 @@ function ObjectIron(mappers) { return source; } - if ('period' in mappers) { + if (source.Period_asArray && 'period' in mappers) { const periodMapper = mappers.period; const periods = source.Period_asArray; for (let i = 0, len = periods.length; i < len; ++i) { diff --git a/src/dash/vo/Mpd.js b/src/dash/vo/Mpd.js index 187f26596c..6633c2ff93 100644 --- a/src/dash/vo/Mpd.js +++ b/src/dash/vo/Mpd.js @@ -40,9 +40,10 @@ class Mpd { this.availabilityEndTime = Number.POSITIVE_INFINITY; this.timeShiftBufferDepth = Number.POSITIVE_INFINITY; this.maxSegmentDuration = Number.POSITIVE_INFINITY; + this.publishTime = null; this.minimumUpdatePeriod = NaN; this.mediaPresentationDuration = NaN; } } -export default Mpd; \ No newline at end of file +export default Mpd; diff --git a/src/dash/vo/PatchOperation.js b/src/dash/vo/PatchOperation.js new file mode 100644 index 0000000000..166c515ac2 --- /dev/null +++ b/src/dash/vo/PatchOperation.js @@ -0,0 +1,49 @@ +/** + * The copyright in this software is being made available under the BSD License, + * included below. This software may be subject to other third party and contributor + * rights, including patent rights, and no such rights are granted under this license. + * + * Copyright (c) 2013, Dash Industry Forum. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation and/or + * other materials provided with the distribution. + * * Neither the name of Dash Industry Forum nor the names of its + * contributors may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS AS IS AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT + * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, + * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ +/** + * @class + * @ignore + */ +class PatchOperation { + constructor(action, xpath, value) { + this.action = action; + this.xpath = xpath; + this.value = value; + this.position = null; + } + + getMpdTarget(root) { + let isSiblingOperation = this.action === 'remove' || this.action === 'replace' || this.position === 'before' || this.position === 'after'; + return this.xpath.getMpdTarget(root, isSiblingOperation); + } +} + +export default PatchOperation; diff --git a/src/dash/vo/SimpleXPath.js b/src/dash/vo/SimpleXPath.js new file mode 100644 index 0000000000..4c9dd77544 --- /dev/null +++ b/src/dash/vo/SimpleXPath.js @@ -0,0 +1,151 @@ +/** + * The copyright in this software is being made available under the BSD License, + * included below. This software may be subject to other third party and contributor + * rights, including patent rights, and no such rights are granted under this license. + * + * Copyright (c) 2013, Dash Industry Forum. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation and/or + * other materials provided with the distribution. + * * Neither the name of Dash Industry Forum nor the names of its + * contributors may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS AS IS AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT + * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, + * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ +/** + * @class + * @ignore + */ +class SimpleXPath { + constructor(selector) { + // establish validation of the path, to catch unsupported cases + this.valid = selector[0] == '/'; // first check, we only support absolute addressing + + // establish parsed path, example: + // /MPD/Period[@id="foobar"]/AdaptationSet[@id="2"]/SegmentTemplate/SegmentTimeline + this.path = selector.split('/') + .filter((component) => component.length !== 0) // remove excess empty components + .map((component) => { + let parsed = { + name: component + }; + + let qualifierPoint = component.indexOf('['); + if (qualifierPoint != -1) { + parsed.name = component.substring(0, qualifierPoint); + + let qualifier = component.substring(qualifierPoint + 1, component.length - 1); + + // quick sanity check are there additional qualifiers making this invalid + this.valid = this.valid && qualifier.indexOf('[') == -1; + + let equalityPoint = qualifier.indexOf('='); + if (equalityPoint != -1) { + parsed.attribute = { + name: qualifier.substring(1, equalityPoint), // skip the @ + value: qualifier.substring(equalityPoint + 1) + }; + + // check for single and double quoted attribute values + if (['\'', '"'].indexOf(parsed.attribute.value[0]) != -1) { + parsed.attribute.value = parsed.attribute.value.substring(1, parsed.attribute.value.length - 1); + } + } else { + // positional access in xpath is 1-based index + // internal processes will assume 0-based so we normalize that here + parsed.position = parseInt(qualifier, 10) - 1; + } + } + + return parsed; + }); + } + + isValid() { + return this.valid; + } + + findsElement() { + return !this.findsAttribute(); + } + + findsAttribute() { + return this.path[this.path.length - 1].name.startsWith('@'); + } + + getMpdTarget(root, isSiblingOperation) { + let parent = null; + let leaf = root; + // assume root is MPD and we start at next level match + let level = 1; + let name = 'MPD'; + + while ( level < this.path.length && leaf !== null) { + // set parent to current + parent = leaf; + + // select next leaf based on component + let component = this.path[level]; + name = component.name; + + // stop one early if this is the last element and an attribute + if (level !== this.path.length - 1 || !name.startsWith('@')) { + let children = parent[name + '_asArray'] || []; + if (children.length === 0 && parent[name]) { + children.push(parent[name]); + } + + if (component.position) { + leaf = children[component.position] || null; + } else if (component.attribute) { + let attr = component.attribute; + leaf = children.filter((elm) => elm[attr.name] == attr.value)[0] || null; + } else { + // default case, select first + leaf = children[0] || null; + } + } + + level++; + } + + if (leaf === null) { + // given path not found in root + return null; + } + + // attributes the target is the leaf node, the name is the attribute + if (name.startsWith('@')) { + return { + name: name.substring(1), + leaf: leaf, + target: leaf + }; + } + + // otherwise we target the parent for sibling operations and leaf for child operations + return { + name: name, + leaf: leaf, + target: isSiblingOperation ? parent : leaf + }; + } +} + +export default SimpleXPath; diff --git a/src/streaming/ManifestLoader.js b/src/streaming/ManifestLoader.js index e8095eeb89..508e08fddb 100644 --- a/src/streaming/ManifestLoader.js +++ b/src/streaming/ManifestLoader.js @@ -100,7 +100,7 @@ function ManifestLoader(config) { mssHandler.registerEvents(); } return parser; - } else if (data.indexOf('MPD') > -1) { + } else if (data.indexOf('MPD') > -1 || data.indexOf('Patch') > -1) { return DashParser(context).create({debug: debug}); } else { return parser; @@ -137,6 +137,16 @@ function ManifestLoader(config) { baseUri = urlUtils.parseBaseUrl(url); } + // A response of no content implies in-memory is properly up to date + if (textStatus == 'No Content') { + eventBus.trigger( + Events.INTERNAL_MANIFEST_LOADED, { + manifest: null + } + ); + return; + } + // Create parser according to manifest type if (parser === null) { parser = createParser(data); diff --git a/src/streaming/ManifestUpdater.js b/src/streaming/ManifestUpdater.js index 9d293eb017..43703ce69e 100644 --- a/src/streaming/ManifestUpdater.js +++ b/src/streaming/ManifestUpdater.js @@ -34,11 +34,13 @@ import FactoryMaker from '../core/FactoryMaker'; import Debug from '../core/Debug'; import Errors from '../core/errors/Errors'; import DashConstants from '../dash/constants/DashConstants'; +import URLUtils from './utils/URLUtils'; function ManifestUpdater() { const context = this.context; const eventBus = EventBus(context).getInstance(); + const urlUtils = URLUtils(context).getInstance(); let instance, logger, @@ -132,18 +134,67 @@ function ManifestUpdater() { } } - function refreshManifest() { + function refreshManifest(ignorePatch = false) { isUpdating = true; const manifest = manifestModel.getValue(); + + // default to the original url in the manifest let url = manifest.url; + + // Check for PatchLocation and Location alternatives + const patchLocation = adapter.getPatchLocation(manifest); const location = adapter.getLocation(manifest); - if (location) { + if (patchLocation && !ignorePatch) { + url = patchLocation; + } else if (location) { url = location; } + + // if one of the alternatives was relative, convert to absolute + if (urlUtils.isRelative(url)) { + url = urlUtils.resolve(url, manifest.url); + } + manifestLoader.load(url); } function update(manifest) { + if (!manifest) { + // successful update with no content implies existing manifest remains valid + manifest = manifestModel.getValue(); + + // override load time to avoid invalid latency tracking + manifest.loadedTime = new Date(); + } else if (adapter.getIsPatch(manifest)) { + // with patches the in-memory manifest is our base + let patch = manifest; + manifest = manifestModel.getValue(); + + // check for patch validity + let isPatchValid = adapter.isPatchValid(manifest, patch); + let patchSuccessful = isPatchValid; + + if (isPatchValid) { + // grab publish time before update + let publishTime = adapter.getPublishTime(manifest); + + // apply validated patch to manifest + patchSuccessful = adapter.applyPatchToManifest(manifest, patch); + + // get the updated publish time + let updatedPublishTime = adapter.getPublishTime(manifest); + + // ensure the patch properly updated the in-memory publish time + patchSuccessful = publishTime.getTime() != updatedPublishTime.getTime(); + } + + // if the patch failed to apply, force a full manifest refresh + if (!patchSuccessful) { + logger.debug('Patch provided is invalid, performing full manifest refresh'); + refreshManifest(true); + return; + } + } // See DASH-IF IOP v4.3 section 4.6.4 "Transition Phase between Live and On-Demand" // Stop manifest update, ignore static manifest and signal end of dynamic stream to detect end of stream diff --git a/src/streaming/controllers/XlinkController.js b/src/streaming/controllers/XlinkController.js index 0de9e3f7b2..79dddad40e 100644 --- a/src/streaming/controllers/XlinkController.js +++ b/src/streaming/controllers/XlinkController.js @@ -97,8 +97,13 @@ function XlinkController(config) { }); manifest = mpd; - elements = getElementsToResolve(manifest.Period_asArray, manifest, DashConstants.PERIOD, RESOLVE_TYPE_ONLOAD); - resolve(elements, DashConstants.PERIOD, RESOLVE_TYPE_ONLOAD); + + if (manifest.Period_asArray) { + elements = getElementsToResolve(manifest.Period_asArray, manifest, DashConstants.PERIOD, RESOLVE_TYPE_ONLOAD); + resolve(elements, DashConstants.PERIOD, RESOLVE_TYPE_ONLOAD); + } else { + eventBus.trigger(Events.XLINK_READY, {manifest: manifest}); + } } function reset() { diff --git a/test/unit/dash.DashAdapter.js b/test/unit/dash.DashAdapter.js index dace269711..9037dc5e5a 100644 --- a/test/unit/dash.DashAdapter.js +++ b/test/unit/dash.DashAdapter.js @@ -1,9 +1,11 @@ import DashAdapter from '../../src/dash/DashAdapter'; import MediaInfo from '../../src/dash/vo/MediaInfo'; import Constants from '../../src/streaming/constants/Constants'; +import DashConstants from '../../src/dash/constants/DashConstants'; import cea608parser from '../../externals/cea608-parser'; import VoHelper from './helpers/VOHelper'; +import PatchHelper from './helpers/PatchHelper.js'; import ErrorHandlerMock from './mocks/ErrorHandlerMock'; const expect = require('chai').expect; @@ -497,5 +499,658 @@ describe('DashAdapter', function () { expect(Object.keys(mediaInfoArray[0].supplementalProperties).length).equals(0); // jshint ignore:line }); }); + + describe('getPatchLocation', function () { + + // example patch location element with ttl + const patchLocationElementTTL = { + __children: [{'#text': 'foobar'}], + '__text': 'foobar', + ttl: 60 * 5 // 5 minute validity period + }; + + // example patch location element that never expires + const patchLocationElementEvergreen = { + __children: [{'#text': 'foobar'}], + '__text': 'foobar' + }; + + it('should provide patch location if present and not expired', function () { + // simulated 1 minute old manifest + let publishTime = new Date(); + publishTime.setMinutes(publishTime.getMinutes() - 1); + const manifest = { + [DashConstants.PUBLISH_TIME]: (publishTime.toISOString()), + PatchLocation: patchLocationElementTTL, + PatchLocation_asArray: [patchLocationElementTTL] + }; + + let patchLocation = dashAdapter.getPatchLocation(manifest); + expect(patchLocation).equals('foobar'); + }); + + it('should not provide patch location if present and expired', function () { + // simulated 10 minute old manifest + let publishTime = new Date(); + publishTime.setMinutes(publishTime.getMinutes() - 10); + const manifest = { + [DashConstants.PUBLISH_TIME]: (publishTime.toISOString()), + PatchLocation: patchLocationElementTTL, + PatchLocation_asArray: [patchLocationElementTTL] + }; + + let patchLocation = dashAdapter.getPatchLocation(manifest); + expect(patchLocation).to.be.null; // jshint ignore:line + }); + + it('should provide patch location if present and never expires', function () { + // simulated 120 minute old manifest + let publishTime = new Date(); + publishTime.setMinutes(publishTime.getMinutes() - 120); + const manifest = { + [DashConstants.PUBLISH_TIME]: (publishTime.toISOString()), + PatchLocation: patchLocationElementEvergreen, + PatchLocation_asArray: [patchLocationElementEvergreen] + }; + + let patchLocation = dashAdapter.getPatchLocation(manifest); + expect(patchLocation).equals('foobar'); + }); + + it('should not provide patch location if not present', function () { + const manifest = { + [DashConstants.PUBLISH_TIME]: (new Date().toISOString()) + }; + + let patchLocation = dashAdapter.getPatchLocation(manifest); + expect(patchLocation).to.be.null; // jshint ignore:line + }); + + it('should not provide patch location if present in manifest without publish time', function () { + const manifest = { + PatchLocation: patchLocationElementTTL, + PatchLocation_asArray: [patchLocationElementTTL] + }; + + let patchLocation = dashAdapter.getPatchLocation(manifest); + expect(patchLocation).to.be.null; // jshint ignore:line + }); + }); + + describe('isPatchValid', function () { + it('considers patch invalid if no patch given', function () { + let publishTime = new Date(); + let manifest = { + [DashConstants.ID]: 'foobar', + [DashConstants.PUBLISH_TIME]: publishTime.toISOString() + }; + let isValid = dashAdapter.isPatchValid(manifest); + + expect(isValid).to.be.false; // jshint ignore:line + }); + + it('considers patch invalid if no manifest given', function () { + let publishTime = new Date(); + let publishTime2 = new Date(publishTime.getTime() + 100); + let patch = { + [DashConstants.ORIGINAL_MPD_ID]: 'foobar', + [DashConstants.ORIGINAL_PUBLISH_TIME]: publishTime.toISOString(), + [DashConstants.PUBLISH_TIME]: publishTime2.toISOString() + }; + let isValid = dashAdapter.isPatchValid(undefined, patch); + + expect(isValid).to.be.false; // jshint ignore:line + }); + + it('considers patch invalid if manifest has no id', function () { + let publishTime = new Date(); + let publishTime2 = new Date(publishTime.getTime() + 100); + let manifest = { + [DashConstants.PUBLISH_TIME]: publishTime + }; + let patch = { + [DashConstants.ORIGINAL_MPD_ID]: 'foobar', + [DashConstants.ORIGINAL_PUBLISH_TIME]: publishTime.toISOString(), + [DashConstants.PUBLISH_TIME]: publishTime2.toISOString() + }; + let isValid = dashAdapter.isPatchValid(manifest, patch); + + expect(isValid).to.be.false; // jshint ignore:line + }); + + it('considers patch invalid if patch has no manifest id', function () { + let publishTime = new Date(); + let publishTime2 = new Date(publishTime.getTime() + 100); + let manifest = { + [DashConstants.ID]: 'foobar', + [DashConstants.PUBLISH_TIME]: publishTime.toISOString() + }; + let patch = { + [DashConstants.ORIGINAL_PUBLISH_TIME]: publishTime.toISOString(), + [DashConstants.PUBLISH_TIME]: publishTime2.toISOString() + }; + let isValid = dashAdapter.isPatchValid(manifest, patch); + + expect(isValid).to.be.false; // jshint ignore:line + }); + + it('considers patch invalid if manifest has no publish time', function () { + let publishTime = new Date(); + let publishTime2 = new Date(publishTime.getTime() + 100); + let manifest = { + [DashConstants.ID]: 'foobar' + }; + let patch = { + [DashConstants.ORIGINAL_MPD_ID]: 'foobar', + [DashConstants.ORIGINAL_PUBLISH_TIME]: publishTime.toISOString(), + [DashConstants.PUBLISH_TIME]: publishTime2.toISOString() + }; + let isValid = dashAdapter.isPatchValid(manifest, patch); + + expect(isValid).to.be.false; // jshint ignore:line + }); + + it('considers patch invalid if patch has no original publish time', function () { + let publishTime = new Date(); + let publishTime2 = new Date(publishTime.getTime() + 100); + let manifest = { + [DashConstants.ID]: 'foobar', + [DashConstants.PUBLISH_TIME]: publishTime.toISOString() + }; + let patch = { + [DashConstants.ORIGINAL_MPD_ID]: 'foobar', + [DashConstants.PUBLISH_TIME]: publishTime2.toISOString() + }; + let isValid = dashAdapter.isPatchValid(manifest, patch); + + expect(isValid).to.be.false; // jshint ignore:line + }); + + it('considers patch invalid if both objects missing ids', function () { + let publishTime = new Date(); + let publishTime2 = new Date(publishTime.getTime() + 100); + let manifest = { + [DashConstants.PUBLISH_TIME]: publishTime.toISOString() + }; + let patch = { + [DashConstants.ORIGINAL_PUBLISH_TIME]: publishTime.toISOString(), + [DashConstants.PUBLISH_TIME]: publishTime2.toISOString() + }; + let isValid = dashAdapter.isPatchValid(manifest, patch); + + expect(isValid).to.be.false; // jshint ignore:line + }); + + it('considers patch invalid if both objects missing mpd publish times', function () { + let publishTime = new Date(); + let manifest = { + [DashConstants.ID]: 'foobar' + }; + let patch = { + [DashConstants.ORIGINAL_MPD_ID]: 'foobar', + [DashConstants.PUBLISH_TIME]: publishTime.toISOString() + }; + let isValid = dashAdapter.isPatchValid(manifest, patch); + + expect(isValid).to.be.false; // jshint ignore:line + }); + + it('considers patch invalid if patch missing new publish time', function () { + let publishTime = new Date(); + let manifest = { + [DashConstants.ID]: 'foobar', + [DashConstants.PUBLISH_TIME]: publishTime.toISOString() + }; + let patch = { + [DashConstants.ORIGINAL_MPD_ID]: 'foobar', + [DashConstants.ORIGINAL_PUBLISH_TIME]: publishTime.toISOString() + }; + let isValid = dashAdapter.isPatchValid(manifest, patch); + + expect(isValid).to.be.false; // jshint ignore:line + }); + + it('considers patch invalid if ids do not match', function () { + let publishTime = new Date(); + let publishTime2 = new Date(publishTime.getTime() + 100); + let manifest = { + [DashConstants.ID]: 'foobar', + [DashConstants.PUBLISH_TIME]: publishTime.toISOString() + }; + let patch = { + [DashConstants.ORIGINAL_MPD_ID]: 'bazqux', + [DashConstants.ORIGINAL_PUBLISH_TIME]: publishTime.toISOString(), + [DashConstants.PUBLISH_TIME]: publishTime2.toISOString() + }; + let isValid = dashAdapter.isPatchValid(manifest, patch); + + expect(isValid).to.be.false; // jshint ignore:line + }); + + it('considers patch invalid if publish times do not match', function () { + let publishTime = new Date(); + let publishTime2 = new Date(publishTime.getTime() + 100); + let publishTime3 = new Date(publishTime.getTime() + 200); + let manifest = { + [DashConstants.ID]: 'foobar', + [DashConstants.PUBLISH_TIME]: publishTime.toISOString() + }; + let patch = { + [DashConstants.ORIGINAL_MPD_ID]: 'foobar', + [DashConstants.ORIGINAL_PUBLISH_TIME]: publishTime2.toISOString(), + [DashConstants.PUBLISH_TIME]: publishTime3.toISOString() + }; + let isValid = dashAdapter.isPatchValid(manifest, patch); + + expect(isValid).to.be.false; // jshint ignore:line + }); + + it('considers patch invalid if new publish time is not later than previous', function () { + let publishTime = new Date(); + let manifest = { + [DashConstants.ID]: 'foobar', + [DashConstants.PUBLISH_TIME]: publishTime.toISOString() + }; + let patch = { + [DashConstants.ORIGINAL_MPD_ID]: 'foobar', + [DashConstants.ORIGINAL_PUBLISH_TIME]: publishTime.toISOString(), + [DashConstants.PUBLISH_TIME]: publishTime.toISOString() + }; + let isValid = dashAdapter.isPatchValid(manifest, patch); + + expect(isValid).to.be.false; // jshint ignore:line + }); + + it('considers patch valid if ids, publish times match, and new publish time is later than previous', function () { + let publishTime = new Date(); + let publishTime2 = new Date(publishTime.getTime() + 100); + let manifest = { + [DashConstants.ID]: 'foobar', + [DashConstants.PUBLISH_TIME]: publishTime.toISOString() + }; + let patch = { + [DashConstants.ORIGINAL_MPD_ID]: 'foobar', + [DashConstants.ORIGINAL_PUBLISH_TIME]: publishTime.toISOString(), + [DashConstants.PUBLISH_TIME]: publishTime2.toISOString() + }; + let isValid = dashAdapter.isPatchValid(manifest, patch); + + expect(isValid).to.be.true; // jshint ignore:line + }); + }); + + describe('applyPatchToManifest', function () { + const patchHelper = new PatchHelper(); + + it('applies add operation to structure with no siblings', function () { + let manifest = {}; + let addedPeriod = {id: 'foo'}; + let patch = patchHelper.generatePatch('foobar', [{ + action: 'add', + selector: '/MPD', + children: [{ + Period: addedPeriod + }] + }]); + + dashAdapter.applyPatchToManifest(manifest, patch); + + expect(manifest.Period).to.equal(addedPeriod); + expect(manifest.Period_asArray).to.deep.equal([addedPeriod]); + }); + + it('applies add operation to structure with single sibling', function () { + let originalPeriod = {id: 'foo'}; + let addedPeriod = {id: 'bar'}; + // special case x2js object which omits the _asArray variant + let manifest = { + Period: originalPeriod + }; + let patch = patchHelper.generatePatch('foobar', [{ + action: 'add', + selector: '/MPD', + children: [{ + Period: addedPeriod + }] + }]); + + dashAdapter.applyPatchToManifest(manifest, patch); + + expect(manifest.Period).to.deep.equal([originalPeriod, addedPeriod]); + expect(manifest.Period).to.deep.equal(manifest.Period_asArray); + }); + + it('applies add implicit append operation with siblings', function () { + let originalPeriods = [{id: 'foo'}, {id: 'bar'}]; + let addedPeriod = {id: 'baz'}; + let manifest = { + Period: originalPeriods.slice(), + Period_asArray: originalPeriods.slice() + }; + let patch = patchHelper.generatePatch('foobar', [{ + action: 'add', + selector: '/MPD', + children: [{ + Period: addedPeriod + }] + }]); + + dashAdapter.applyPatchToManifest(manifest, patch); + + expect(manifest.Period).to.deep.equal([originalPeriods[0], originalPeriods[1], addedPeriod]); + expect(manifest.Period).to.deep.equal(manifest.Period_asArray); + }); + + it('applies add prepend operation with siblings', function () { + let originalPeriods = [{id: 'foo'}, {id: 'bar'}]; + let addedPeriod = {id: 'baz'}; + let manifest = { + Period: originalPeriods.slice(), + Period_asArray: originalPeriods.slice() + }; + let patch = patchHelper.generatePatch('foobar', [{ + action: 'add', + selector: '/MPD', + position: 'prepend', + children: [{ + Period: addedPeriod + }] + }]); + + dashAdapter.applyPatchToManifest(manifest, patch); + + expect(manifest.Period).to.deep.equal([addedPeriod, originalPeriods[0], originalPeriods[1]]); + expect(manifest.Period).to.deep.equal(manifest.Period_asArray); + }); + + it('applies add before operation with siblings', function () { + let originalPeriods = [{id: 'foo'}, {id: 'bar'}, {id: 'baz'}]; + let addedPeriod = {id: 'qux'}; + let manifest = { + Period: originalPeriods.slice(), + Period_asArray: originalPeriods.slice() + }; + let patch = patchHelper.generatePatch('foobar', [{ + action: 'add', + selector: '/MPD/Period[2]', + position: 'before', + children: [{ + Period: addedPeriod + }] + }]); + + dashAdapter.applyPatchToManifest(manifest, patch); + + expect(manifest.Period).to.deep.equal([originalPeriods[0], addedPeriod, originalPeriods[1], originalPeriods[2]]); + expect(manifest.Period).to.deep.equal(manifest.Period_asArray); + }); + + it('applies add after operation with siblings', function () { + let originalPeriods = [{id: 'foo'}, {id: 'bar'}, {id: 'baz'}]; + let addedPeriod = {id: 'qux'}; + let manifest = { + Period: originalPeriods.slice(), + Period_asArray: originalPeriods.slice() + }; + let patch = patchHelper.generatePatch('foobar', [{ + action: 'add', + selector: '/MPD/Period[2]', + position: 'after', + children: [{ + Period: addedPeriod + }] + }]); + + dashAdapter.applyPatchToManifest(manifest, patch); + + expect(manifest.Period).to.deep.equal([originalPeriods[0], originalPeriods[1], addedPeriod, originalPeriods[2]]); + expect(manifest.Period).to.deep.equal(manifest.Period_asArray); + }); + + it('applies add attribute operation', function () { + let originalPeriod = {}; + let manifest = { + Period: originalPeriod, + Period_asArray: [originalPeriod] + }; + let patch = patchHelper.generatePatch('foobar', [{ + action: 'add', + selector: '/MPD/Period[1]', + type: '@id', + text: 'foo' + }]); + + dashAdapter.applyPatchToManifest(manifest, patch); + + expect(originalPeriod.id).to.equal('foo'); + }); + + it('applies add attribute operation on existing attribute, should act as replace', function () { + let originalPeriod = {id: 'foo'}; + let manifest = { + Period: originalPeriod, + Period_asArray: [originalPeriod] + }; + let patch = patchHelper.generatePatch('foobar', [{ + action: 'add', + selector: '/MPD/Period[1]', + type: '@id', + text: 'bar' + }]); + + dashAdapter.applyPatchToManifest(manifest, patch); + + expect(originalPeriod.id).to.equal('bar'); + }); + + it('applies replace operation with siblings', function () { + let originalPeriods = [{id: 'foo'}, {id: 'bar'}, {id: 'baz'}]; + let replacementPeriod = {id: 'qux'}; + let manifest = { + Period: originalPeriods.slice(), + Period_asArray: originalPeriods.slice() + }; + let patch = patchHelper.generatePatch('foobar', [{ + action: 'replace', + selector: '/MPD/Period[2]', + children: [{ + Period: replacementPeriod + }] + }]); + + dashAdapter.applyPatchToManifest(manifest, patch); + + expect(manifest.Period).to.deep.equal([originalPeriods[0], replacementPeriod, originalPeriods[2]]); + expect(manifest.Period).to.deep.equal(manifest.Period_asArray); + }); + + it('applies replace operation without siblings', function () { + let originalPeriod = {id: 'foo'}; + let replacementPeriod = {id: 'bar'}; + let manifest = { + Period: originalPeriod, + Period_asArray: [originalPeriod] + }; + let patch = patchHelper.generatePatch('foobar', [{ + action: 'replace', + selector: '/MPD/Period[1]', + children: [{ + Period: replacementPeriod + }] + }]); + + dashAdapter.applyPatchToManifest(manifest, patch); + + expect(manifest.Period).to.deep.equal(replacementPeriod); + expect(manifest.Period_asArray).to.deep.equal([replacementPeriod]); + }); + + it('applies replace operation to attribute', function () { + let originalPeriod = {id: 'foo'}; + let manifest = { + Period: originalPeriod, + Period_asArray: [originalPeriod] + }; + let patch = patchHelper.generatePatch('foobar', [{ + action: 'replace', + selector: '/MPD/Period[1]/@id', + text: 'bar' + }]); + + dashAdapter.applyPatchToManifest(manifest, patch); + + expect(originalPeriod.id).to.equal('bar'); + }); + + it('applies remove operation leaving multiple siblings', function () { + let originalPeriods = [{id: 'foo'}, {id: 'bar'}, {id: 'baz'}]; + let manifest = { + Period: originalPeriods.slice(), + Period_asArray: originalPeriods.slice() + }; + let patch = patchHelper.generatePatch('foobar', [{ + action: 'remove', + selector: '/MPD/Period[2]' + }]); + + dashAdapter.applyPatchToManifest(manifest, patch); + + expect(manifest.Period).to.deep.equal([originalPeriods[0], originalPeriods[2]]); + expect(manifest.Period).to.deep.equal(manifest.Period_asArray); + }); + + it('applies remove operation leaving one sibling', function () { + let originalPeriods = [{id: 'foo'}, {id: 'bar'}]; + let manifest = { + Period: originalPeriods.slice(), + Period_asArray: originalPeriods.slice() + }; + let patch = patchHelper.generatePatch('foobar', [{ + action: 'remove', + selector: '/MPD/Period[2]' + }]); + + dashAdapter.applyPatchToManifest(manifest, patch); + + expect(manifest.Period).to.equal(originalPeriods[0]); + expect(manifest.Period_asArray).to.deep.equal([originalPeriods[0]]); + }); + + it('applies remove operation leaving no siblings', function () { + let originalPeriod = {id: 'foo'}; + let manifest = { + Period: originalPeriod, + Period_asArray: [originalPeriod] + }; + let patch = patchHelper.generatePatch('foobar', [{ + action: 'remove', + selector: '/MPD/Period[1]' + }]); + + dashAdapter.applyPatchToManifest(manifest, patch); + + expect(manifest).to.not.have.property('Period'); + expect(manifest).to.not.have.property('Period_asArray'); + }); + + it('applies remove attribute operation', function () { + let originalPeriod = {id: 'foo', start: 'bar'}; + let manifest = { + Period: originalPeriod, + Period_asArray: [originalPeriod] + }; + let patch = patchHelper.generatePatch('foobar', [{ + action: 'remove', + selector: '/MPD/Period[1]/@start' + }]); + + dashAdapter.applyPatchToManifest(manifest, patch); + + expect(originalPeriod).to.not.have.property('start'); + expect(manifest.Period).to.deep.equal(originalPeriod); + expect(manifest.Period_asArray).to.deep.equal([originalPeriod]); + }); + + it('applies multiple operations respecting order', function () { + let originalPeriods = [{id: 'foo'}, {id: 'bar'}]; + let newPeriod = {id: 'baz'}; + let manifest = { + Period: originalPeriods.slice(), + Period_asArray: originalPeriods.slice() + }; + let patch = patchHelper.generatePatch('foobar', [ + { + action: 'add', + selector: '/MPD/Period[1]', + type: '@start', + text: 'findme' + }, + { + action: 'add', + selector: '/MPD/Period[2]', + position: 'before', + children: [{ + Period: newPeriod + }] + }, + { + action: 'replace', + selector: '/MPD/Period[3]/@id', + text: 'qux' + }, + { + action: 'remove', + selector: '/MPD/Period[@start="findme"]' + } + ]); + + dashAdapter.applyPatchToManifest(manifest, patch); + + // check attribute changes + expect(originalPeriods[0].start).to.equal('findme'); + expect(originalPeriods[1].id).to.equal('qux'); + + // check insertion and ordering based on application + expect(manifest.Period).to.deep.equal([newPeriod, originalPeriods[1]]); + expect(manifest.Period).to.deep.equal(manifest.Period_asArray); + }); + + it('invalid operations are ignored', function () { + let originalPeriods = [{id: 'foo'}, {id: 'bar'}]; + let manifest = { + Period: originalPeriods.slice(), + Period_asArray: originalPeriods.slice() + }; + let patch = patchHelper.generatePatch('foobar', [ + { + action: 'add', + selector: '/MPD/Period[1]', + type: '@start', + text: 'findme' + }, + { + action: 'replace', + selector: '/MPD/Period[@id="nothere"]/@id', + text: 'nochange' + }, + { + action: 'replace', + selector: '/MPD/Period[2]/@id', + text: 'baz' + } + ]); + + dashAdapter.applyPatchToManifest(manifest, patch); + + // check updates executed + expect(originalPeriods[0]).to.have.property('start'); + expect(originalPeriods[0].start).to.equal('findme'); + expect(originalPeriods[1].id).to.equal('baz'); + + // check ordering proper + expect(manifest.Period).to.deep.equal(originalPeriods); + expect(manifest.Period).to.deep.equal(manifest.Period_asArray); + }); + }); }); }); diff --git a/test/unit/dash.models.DashManifestModel.js b/test/unit/dash.models.DashManifestModel.js index ecf5df740e..282fbba3c4 100644 --- a/test/unit/dash.models.DashManifestModel.js +++ b/test/unit/dash.models.DashManifestModel.js @@ -518,6 +518,33 @@ describe('DashManifestModel', function () { expect(location).to.be.equal('location_1'); // jshint ignore:line }); + it('should return undefined when getPatchLocation is called and manifest is undefined', () => { + const location = dashManifestModel.getPatchLocation(); + + expect(location).to.be.undefined; // jshint ignore:line + }); + + it('should return undefined when getPatchLocation is called and one is not present', () => { + const location = dashManifestModel.getPatchLocation({}); + + expect(location).to.be.undefined; // jshint ignore:line + }); + + it('should return valid patch location when getLocation is called and manifest contains complex location', () => { + const patchLocation = { + __text: 'http://example.com', + ttl: 60 + }; + const manifest = { + [DashConstants.PATCH_LOCATION]: patchLocation, + PatchLocation_asArray: [patchLocation] + }; + + const location = dashManifestModel.getPatchLocation(manifest); + + expect(location).to.equal(patchLocation); + }); + it('should return an empty Array when getUTCTimingSources is called and manifest is undefined', () => { const utcSourceArray = dashManifestModel.getUTCTimingSources(); @@ -791,6 +818,28 @@ describe('DashManifestModel', function () { expect(representationArray[0].index).to.equals(0); // jshint ignore:line }); + it('should return null when getId is called and manifest undefined', () => { + const id = dashManifestModel.getId(); + + expect(id).to.be.null; // jshint ignore:line + }); + + it('should return null when getId is called and manifest is missing id', () => { + const id = dashManifestModel.getId({}); + + expect(id).to.be.null; // jshint ignore:line + }); + + it('should return id when getId is called and manifest contains id', () => { + const manifest = { + [DashConstants.ID]: 'foobar' + }; + + const id = dashManifestModel.getId(manifest); + + expect(id).to.equal('foobar'); + }); + it('should return false when hasProfile is called and manifest is undefined', () => { const IsDVB = dashManifestModel.hasProfile(); @@ -817,6 +866,34 @@ describe('DashManifestModel', function () { expect(isDVB).to.be.false; // jshint ignore:line }); + it('should return null when getPublishTime is called and manifest is undefined', () => { + const publishTime = dashManifestModel.getPublishTime(); + + expect(publishTime).to.be.null; // jshint ignore:line + }); + + it('should return valid date object when getPublishTime is called with manifest with valid date', () => { + const manifest = { + [DashConstants.PUBLISH_TIME]: '2020-11-11T05:13:19.514676331Z' + }; + + const publishTime = dashManifestModel.getPublishTime(manifest); + + expect(publishTime).to.be.instanceOf(Date); + expect(publishTime.getTime()).to.not.be.NaN; // jshint ignore:line + }); + + it('should return invalid date object when getPublishTime is called with manifest with invalid date', () => { + const manifest = { + [DashConstants.PUBLISH_TIME]: '' + }; + + const publishTime = dashManifestModel.getPublishTime(manifest); + + expect(publishTime).to.be.instanceOf(Date); + expect(publishTime.getTime()).to.be.NaN; // jshint ignore:line + }); + it('should return NaN when getManifestUpdatePeriod is called and manifest is undefined', () => { const updatePeriod = dashManifestModel.getManifestUpdatePeriod(); expect(updatePeriod).to.be.NaN; // jshint ignore:line diff --git a/test/unit/dash.models.PatchManifestModel.js b/test/unit/dash.models.PatchManifestModel.js new file mode 100644 index 0000000000..ea59e49138 --- /dev/null +++ b/test/unit/dash.models.PatchManifestModel.js @@ -0,0 +1,259 @@ +import PatchManifestModel from '../../src/dash/models/PatchManifestModel'; +import DashConstants from '../../src/dash/constants/DashConstants'; +import PatchOperation from '../../src/dash/vo/PatchOperation'; +import SimpleXPath from '../../src/dash/vo/SimpleXPath'; + +import PatchHelper from './helpers/PatchHelper'; + +const expect = require('chai').expect; + +const context = {}; +const patchManifestModel = PatchManifestModel(context).getInstance(); + +describe('PatchManifestModel', function () { + describe('getIsPatch', function () { + it('should identify patches by presence of original MPD id', function () { + let patch = { + [DashConstants.ORIGINAL_MPD_ID]: 'foobar' + }; + expect(patchManifestModel.getIsPatch(patch)).to.be.true; // jshint ignore:line + }); + + it('should consider the lack of original MPD id as non-patch', function () { + expect(patchManifestModel.getIsPatch({})).to.be.false; // jshint ignore:line + }); + + it('should consider lack of patch argument as non-patch', function () { + expect(patchManifestModel.getIsPatch()).to.be.false; // jshint ignore:line + }); + }); + + describe('getPublishTime', function () { + it('should provide null for missing argument', function () { + expect(patchManifestModel.getPublishTime()).to.be.null; // jshint ignore:line + }); + + it('should provide null for missing publish time in patch', function () { + expect(patchManifestModel.getPublishTime({})).to.be.null; // jshint ignore:line + }); + + it('should provide Date object for parsed publish time', function () { + let patch = { + [DashConstants.PUBLISH_TIME]: '2020-11-11T05:13:19.514676331Z' + }; + expect(patchManifestModel.getPublishTime(patch)).to.be.instanceOf(Date); + }); + }); + + describe('getOriginalPublishTime', function () { + it('should provide null for missing argument', function () { + expect(patchManifestModel.getOriginalPublishTime()).to.be.null; // jshint ignore:line + }); + + it('should provide null for missing original publish time in patch', function () { + expect(patchManifestModel.getOriginalPublishTime({})).to.be.null; // jshint ignore:line + }); + + it('should provide Date object for parsed original publish time', function () { + let patch = { + [DashConstants.ORIGINAL_PUBLISH_TIME]: '2020-11-11T05:13:19.514676331Z' + }; + expect(patchManifestModel.getOriginalPublishTime(patch)).to.be.instanceOf(Date); + }); + }); + + describe('getMpdId', function () { + it('should provide null for missing argument', function () { + expect(patchManifestModel.getMpdId()).to.be.null; // jshint ignore:line + }); + + it('should provide null for missing attribute', function () { + expect(patchManifestModel.getMpdId({})).to.be.null; // jshint ignore:line + }); + + it('should provide mpd id when present', function () { + let patch = { + [DashConstants.ORIGINAL_MPD_ID]: 'foobar' + }; + expect(patchManifestModel.getMpdId(patch)).to.equal('foobar'); + }); + }); + + describe('getPatchOperations', function () { + + const patchHelper = new PatchHelper(); + + it('should provide empty operation set for missing argument', function () { + expect(patchManifestModel.getPatchOperations()).to.be.empty; // jshint ignore:line + }); + + describe('add operations', function () { + it('should properly parse add operation targeting element', function () { + let patch = patchHelper.generatePatch('foobar', [{ + action: 'add', + selector: '/MPD/Period', + position: 'after', + children: [{ 'Period': {} }] + }]); + let operations = patchManifestModel.getPatchOperations(patch); + expect(operations.length).to.equal(1); + expect(operations[0]).to.be.instanceOf(PatchOperation); + expect(operations[0].action).to.equal('add'); + expect(operations[0].xpath).to.be.instanceOf(SimpleXPath); + expect(operations[0].xpath.findsElement()).to.be.true; // jshint ignore:line + expect(operations[0].position).to.equal('after'); + expect(operations[0].value).to.have.all.keys(['Period']); + }); + + it('should properly parse add operation targeting attribute', function () { + let patch = patchHelper.generatePatch('foobar', [{ + action: 'add', + selector: '/MPD/Period', + type: '@id', + text: 'foo-1' + }]); + let operations = patchManifestModel.getPatchOperations(patch); + expect(operations.length).to.equal(1); + expect(operations[0]).to.be.instanceOf(PatchOperation); + expect(operations[0].action).to.equal('add'); + expect(operations[0].xpath).to.be.instanceOf(SimpleXPath); + expect(operations[0].xpath.findsAttribute()).to.be.true; // jshint ignore:line + expect(operations[0].value).to.equal('foo-1'); + }); + + it('should properly ignore add operation attempting namespace addition', function () { + let patch = patchHelper.generatePatch('foobar', [{ + action: 'add', + selector: '/MPD/Period', + type: 'namespace::thing', + text: 'foo-1' + }]); + let operations = patchManifestModel.getPatchOperations(patch); + expect(operations.length).to.equal(0); + }); + }); + + describe('replace operations', function () { + it('should properly parse replace operation targeting element', function () { + let patch = patchHelper.generatePatch('foobar', [{ + action: 'replace', + selector: '/MPD/Period', + children: [{ 'Period': {} }] + }]); + let operations = patchManifestModel.getPatchOperations(patch); + expect(operations.length).to.equal(1); + expect(operations[0]).to.be.instanceOf(PatchOperation); + expect(operations[0].action).to.equal('replace'); + expect(operations[0].xpath).to.be.instanceOf(SimpleXPath); + expect(operations[0].xpath.findsElement()).to.be.true; // jshint ignore:line + expect(operations[0].value).to.have.all.keys(['Period']); + }); + + it('should properly parse replace operation targeting attribute', function () { + let patch = patchHelper.generatePatch('foobar', [{ + action: 'replace', + selector: '/MPD/Period/@id', + text: 'foo-2' + }]); + let operations = patchManifestModel.getPatchOperations(patch); + expect(operations.length).to.equal(1); + expect(operations[0]).to.be.instanceOf(PatchOperation); + expect(operations[0].action).to.equal('replace'); + expect(operations[0].xpath).to.be.instanceOf(SimpleXPath); + expect(operations[0].xpath.findsAttribute()).to.be.true; // jshint ignore:line + expect(operations[0].value).to.equal('foo-2'); + }); + }); + + describe('remove operations', function () { + it('should properly parse remove operation targeting element', function () { + let patch = patchHelper.generatePatch('foobar', [{ + action: 'remove', + selector: '/MPD/Period[3]' + }]); + let operations = patchManifestModel.getPatchOperations(patch); + expect(operations.length).to.equal(1); + expect(operations[0]).to.be.instanceOf(PatchOperation); + expect(operations[0].action).to.equal('remove'); + expect(operations[0].xpath).to.be.instanceOf(SimpleXPath); + expect(operations[0].xpath.findsElement()).to.be.true; // jshint ignore:line + }); + + it('should properly parse remove operation targeting attribute', function () { + let patch = patchHelper.generatePatch('foobar', [{ + action: 'remove', + selector: '/MPD/Period/@id' + }]); + let operations = patchManifestModel.getPatchOperations(patch); + expect(operations.length).to.equal(1); + expect(operations[0]).to.be.instanceOf(PatchOperation); + expect(operations[0].action).to.equal('remove'); + expect(operations[0].xpath).to.be.instanceOf(SimpleXPath); + expect(operations[0].xpath.findsAttribute()).to.be.true; // jshint ignore:line + }); + }); + + describe('operation edge cases', function () { + it('should properly parse operation sequence', function () { + let patch = patchHelper.generatePatch('foobar', [ + { + action: 'remove', + selector: '/MPD/Period[2]' + }, + { + action: 'replace', + selector: '/MPD/@publishTime', + text: 'some-new-time' + }, + { + action: 'add', + selector: '/MPD/Period', + position: 'after', + children: [{ 'Period': {} }] + } + ]); + let operations = patchManifestModel.getPatchOperations(patch); + expect(operations.length).to.equal(3); + expect(operations[0].action).to.equal('remove'); + expect(operations[1].action).to.equal('replace'); + expect(operations[2].action).to.equal('add'); + }); + + it('should properly ignore invalid operations', function () { + let patch = patchHelper.generatePatch('foobar', [ + { + action: 'remove', + selector: '/MPD/Period[2]' + }, + { + action: 'unknown' + }, + { + action: 'add', + selector: '/MPD/Period', + position: 'after', + children: [{ 'Period': {} }] + }, + { + action: 'other-unknown' + } + ]); + let operations = patchManifestModel.getPatchOperations(patch); + expect(operations.length).to.equal(2); + expect(operations[0].action).to.equal('remove'); + expect(operations[1].action).to.equal('add'); + }); + + it('should properly ignore operations with unsupported xpaths', function () { + let patch = patchHelper.generatePatch('foobar', [ + { + action: 'remove', + selector: 'MPD/Period' // non-absolute paths not supported + } + ]); + let operations = patchManifestModel.getPatchOperations(patch); + expect(operations.length).to.equal(0); + }); + }); + }); +}); diff --git a/test/unit/dash.vo.PatchOperation.js b/test/unit/dash.vo.PatchOperation.js new file mode 100644 index 0000000000..c0cda02411 --- /dev/null +++ b/test/unit/dash.vo.PatchOperation.js @@ -0,0 +1,71 @@ +import PatchOperation from '../../src/dash/vo/PatchOperation'; + +const expect = require('chai').expect; +const sinon = require('sinon'); + +describe('PatchOperation', function () { + describe('getMpdTarget', function () { + it('should consider remove operation sibling operation', function () { + let xpath = { getMpdTarget: sinon.fake() }; + let root = {}; + let operation = new PatchOperation('remove', xpath); + + operation.getMpdTarget(root); + + expect(xpath.getMpdTarget.calledWith(root, true)); + }); + + it('should consider replace operation sibling operation', function () { + let xpath = { getMpdTarget: sinon.fake() }; + let root = {}; + let operation = new PatchOperation('replace', xpath); + + operation.getMpdTarget(root); + + expect(xpath.getMpdTarget.calledWith(root, true)); + }); + + it('should consider add operation with position after as sibling operation', function () { + let xpath = { getMpdTarget: sinon.fake() }; + let root = {}; + let operation = new PatchOperation('add', xpath); + operation.position = 'after'; + + operation.getMpdTarget(root); + + expect(xpath.getMpdTarget.calledWith(root, true)); + }); + + it('should consider add operation with position before as sibling operation', function () { + let xpath = { getMpdTarget: sinon.fake() }; + let root = {}; + let operation = new PatchOperation('add', xpath); + operation.position = 'before'; + + operation.getMpdTarget(root); + + expect(xpath.getMpdTarget.calledWith(root, true)); + }); + + it('should not consider add operation with position prepend as sibling operation', function () { + let xpath = { getMpdTarget: sinon.fake() }; + let root = {}; + let operation = new PatchOperation('add', xpath); + operation.position = 'prepend'; + + operation.getMpdTarget(root); + + expect(xpath.getMpdTarget.calledWith(root, false)); + }); + + it('should not consider add operation without position as sibling operation', function () { + let xpath = { getMpdTarget: sinon.fake() }; + let root = {}; + let operation = new PatchOperation('add', xpath); + + operation.getMpdTarget(root); + + expect(xpath.getMpdTarget.calledWith(root, false)); + }); + }); +}); diff --git a/test/unit/dash.vo.SimpleXPath.js b/test/unit/dash.vo.SimpleXPath.js new file mode 100644 index 0000000000..b6a2c12bc6 --- /dev/null +++ b/test/unit/dash.vo.SimpleXPath.js @@ -0,0 +1,206 @@ +import SimpleXPath from '../../src/dash/vo/SimpleXPath'; + +import PatchHelper from './helpers/PatchHelper'; + +const expect = require('chai').expect; + +describe('SimpleXPath', function () { + describe('construction', function () { + it('simple valid path properly parsed', function () { + let xpath = new SimpleXPath('/MPD/Period'); + expect(xpath.isValid()).to.be.true; // jshint ignore:line + expect(xpath.path).to.deep.equal([ + {name: 'MPD'}, + {name: 'Period'} + ]); + }); + + it('path with positional selectors parsed', function () { + let xpath = new SimpleXPath('/MPD/Period[1]/AdaptationSet'); + expect(xpath.isValid()).to.be.true; // jshint ignore:line + expect(xpath.path).to.deep.equal([ + {name: 'MPD'}, + {name: 'Period', position: 0}, // xpath positions are 1 based, we compute in 0 based + {name: 'AdaptationSet'} + ]); + }); + + it('path with attribute selectors no quoting parsed', function () { + let xpath = new SimpleXPath('/MPD/Period[@id=foobar]/AdaptationSet'); + expect(xpath.isValid()).to.be.true; // jshint ignore:line + expect(xpath.path).to.deep.equal([ + {name: 'MPD'}, + {name: 'Period', attribute: {name: 'id', value: 'foobar'}}, + {name: 'AdaptationSet'} + ]); + }); + + it('path with attribute selector single quote parsed', function () { + let xpath = new SimpleXPath('/MPD/Period[@id=\'foobar\']/AdaptationSet'); + expect(xpath.isValid()).to.be.true; // jshint ignore:line + expect(xpath.path).to.deep.equal([ + {name: 'MPD'}, + {name: 'Period', attribute: {name: 'id', value: 'foobar'}}, + {name: 'AdaptationSet'} + ]); + }); + + it('path with attribute selector double quote parsed', function () { + let xpath = new SimpleXPath('/MPD/Period[@id="foobar"]/AdaptationSet'); + expect(xpath.isValid()).to.be.true; // jshint ignore:line + expect(xpath.path).to.deep.equal([ + {name: 'MPD'}, + {name: 'Period', attribute: {name: 'id', value: 'foobar'}}, + {name: 'AdaptationSet'} + ]); + }); + + it('non-absolute path marked invalid', function () { + let xpath = new SimpleXPath('Period/AdaptationSet'); + expect(xpath.isValid()).to.be.false; // jshint ignore:line + }); + + it('path with mixture of selectors marked invalid', function () { + let xpath = new SimpleXPath('/MPD/Period[@start=foo][@id=bar]/AdaptationSet'); + expect(xpath.isValid()).to.be.false; // jshint ignore:line + }); + }); + + describe('findsElement/findsAttribute', function () { + it('should properly identify element endpoint', function () { + let xpath = new SimpleXPath('/MPD/Period'); + expect(xpath.findsElement()).to.be.true; // jshint ignore:line + expect(xpath.findsAttribute()).to.be.false; // jshint ignore:line + }); + + it('should properly identify attribute endpoint', function () { + let xpath = new SimpleXPath('/MPD/Period/@id'); + expect(xpath.findsElement()).to.be.false; // jshint ignore:line + expect(xpath.findsAttribute()).to.be.true; // jshint ignore:line + }); + }); + + describe('getMpdTarget', function () { + // basic MPD that has all search cases: + const patchHelper = new PatchHelper(); + const mpd = patchHelper.getStaticBaseMPD(); + + + it('should find node with basic path', function () { + let xpath = new SimpleXPath('/MPD/UTCTiming'); + let result = xpath.getMpdTarget(mpd); + expect(result.name).to.equal('UTCTiming'); + expect(result.leaf).to.equal('timetime'); + expect(result.target).to.equal(result.leaf); + }); + + it('should find node parent with basic path and sibling search', function () { + let xpath = new SimpleXPath('/MPD/UTCTiming'); + let result = xpath.getMpdTarget(mpd, true); + expect(result.name).to.equal('UTCTiming'); + expect(result.leaf).to.equal('timetime'); + expect(result.target).to.equal(mpd); + }); + + it('should find node when using position search with one child', function () { + let xpath = new SimpleXPath('/MPD/Period[1]/BaseURL'); + let result = xpath.getMpdTarget(mpd); + expect(result.name).to.equal('BaseURL'); + expect(result.leaf).to.equal(mpd.Period.BaseURL); + }); + + it('should find node when using implicit position search with one child', function () { + let xpath = new SimpleXPath('/MPD/Period/BaseURL'); + let result = xpath.getMpdTarget(mpd); + expect(result.name).to.equal('BaseURL'); + expect(result.leaf).to.equal(mpd.Period.BaseURL); + }); + + it('should find node when using position search with multiple children', function () { + let xpath = new SimpleXPath('/MPD/Period/AdaptationSet[2]/SegmentTemplate'); + let result = xpath.getMpdTarget(mpd); + expect(result.name).to.equal('SegmentTemplate'); + expect(result.leaf).to.equal(mpd.Period.AdaptationSet[1].SegmentTemplate); + }); + + it('should find node when ending with positional search of one child', function () { + let xpath = new SimpleXPath('/MPD/Period[1]'); + let result = xpath.getMpdTarget(mpd); + expect(result.name).to.equal('Period'); + expect(result.leaf).to.equal(mpd.Period); + }); + + it('should find node when ending with positional search of multiple children', function () { + let xpath = new SimpleXPath('/MPD/Period/AdaptationSet[2]'); + let result = xpath.getMpdTarget(mpd); + expect(result.name).to.equal('AdaptationSet'); + expect(result.leaf).to.equal(mpd.Period.AdaptationSet[1]); + }); + + it('should find node when attribute search used for one child', function () { + let xpath = new SimpleXPath('/MPD/Period[@id="foo"]/BaseURL'); + let result = xpath.getMpdTarget(mpd); + expect(result.name).to.equal('BaseURL'); + expect(result.leaf).to.equal(mpd.Period.BaseURL); + }); + + it('should find node when attribute search used for multiple children', function () { + let xpath = new SimpleXPath('/MPD/Period/AdaptationSet[@id=20]/SegmentTemplate'); + let result = xpath.getMpdTarget(mpd); + expect(result.name).to.equal('SegmentTemplate'); + expect(result.leaf).to.equal(mpd.Period.AdaptationSet[1].SegmentTemplate); + }); + + it('should find node when path ends in attributes search with one child', function () { + let xpath = new SimpleXPath('/MPD/Period[@id="foo"]'); + let result = xpath.getMpdTarget(mpd); + expect(result.name).to.equal('Period'); + expect(result.leaf).to.equal(mpd.Period); + }); + + it('should find node when path ends in attributes search with multiple children', function () { + let xpath = new SimpleXPath('/MPD/Period/AdaptationSet[@id=20]'); + let result = xpath.getMpdTarget(mpd); + expect(result.name).to.equal('AdaptationSet'); + expect(result.leaf).to.equal(mpd.Period.AdaptationSet[1]); + }); + + it('should find node when path targets attribute', function () { + let xpath = new SimpleXPath('/MPD/BaseURL/@serviceLocation'); + let result = xpath.getMpdTarget(mpd); + expect(result.name).to.equal('serviceLocation'); + expect(result.leaf).to.equal(mpd.BaseURL); + }); + + it('should fail to find positional search that does not exist', function () { + let xpath = new SimpleXPath('/MPD/Period[5]/BaseURL'); + let result = xpath.getMpdTarget(mpd); + expect(result).to.be.null; // jshint ignore:line + }); + + it('should fail to find positional search end that does not exist', function () { + let xpath = new SimpleXPath('/MPD/Period[5]'); + let result = xpath.getMpdTarget(mpd); + expect(result).to.be.null; // jshint ignore:line + }); + + it('should fail to find attribute search that does not exist', function () { + let xpath = new SimpleXPath('/MPD/Period[@id="bar"]/BaseURL'); + let result = xpath.getMpdTarget(mpd); + expect(result).to.be.null; // jshint ignore:line + }); + + it('should fail to find attribute search end that does not exist', function () { + let xpath = new SimpleXPath('/MPD/Period[@id="bar"]'); + let result = xpath.getMpdTarget(mpd); + expect(result).to.be.null; // jshint ignore:line + }); + + it('should find node in typical segment append case', function () { + let xpath = new SimpleXPath('/MPD/Period[@id="foo"]/AdaptationSet[@id="20"]/SegmentTemplate/SegmentTimeline'); + let result = xpath.getMpdTarget(mpd); + expect(result.name).to.equal('SegmentTimeline'); + expect(result.leaf).to.equal(mpd.Period.AdaptationSet[1].SegmentTemplate.SegmentTimeline); + }); + }); +}); diff --git a/test/unit/helpers/PatchHelper.js b/test/unit/helpers/PatchHelper.js new file mode 100644 index 0000000000..8ac80f0921 --- /dev/null +++ b/test/unit/helpers/PatchHelper.js @@ -0,0 +1,135 @@ +import DashConstants from '../../../src/dash/constants/DashConstants'; + +function staticSElements() { + return [[0,10], [10,5], [15,10]].map(([t, d]) => { + return { + __children: [], + d: d, + t: t + }; + }); +} + +function staticSegmentTimeline() { + let sElements = staticSElements(); + return { + S: sElements, + S_asArray: sElements.slice(), + __children: sElements.map((element) => { + return { + S: element + }; + }) + }; +} + +function staticSegmentTemplate() { + let timeline = staticSegmentTimeline(); + return { + SegmentTimeline: timeline, // purposely omit the _asArray to ensure single node case captured + __children: [{ + SegmentTimeline: timeline + }] + }; +} + +function staticAdaptationSet(id) { + let template = staticSegmentTemplate(); + return { + SegmentTemplate: template, // purposely omit the _asArray to ensure single node case captured + __children: [{ + SegmentTemplate: template + }], + id: id + }; +} + +function staticBaseUrl(url, serviceLocation) { + if(!serviceLocation) { + return url; + } + return { + __children: [ + { + '#text': url + } + ], + serviceLocation: serviceLocation, + __text: url + } +} + +function staticPeriod(id) { + let baseUrl = staticBaseUrl(`period-${id}/`); + let adaptationSets = [staticAdaptationSet(10), staticAdaptationSet(20)]; + return { + BaseURL: baseUrl, + BaseURL_asArray: [baseUrl], + AdaptationSet: adaptationSets, + AdaptationSet_asArray: adaptationSets.slice(), + __children: [ + { BaseURL: baseUrl }, + { AdaptationSet: adaptationSets[0] }, + { AdaptationSet: adaptationSets[1] } + ], + id: id + } +} + +class PatchHelper { + + generatePatch(mpdId, operations = []) { + return { + [DashConstants.ORIGINAL_MPD_ID]: mpdId, + [DashConstants.PUBLISH_TIME]: new Date().toISOString(), + [DashConstants.ORIGINAL_PUBLISH_TIME]: new Date().toISOString(), + // only the ordered child array is simulated + __children: operations.map((operation) => { + if (operation.action == 'add') { + // add is special because it has extra possible attributes + return { + add: { + sel: operation.selector, + __children: operation.children, + __text: operation.text, + pos: operation.position, + type: operation.type + } + }; + } else { + return { + [operation.action]: { + sel: operation.selector, + __children: operation.children, + __text: operation.text + } + }; + } + }) + }; + } + + getStaticBaseMPD() { + // we will generate a simple base manifest example, it will not be a fully valid manifest + // but it will match the object structure of X2JS + let baseUrl = staticBaseUrl('http://example.com/base', 'a'); + let utcTiming = 'timetime'; + let period = staticPeriod('foo'); + return { + UTCTiming: utcTiming, + UTCTiming_asArray: [utcTiming], + BaseURL: baseUrl, + BaseURL_asArray: [baseUrl], + Period: period, + Period_asArray: [period], + __children: [ + { UTCTiming: utcTiming }, + { BaseURL: baseUrl }, + { Period: period } + ], + id: 'foobar' + } + } +} + +export default PatchHelper; diff --git a/test/unit/mocks/AdapterMock.js b/test/unit/mocks/AdapterMock.js index 4e046c82ff..a8a485e9e6 100644 --- a/test/unit/mocks/AdapterMock.js +++ b/test/unit/mocks/AdapterMock.js @@ -87,6 +87,10 @@ function AdapterMock () { return 0; }; + this.getPublishTime = function () { + return null; + }; + this.updatePeriods = function () { }; @@ -106,6 +110,16 @@ function AdapterMock () { return false; }; + this.getIsPatch = function () { + return false; + }; + + this.isPatchValid = function () { + return false; + }; + + this.applyPatchToManifest = function () {}; + this.convertDataToRepresentationInfo = function () { return null; }; @@ -146,6 +160,14 @@ function AdapterMock () { }); }; + + this.getLocation = function () { + return null; + }; + + this.getPatchLocation = function () { + return null; + }; } export default AdapterMock; diff --git a/test/unit/streaming.ManifestUpdater.js b/test/unit/streaming.ManifestUpdater.js index dc0eb29fff..f2828285ec 100644 --- a/test/unit/streaming.ManifestUpdater.js +++ b/test/unit/streaming.ManifestUpdater.js @@ -3,11 +3,13 @@ import Events from '../../src/core/events/Events'; import EventBus from '../../src/core/EventBus'; import Errors from '../../src/core/errors/Errors'; +import AdapterMock from './mocks/AdapterMock'; import ManifestModelMock from './mocks/ManifestModelMock'; import ManifestLoaderMock from './mocks/ManifestLoaderMock'; import ErrorHandlerMock from './mocks/ErrorHandlerMock'; const chai = require('chai'); +const sinon = require('sinon'); const expect = chai.expect; describe('ManifestUpdater', function () { @@ -16,6 +18,7 @@ describe('ManifestUpdater', function () { let manifestUpdater = ManifestUpdater(context).create(); // init mock + const adapterMock = new AdapterMock(); const manifestModelMock = new ManifestModelMock(); const manifestLoaderMock = new ManifestLoaderMock(); const errHandlerMock = new ErrorHandlerMock(); @@ -23,6 +26,7 @@ describe('ManifestUpdater', function () { const manifestErrorMockText = `Mock Failed detecting manifest type or manifest type unsupported`; manifestUpdater.setConfig({ + adapter: adapterMock, manifestModel: manifestModelMock, manifestLoader: manifestLoaderMock, errHandler: errHandlerMock @@ -59,4 +63,187 @@ describe('ManifestUpdater', function () { expect(errHandlerMock.errorCode).to.equal(Errors.MANIFEST_LOADER_PARSING_FAILURE_ERROR_CODE); // jshint ignore:line }); -}); \ No newline at end of file + + it('should call MANIFEST_UPDATED with existing manifest if update provided 204', function () { + let originalTime = new Date(Date.now() - 1000 * 60 * 10); // originally 10 minutes ago + let inMemoryManifest = { + loadedTime: originalTime + }; + manifestModelMock.setValue(inMemoryManifest); + + const spy = sinon.spy(); // using sinon to access call params later + eventBus.on(Events.MANIFEST_UPDATED, spy); + + // call empty for 204 simulation + eventBus.trigger(Events.INTERNAL_MANIFEST_LOADED, {}); + + expect(manifestModelMock.getValue()).to.equal(inMemoryManifest); + expect(inMemoryManifest.loadedTime).to.not.equal(originalTime); + expect(spy.calledOnce).to.be.true; // jshint ignore:line + expect(spy.firstCall.args[0].manifest).to.equal(inMemoryManifest); + + manifestModelMock.setValue(null); + eventBus.off(Events.MANIFEST_UPDATED, spy); + }); + + it('should call MANIFEST_UPDATED with patched manifest if valid patch update provided', function () { + let originalTime = new Date(Date.now() - 1000 * 60 * 10); + let inMemoryManifest = { + loadedTime: originalTime + }; + manifestModelMock.setValue(inMemoryManifest); + + const patchCheckStub = sinon.stub(adapterMock, 'getIsPatch').returns(true); + const isPatchValidStub = sinon.stub(adapterMock, 'isPatchValid').returns(true); + const applyPatchStub = sinon.stub(adapterMock, 'applyPatchToManifest'); + const publishTimeStub = sinon.stub(adapterMock, 'getPublishTime'); + publishTimeStub.onCall(0).returns(originalTime); + publishTimeStub.onCall(1).returns(new Date()); + + const spy = sinon.spy(); + eventBus.on(Events.MANIFEST_UPDATED, spy); + + const patch = {}; + eventBus.trigger(Events.INTERNAL_MANIFEST_LOADED, {manifest: patch}); + + expect(manifestModelMock.getValue()).to.equal(inMemoryManifest); + expect(patchCheckStub.called).to.be.true; // jshint ignore:line + expect(isPatchValidStub.called).to.be.true; // jshint ignore:line + expect(applyPatchStub.calledWith(inMemoryManifest, patch)).to.be.true; // jshint ignore:line + expect(spy.calledOnce).to.be.true; // jshint ignore:line + expect(spy.firstCall.args[0].manifest).to.equal(inMemoryManifest); + + manifestModelMock.setValue(null); + patchCheckStub.restore(); + isPatchValidStub.restore(); + applyPatchStub.restore(); + publishTimeStub.restore(); + eventBus.off(Events.MANIFEST_UPDATED, spy); + }); + + it('should force full manifest refresh if invalid patch update provided', function () { + let originalTime = new Date(Date.now() - 1000 * 60 * 10); + let inMemoryManifest = { + loadedTime: originalTime + }; + manifestModelMock.setValue(inMemoryManifest); + + const patchCheckStub = sinon.stub(adapterMock, 'getIsPatch').returns(true); + const isPatchValidStub = sinon.stub(adapterMock, 'isPatchValid').returns(false); + const applyPatchStub = sinon.stub(adapterMock, 'applyPatchToManifest'); + const loaderStub = sinon.stub(manifestLoaderMock, 'load'); + const publishTimeStub = sinon.stub(adapterMock, 'getPublishTime'); + publishTimeStub.onCall(0).returns(originalTime); + publishTimeStub.onCall(1).returns(new Date()); + + const patch = {}; + eventBus.trigger(Events.INTERNAL_MANIFEST_LOADED, {manifest: patch}); + + expect(manifestModelMock.getValue()).to.equal(inMemoryManifest); + expect(patchCheckStub.called).to.be.true; // jshint ignore:line + expect(isPatchValidStub.called).to.be.true; // jshint ignore:line + expect(applyPatchStub.called).to.be.false; // jshint ignore:line + expect(loaderStub.called).to.be.true; // jshint ignore:line + + manifestModelMock.setValue(null); + patchCheckStub.restore(); + isPatchValidStub.restore(); + applyPatchStub.restore(); + publishTimeStub.restore(); + loaderStub.restore(); + }); + + it('should force full manifest refresh if patch update does not update publish time', function () { + let originalTime = new Date(Date.now() - 1000 * 60 * 10); + let inMemoryManifest = { + loadedTime: originalTime + }; + manifestModelMock.setValue(inMemoryManifest); + + const patchCheckStub = sinon.stub(adapterMock, 'getIsPatch').returns(true); + const isPatchValidStub = sinon.stub(adapterMock, 'isPatchValid').returns(true); + const applyPatchStub = sinon.stub(adapterMock, 'applyPatchToManifest'); + const loaderStub = sinon.stub(manifestLoaderMock, 'load'); + const publishTimeStub = sinon.stub(adapterMock, 'getPublishTime').returns(originalTime); + + const patch = {}; + eventBus.trigger(Events.INTERNAL_MANIFEST_LOADED, {manifest: patch}); + + expect(manifestModelMock.getValue()).to.equal(inMemoryManifest); + expect(patchCheckStub.called).to.be.true; // jshint ignore:line + expect(isPatchValidStub.called).to.be.true; // jshint ignore:line + expect(applyPatchStub.called).to.be.true; // jshint ignore:line + expect(loaderStub.called).to.be.true; // jshint ignore:line + + manifestModelMock.setValue(null); + patchCheckStub.restore(); + isPatchValidStub.restore(); + applyPatchStub.restore(); + publishTimeStub.restore(); + loaderStub.restore(); + }); + + describe('refresh manifest location', function () { + const patchLocation = 'http://example.com/bar'; + const location = 'http://example.com/baz'; + const manifest = { + url: 'http://example.com' + }; + + let patchLocationStub, locationStub, loadStub; + + beforeEach(function () { + patchLocationStub = sinon.stub(adapterMock, 'getPatchLocation'); + locationStub = sinon.stub(adapterMock, 'getLocation'); + loadStub = sinon.stub(manifestLoaderMock, 'load'); + manifestModelMock.setValue(manifest); + }); + + afterEach(function () { + patchLocationStub.restore(); + locationStub.restore(); + loadStub.restore(); + manifestModelMock.setValue(null); + + patchLocationStub = null; + locationStub = null; + loadStub = null; + }); + + it('should utilize patch location for update if provided one', function () { + patchLocationStub.returns(patchLocation); + locationStub.returns(location); + + manifestUpdater.refreshManifest(); + + expect(loadStub.calledWith(patchLocation)).to.be.true; // jshint ignore:line + }); + + it('should utilize location for update if provided one and no patch location', function () { + patchLocationStub.returns(null); + locationStub.returns(location); + + manifestUpdater.refreshManifest(); + + expect(loadStub.calledWith(location)).to.be.true; // jshint ignore:line + }); + + it('should utilize original mpd location if no other signal provided', function () { + patchLocationStub.returns(null); + locationStub.returns(null); + + manifestUpdater.refreshManifest(); + + expect(loadStub.calledWith(manifest.url)).to.be.true; // jshint ignore:line + }); + + it('should make relative locations absolute relative to the manifest', function () { + patchLocationStub.returns(null); + locationStub.returns('baz'); // should convert to 'http://example.com/baz' + + manifestUpdater.refreshManifest(); + + expect(loadStub.calledWith(location)).to.be.true; // jshint ignore:line + }); + }); +});