diff --git a/README.md b/README.md index 51eae93984..05b4454df2 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,8 @@ DASH features supported: - CEA-608/708 captions - Multi-codec variants (on platforms with changeType support) - MPD chaining + - MPD Patch updates for SegmentTemplate with $Number$, SegmentTimeline with + $Number$ and SegmentTimeline with $Time$ DASH features **not** supported: - Xlink with actuate=onRequest @@ -116,6 +118,8 @@ DASH features **not** supported: bitrates - Timescales so large that timestamps cannot be represented as integers in JavaScript (2^53): https://github.com/shaka-project/shaka-player/issues/1667 + - Modifying elements with an @schemeIdUri attribute via MPD Patch + - Xlink dereferencing with MPD Patch ## HLS features diff --git a/demo/common/assets.js b/demo/common/assets.js index 2d4df3357b..dffbd6dd3b 100644 --- a/demo/common/assets.js +++ b/demo/common/assets.js @@ -164,6 +164,9 @@ shakaAssets.Feature = { // Set if the asset has Content Steering. CONTENT_STEERING: 'Content Steering', + // Set if the asset supports MPD Patch. + MPD_PATCH: 'MPD Patch', + // Set if the asset is VR. VR: 'VR', @@ -1003,6 +1006,60 @@ shakaAssets.testAssets = [ .addFeature(shakaAssets.Feature.MP4) .addFeature(shakaAssets.Feature.LIVE) .addFeature(shakaAssets.Feature.THUMBNAILS), + new ShakaDemoAssetInfo( + /* name= */ 'DASH-IF MPD Patch - SegmentTemplate with $Number$ (livesim)', + /* iconUri= */ 'https://storage.googleapis.com/shaka-asset-icons/dash_if_test_pattern.png', + /* manifestUri= */ 'https://livesim2.dashif.org/livesim2/patch_60/testpic_2s/Manifest.mpd', + /* source= */ shakaAssets.Source.DASH_IF) + .addFeature(shakaAssets.Feature.DASH) + .addFeature(shakaAssets.Feature.MP4) + .addFeature(shakaAssets.Feature.LIVE) + .addFeature(shakaAssets.Feature.MPD_PATCH), + new ShakaDemoAssetInfo( + /* name= */ 'DASH-IF MPD Patch - SegmentTemplate with $Number$, multiperiod (livesim)', + /* iconUri= */ 'https://storage.googleapis.com/shaka-asset-icons/dash_if_test_pattern.png', + /* manifestUri= */ 'https://livesim2.dashif.org/livesim2/patch_60/periods_60/testpic_2s/Manifest.mpd', + /* source= */ shakaAssets.Source.DASH_IF) + .addFeature(shakaAssets.Feature.DASH) + .addFeature(shakaAssets.Feature.MP4) + .addFeature(shakaAssets.Feature.LIVE) + .addFeature(shakaAssets.Feature.MPD_PATCH), + new ShakaDemoAssetInfo( + /* name= */ 'DASH-IF MPD Patch - SegmentTimeline with $Number$ (livesim)', + /* iconUri= */ 'https://storage.googleapis.com/shaka-asset-icons/dash_if_test_pattern.png', + /* manifestUri= */ 'https://livesim2.dashif.org/livesim2/patch_60/segtimelinenr_1/testpic_2s/Manifest.mpd', + /* source= */ shakaAssets.Source.DASH_IF) + .addFeature(shakaAssets.Feature.DASH) + .addFeature(shakaAssets.Feature.MP4) + .addFeature(shakaAssets.Feature.LIVE) + .addFeature(shakaAssets.Feature.MPD_PATCH), + new ShakaDemoAssetInfo( + /* name= */ 'DASH-IF MPD Patch - SegmentTimeline with $Number$, multiperiod (livesim)', + /* iconUri= */ 'https://storage.googleapis.com/shaka-asset-icons/dash_if_test_pattern.png', + /* manifestUri= */ 'https://livesim2.dashif.org/livesim2/patch_60/segtimelinenr_1/periods_60/testpic_2s/Manifest.mpd', + /* source= */ shakaAssets.Source.DASH_IF) + .addFeature(shakaAssets.Feature.DASH) + .addFeature(shakaAssets.Feature.MP4) + .addFeature(shakaAssets.Feature.LIVE) + .addFeature(shakaAssets.Feature.MPD_PATCH), + new ShakaDemoAssetInfo( + /* name= */ 'DASH-IF MPD Patch - SegmentTimeline with $Time$ (livesim)', + /* iconUri= */ 'https://storage.googleapis.com/shaka-asset-icons/dash_if_test_pattern.png', + /* manifestUri= */ 'https://livesim2.dashif.org/livesim2/patch_60/segtimeline_1/testpic_2s/Manifest.mpd', + /* source= */ shakaAssets.Source.DASH_IF) + .addFeature(shakaAssets.Feature.DASH) + .addFeature(shakaAssets.Feature.MP4) + .addFeature(shakaAssets.Feature.LIVE) + .addFeature(shakaAssets.Feature.MPD_PATCH), + new ShakaDemoAssetInfo( + /* name= */ 'DASH-IF MPD Patch - SegmentTimeline with $Time$, multiperiod (livesim)', + /* iconUri= */ 'https://storage.googleapis.com/shaka-asset-icons/dash_if_test_pattern.png', + /* manifestUri= */ 'https://livesim2.dashif.org/livesim2/patch_60/segtimeline_1/periods_60/testpic_2s/Manifest.mpd', + /* source= */ shakaAssets.Source.DASH_IF) + .addFeature(shakaAssets.Feature.DASH) + .addFeature(shakaAssets.Feature.MP4) + .addFeature(shakaAssets.Feature.LIVE) + .addFeature(shakaAssets.Feature.MPD_PATCH), new ShakaDemoAssetInfo( /* name= */ 'DASH-IF - Regular chaining', /* iconUri= */ '', diff --git a/demo/search.js b/demo/search.js index 5907f3be3c..d7d737fa44 100644 --- a/demo/search.js +++ b/demo/search.js @@ -397,6 +397,8 @@ shakaDemo.Search = class { 'Filters for assets that have an LCEVC enhancement layer.'); this.makeBooleanInput_(specialContainer, Feature.CONTENT_STEERING, FEATURE, 'Filters for assets that use Content Steering.'); + this.makeBooleanInput_(specialContainer, Feature.MPD_PATCH, FEATURE, + 'Filters for assets that use MPD Patch.'); this.makeBooleanInput_(specialContainer, Feature.VR, FEATURE, 'Filters for assets that are VR.'); this.makeBooleanInput_(specialContainer, Feature.MPD_CHAINING, FEATURE, diff --git a/lib/dash/dash_parser.js b/lib/dash/dash_parser.js index c1d0a77360..1a406ca7d6 100644 --- a/lib/dash/dash_parser.js +++ b/lib/dash/dash_parser.js @@ -62,6 +62,34 @@ shaka.dash.DashParser = class { /** @private {number} */ this.globalId_ = 1; + /** @private {!Array} */ + this.patchLocationNodes_ = []; + + /** + * A context of the living manifest used for processing + * Patch MPD's + * @private {!shaka.dash.DashParser.PatchContext} + */ + this.manifestPatchContext_ = { + mpdId: '', + type: '', + profiles: [], + mediaPresentationDuration: null, + availabilityTimeOffset: 0, + getBaseUris: null, + publishTime: 0, + }; + + /** + * This is a cache is used the store a snapshot of the context + * object which is built up throughout node traversal to maintain + * a current state. This data needs to be preserved for parsing + * patches. + * The key is a combination period and representation id's. + * @private {!Map} + */ + this.contextCache_ = new Map(); + /** * A map of IDs to Stream objects. * ID: Period@id,AdaptationSet@id,@Representation@id @@ -204,6 +232,16 @@ shaka.dash.DashParser = class { this.manifestUris_ = []; this.manifest_ = null; this.streamMap_ = {}; + this.contextCache_.clear(); + this.manifestPatchContext_ = { + mpdId: '', + type: '', + profiles: [], + mediaPresentationDuration: null, + availabilityTimeOffset: 0, + getBaseUris: null, + publishTime: 0, + }; this.periodCombiner_ = null; if (this.updateTimer_ != null) { @@ -289,8 +327,13 @@ shaka.dash.DashParser = class { async requestManifest_() { const requestType = shaka.net.NetworkingEngine.RequestType.MANIFEST; const type = shaka.net.NetworkingEngine.AdvancedRequestType.MPD; + let rootElement = 'MPD'; + const patchLocationUris = this.getPatchLocationUris_(); let manifestUris = this.manifestUris_; - if (this.manifestUris_.length > 1 && this.contentSteeringManager_) { + if (patchLocationUris.length) { + manifestUris = patchLocationUris; + rootElement = 'Patch'; + } else if (this.manifestUris_.length > 1 && this.contentSteeringManager_) { const locations = this.contentSteeringManager_.getLocations( 'Location', /* ignoreBaseUrls= */ true); if (locations.length) { @@ -317,7 +360,7 @@ shaka.dash.DashParser = class { } // This may throw, but it will result in a failed promise. - await this.parseManifest_(response.data, response.uri); + await this.parseManifest_(response.data, response.uri, rootElement); // Keep track of how long the longest manifest update took. const endTime = Date.now(); const updateDuration = (endTime - startTime) / 1000.0; @@ -334,10 +377,11 @@ shaka.dash.DashParser = class { * @param {BufferSource} data * @param {string} finalManifestUri The final manifest URI, which may * differ from this.manifestUri_ if there has been a redirect. + * @param {string} rootElement MPD or Patch, depending on context * @return {!Promise} * @private */ - async parseManifest_(data, finalManifestUri) { + async parseManifest_(data, finalManifestUri, rootElement) { let manifestData = data; const manifestPreprocessor = this.config_.dash.manifestPreprocessor; const defaultManifestPreprocessor = @@ -347,7 +391,7 @@ shaka.dash.DashParser = class { 'manifest.dash.manifestPreprocessor configuration', 'Please Use manifest.dash.manifestPreprocessorTXml instead.'); const mpdElement = - shaka.util.XmlUtils.parseXml(manifestData, 'MPD'); + shaka.util.XmlUtils.parseXml(manifestData, rootElement); if (!mpdElement) { throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, @@ -358,7 +402,7 @@ shaka.dash.DashParser = class { manifestPreprocessor(mpdElement); manifestData = shaka.util.XmlUtils.toArrayBuffer(mpdElement); } - const mpd = shaka.util.TXml.parseXml(manifestData, 'MPD'); + const mpd = shaka.util.TXml.parseXml(manifestData, rootElement); if (!mpd) { throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, @@ -374,6 +418,10 @@ shaka.dash.DashParser = class { manifestPreprocessorTXml(mpd); } + if (rootElement === 'Patch') { + return this.processPatchManifest_(mpd); + } + const disableXlinkProcessing = this.config_.dash.disableXlinkProcessing; if (disableXlinkProcessing) { return this.processManifest_(mpd, finalManifestUri); @@ -448,6 +496,11 @@ shaka.dash.DashParser = class { manifestBaseUris = locations; } + this.manifestPatchContext_.mpdId = mpd.attributes['id'] || ''; + this.manifestPatchContext_.publishTime = + TXml.parseAttr(mpd, 'publishTime', TXml.parseDate) || 0; + this.patchLocationNodes_ = TXml.findChildren(mpd, 'PatchLocation'); + let contentSteeringPromise = Promise.resolve(); const contentSteering = TXml.findChild(mpd, 'ContentSteering'); @@ -515,11 +568,14 @@ shaka.dash.DashParser = class { return []; }; + this.manifestPatchContext_.getBaseUris = getBaseUris; + let availabilityTimeOffset = 0; if (uriObjs && uriObjs.length) { availabilityTimeOffset = TXml.parseAttr(uriObjs[0], 'availabilityTimeOffset', TXml.parseFloat) || 0; } + this.manifestPatchContext_.availabilityTimeOffset = availabilityTimeOffset; const ignoreMinBufferTime = this.config_.dash.ignoreMinBufferTime; let minBufferTime = 0; @@ -553,6 +609,8 @@ shaka.dash.DashParser = class { } const mpdType = mpd.attributes['type'] || 'static'; + this.manifestPatchContext_.type = mpdType; + /** @type {!shaka.media.PresentationTimeline} */ let presentationTimeline; if (this.manifest_) { @@ -612,6 +670,7 @@ shaka.dash.DashParser = class { segmentAvailabilityDuration); const profiles = mpd.attributes['profiles'] || ''; + this.manifestPatchContext_.profiles = profiles.split(','); /** @type {shaka.dash.DashParser.Context} */ const context = { @@ -626,6 +685,7 @@ shaka.dash.DashParser = class { bandwidth: 0, indexRangeWarningGiven: false, availabilityTimeOffset: availabilityTimeOffset, + mediaPresentationDuration: null, profiles: profiles.split(','), }; @@ -671,8 +731,6 @@ shaka.dash.DashParser = class { presentationTimeline.assertIsValid(); } - await this.periodCombiner_.combinePeriods(periods, context.dynamic); - await contentSteeringPromise; // Set minBufferTime to 0 for low-latency DASH live stream to achieve the @@ -686,6 +744,8 @@ shaka.dash.DashParser = class { // These steps are not done on manifest update. if (!this.manifest_) { + await this.periodCombiner_.combinePeriods(periods, context.dynamic); + this.manifest_ = { presentationTimeline: presentationTimeline, variants: this.periodCombiner_.getVariants(), @@ -718,19 +778,8 @@ shaka.dash.DashParser = class { // maintain consistency from here on. presentationTimeline.lockStartTime(); } else { - // Just update the variants and text streams, which may change as periods - // are added or removed. - this.manifest_.variants = this.periodCombiner_.getVariants(); - const textStreams = this.periodCombiner_.getTextStreams(); - if (textStreams.length > 0) { - this.manifest_.textStreams = textStreams; - } - this.manifest_.imageStreams = this.periodCombiner_.getImageStreams(); - - // Re-filter the manifest. This will check any configured restrictions on - // new variants, and will pass any new init data to DrmEngine to ensure - // that key rotation works correctly. - this.playerInterface_.filter(this.manifest_); + await this.postPeriodProcessing_( + periodsAndDuration.periods, /* isPatchUpdate= */ false); } // Add text streams to correspond to closed captions. This happens right @@ -739,6 +788,342 @@ shaka.dash.DashParser = class { this.playerInterface_.makeTextStreamsForClosedCaptions(this.manifest_); } + /** + * Handles common procedures after processing new periods. + * + * @param {!Array} periods to be appended + * @param {boolean} isPatchUpdate does call comes from mpd patch update + * @private + */ + async postPeriodProcessing_(periods, isPatchUpdate) { + await this.periodCombiner_.combinePeriods(periods, true, isPatchUpdate); + + // Just update the variants and text streams, which may change as periods + // are added or removed. + this.manifest_.variants = this.periodCombiner_.getVariants(); + const textStreams = this.periodCombiner_.getTextStreams(); + if (textStreams.length > 0) { + this.manifest_.textStreams = textStreams; + } + this.manifest_.imageStreams = this.periodCombiner_.getImageStreams(); + + // Re-filter the manifest. This will check any configured restrictions on + // new variants, and will pass any new init data to DrmEngine to ensure + // that key rotation works correctly. + this.playerInterface_.filter(this.manifest_); + } + + /** + * Takes a formatted Patch MPD and converts it into a manifest. + * + * @param {!shaka.extern.xml.Node} mpd + * @return {!Promise} + * @private + */ + async processPatchManifest_(mpd) { + const TXml = shaka.util.TXml; + + const mpdId = mpd.attributes['mpdId']; + const originalPublishTime = TXml.parseAttr(mpd, 'originalPublishTime', + TXml.parseDate); + if (!mpdId || mpdId !== this.manifestPatchContext_.mpdId || + originalPublishTime !== this.manifestPatchContext_.publishTime) { + // Clean patch location nodes, so it will force full MPD update. + this.patchLocationNodes_ = []; + throw new shaka.util.Error( + shaka.util.Error.Severity.RECOVERABLE, + shaka.util.Error.Category.MANIFEST, + shaka.util.Error.Code.DASH_INVALID_PATCH); + } + + /** @type {!Array} */ + const newPeriods = []; + /** @type {!Array} */ + const periodAdditions = []; + /** @type {!Set} */ + const modifiedTimelines = new Set(); + + for (const patchNode of TXml.getChildNodes(mpd)) { + let handled = true; + const paths = TXml.parseXpath(patchNode.attributes['sel'] || ''); + const node = paths[paths.length - 1]; + const content = TXml.getContents(patchNode) || ''; + + if (node.name === 'MPD') { + if (node.attribute === 'mediaPresentationDuration') { + const content = TXml.getContents(patchNode) || ''; + this.parsePatchMediaPresentationDurationChange_(content); + } else if (node.attribute === 'type') { + this.parsePatchMpdTypeChange_(content); + } else if (node.attribute === 'publishTime') { + this.manifestPatchContext_.publishTime = TXml.parseDate(content) || 0; + } else if (node.attribute === null && patchNode.tagName === 'add') { + periodAdditions.push(patchNode); + } else { + handled = false; + } + } else if (node.name === 'PatchLocation') { + this.updatePatchLocationNodes_(patchNode); + } else if (node.name === 'Period') { + if (patchNode.tagName === 'add') { + periodAdditions.push(patchNode); + } else if (patchNode.tagName === 'remove' && node.id) { + this.removePatchPeriod_(node.id); + } + } else if (node.name === 'SegmentTemplate') { + const timelines = this.modifySegmentTemplate_(patchNode); + for (const timeline of timelines) { + modifiedTimelines.add(timeline); + } + } else if (node.name === 'SegmentTimeline' || node.name === 'S') { + const timelines = this.modifyTimepoints_(patchNode); + for (const timeline of timelines) { + modifiedTimelines.add(timeline); + } + } else { + handled = false; + } + if (!handled) { + shaka.log.warning('Unhandled ' + patchNode.tagName + ' operation', + patchNode.attributes['sel']); + } + } + + for (const timeline of modifiedTimelines) { + this.parsePatchSegment_(timeline); + } + + // Add new periods after extending timelines, as new periods + // remove context cache of previous periods. + for (const periodAddition of periodAdditions) { + newPeriods.push(...this.parsePatchPeriod_(periodAddition)); + } + + if (newPeriods.length) { + await this.postPeriodProcessing_(newPeriods, /* isPatchUpdate= */ true); + } + if (this.manifestPatchContext_.type == 'static') { + const duration = this.manifestPatchContext_.mediaPresentationDuration; + this.manifest_.presentationTimeline.setDuration(duration || Infinity); + } + } + + /** + * Handles manifest type changes, this transition is expected to be + * "dyanmic" to "static". + * + * @param {!string} mpdType + * @private + */ + parsePatchMpdTypeChange_(mpdType) { + this.manifest_.presentationTimeline.setStatic(mpdType == 'static'); + this.manifestPatchContext_.type = mpdType; + for (const context of this.contextCache_.values()) { + context.dynamic = mpdType == 'dynamic'; + } + if (mpdType == 'static') { + // Manifest is no longer dynamic, so stop live updates. + this.updatePeriod_ = -1; + } + } + + /** + * @param {string} durationString + * @private + */ + parsePatchMediaPresentationDurationChange_(durationString) { + const duration = shaka.util.TXml.parseDuration(durationString); + if (duration == null) { + return; + } + this.manifestPatchContext_.mediaPresentationDuration = duration; + for (const context of this.contextCache_.values()) { + context.mediaPresentationDuration = duration; + } + } + + /** + * Ingests a full MPD period element from a patch update + * + * @param {!shaka.extern.xml.Node} periods + * @private + */ + parsePatchPeriod_(periods) { + goog.asserts.assert(this.manifestPatchContext_.getBaseUris, + 'Must provide getBaseUris on manifestPatchContext_'); + + /** @type {shaka.dash.DashParser.Context} */ + const context = { + dynamic: this.manifestPatchContext_.type == 'dynamic', + presentationTimeline: this.manifest_.presentationTimeline, + period: null, + periodInfo: null, + adaptationSet: null, + representation: null, + bandwidth: 0, + indexRangeWarningGiven: false, + availabilityTimeOffset: this.manifestPatchContext_.availabilityTimeOffset, + profiles: this.manifestPatchContext_.profiles, + mediaPresentationDuration: + this.manifestPatchContext_.mediaPresentationDuration, + }; + + const periodsAndDuration = this.parsePeriods_(context, + this.manifestPatchContext_.getBaseUris, periods); + + return periodsAndDuration.periods; + } + + /** + * @param {string} periodId + * @private + */ + removePatchPeriod_(periodId) { + const SegmentTemplate = shaka.dash.SegmentTemplate; + for (const contextId of this.contextCache_.keys()) { + if (contextId.startsWith(periodId)) { + const context = this.contextCache_.get(contextId); + SegmentTemplate.removeTimepoints(context); + this.parsePatchSegment_(contextId); + this.contextCache_.delete(contextId); + } + } + } + + /** + * @param {!Array} paths + * @return {!Array} + * @private + */ + getContextIdsFromPath_(paths) { + let periodId = ''; + let adaptationSetId = ''; + let representationId = ''; + for (const node of paths) { + if (node.name === 'Period') { + periodId = node.id; + } else if (node.name === 'AdaptationSet') { + adaptationSetId = node.id; + } else if (node.name === 'Representation') { + representationId = node.id; + } + } + + /** @type {!Array} */ + const contextIds = []; + + if (representationId) { + contextIds.push(periodId + ',' + representationId); + } else { + for (const context of this.contextCache_.values()) { + if (context.period.id === periodId && + context.adaptationSet.id === adaptationSetId && + context.representation.id) { + contextIds.push(periodId + ',' + context.representation.id); + } + } + } + return contextIds; + } + + /** + * Modifies SegmentTemplate based on MPD patch. + * + * @param {!shaka.extern.xml.Node} patchNode + * @return {!Array} context ids with updated timeline + * @private + */ + modifySegmentTemplate_(patchNode) { + const TXml = shaka.util.TXml; + const paths = TXml.parseXpath(patchNode.attributes['sel'] || ''); + const lastPath = paths[paths.length - 1]; + if (!lastPath.attribute) { + return []; + } + const contextIds = this.getContextIdsFromPath_(paths); + const content = TXml.getContents(patchNode) || ''; + + for (const contextId of contextIds) { + /** @type {shaka.dash.DashParser.Context} */ + const context = this.contextCache_.get(contextId); + goog.asserts.assert(context && context.representation.segmentTemplate, + 'cannot modify segment template'); + TXml.modifyNodeAttribute(context.representation.segmentTemplate, + patchNode.tagName, lastPath.attribute, content); + } + return contextIds; + } + + /** + * Ingests Patch MPD segments into timeline. + * + * @param {!shaka.extern.xml.Node} patchNode + * @return {!Array} context ids with updated timeline + * @private + */ + modifyTimepoints_(patchNode) { + const TXml = shaka.util.TXml; + const SegmentTemplate = shaka.dash.SegmentTemplate; + + const paths = TXml.parseXpath(patchNode.attributes['sel'] || ''); + const contextIds = this.getContextIdsFromPath_(paths); + + for (const contextId of contextIds) { + /** @type {shaka.dash.DashParser.Context} */ + const context = this.contextCache_.get(contextId); + SegmentTemplate.modifyTimepoints(context, patchNode); + } + return contextIds; + } + + /** + * Parses modified segments. + * + * @param {string} contextId + * @private + */ + parsePatchSegment_(contextId) { + /** @type {shaka.dash.DashParser.Context} */ + const context = this.contextCache_.get(contextId); + + const currentStream = this.streamMap_[contextId]; + goog.asserts.assert(currentStream, 'stream should exist'); + + if (currentStream.segmentIndex) { + currentStream.segmentIndex.evict( + this.manifest_.presentationTimeline.getSegmentAvailabilityStart()); + } + + try { + const requestSegment = (uris, startByte, endByte, isInit) => { + return this.requestSegment_(uris, startByte, endByte, isInit); + }; + // TODO we should obtain lastSegmentNumber if possible + const streamInfo = shaka.dash.SegmentTemplate.createStreamInfo( + context, requestSegment, this.streamMap_, /* isUpdate= */ true, + this.config_.dash.initialSegmentLimit, this.periodDurations_, + context.representation.aesKey, /* lastSegmentNumber= */ null, + /* isPatchUpdate= */ true); + currentStream.createSegmentIndex = async () => { + if (!currentStream.segmentIndex) { + currentStream.segmentIndex = + await streamInfo.generateSegmentIndex(); + } + }; + } catch (error) { + const ContentType = shaka.util.ManifestParserUtils.ContentType; + const contentType = context.representation.contentType; + const isText = contentType == ContentType.TEXT || + contentType == ContentType.APPLICATION; + const isImage = contentType == ContentType.IMAGE; + if (!(isText || isImage) || + error.code != shaka.util.Error.Code.DASH_NO_SEGMENT_INFO) { + // We will ignore any DASH_NO_SEGMENT_INFO errors for text/image + throw error; + } + } + } + /** * Reads maxLatency and maxPlaybackRate properties from service * description element. @@ -826,8 +1211,14 @@ shaka.dash.DashParser = class { */ parsePeriods_(context, getBaseUris, mpd) { const TXml = shaka.util.TXml; - const presentationDuration = TXml.parseAttr( - mpd, 'mediaPresentationDuration', TXml.parseDuration); + let presentationDuration = context.mediaPresentationDuration; + + if (!presentationDuration) { + presentationDuration = TXml.parseAttr( + mpd, 'mediaPresentationDuration', TXml.parseDuration); + this.manifestPatchContext_.mediaPresentationDuration = + presentationDuration; + } const periods = []; let prevEnd = 0; @@ -1494,6 +1885,8 @@ shaka.dash.DashParser = class { context.representation = this.createFrame_(node, context.adaptationSet, null); + const representationId = context.representation.id; + this.minTotalAvailabilityTimeOffset_ = Math.min(this.minTotalAvailabilityTimeOffset_, context.representation.availabilityTimeOffset); @@ -1561,6 +1954,8 @@ shaka.dash.DashParser = class { aesKey.fetchKey = undefined; // No longer needed. }; } + context.representation.aesKey = aesKey; + const requestSegment = (uris, startByte, endByte, isInit) => { return this.requestSegment_(uris, startByte, endByte, isInit); }; @@ -1576,7 +1971,7 @@ shaka.dash.DashParser = class { streamInfo = shaka.dash.SegmentTemplate.createStreamInfo( context, requestSegment, this.streamMap_, hasManifest, this.config_.dash.initialSegmentLimit, this.periodDurations_, - aesKey, lastSegmentNumber); + aesKey, lastSegmentNumber, /* isPatchUpdate= */ false); } else { goog.asserts.assert(isText, 'Must have Segment* with non-text streams.'); @@ -1671,6 +2066,11 @@ shaka.dash.DashParser = class { const contextId = context.representation.id ? context.period.id + ',' + context.representation.id : ''; + if (this.patchLocationNodes_.length && representationId) { + this.contextCache_.set(`${context.period.id},${representationId}`, + this.cloneContext_(context)); + } + /** @type {shaka.extern.Stream} */ let stream; @@ -1739,6 +2139,57 @@ shaka.dash.DashParser = class { return stream; } + /** + * Clone context and remove xml document references. + * + * @param {!shaka.dash.DashParser.Context} context + * @return {!shaka.dash.DashParser.Context} + * @private + */ + cloneContext_(context) { + const contextClone = /** @type {!shaka.dash.DashParser.Context} */({}); + + for (const k of Object.keys(context)) { + if (['period', 'adaptationSet', 'representation'].includes(k)) { + /** @type {shaka.dash.DashParser.InheritanceFrame} */ + const frameRef = context[k]; + contextClone[k] = { + segmentBase: null, + segmentList: null, + segmentTemplate: frameRef.segmentTemplate, + getBaseUris: frameRef.getBaseUris, + width: frameRef.width, + height: frameRef.height, + contentType: frameRef.contentType, + mimeType: frameRef.mimeType, + language: frameRef.language, + codecs: frameRef.codecs, + frameRate: frameRef.frameRate, + pixelAspectRatio: frameRef.pixelAspectRatio, + emsgSchemeIdUris: frameRef.emsgSchemeIdUris, + id: frameRef.id, + numChannels: frameRef.numChannels, + audioSamplingRate: frameRef.audioSamplingRate, + availabilityTimeOffset: frameRef.availabilityTimeOffset, + initialization: frameRef.initialization, + }; + } else if (k == 'periodInfo') { + /** @type {shaka.dash.DashParser.PeriodInfo} */ + const frameRef = context[k]; + contextClone[k] = { + start: frameRef.start, + duration: frameRef.duration, + node: null, + isLastPeriod: frameRef.isLastPeriod, + }; + } else { + contextClone[k] = context[k]; + } + } + + return contextClone; + } + /** * Called when the update timer ticks. * @@ -1967,6 +2418,7 @@ shaka.dash.DashParser = class { numChannels: numChannels, audioSamplingRate: audioSamplingRate, availabilityTimeOffset: availabilityTimeOffset, + initialization: null, segmentSequenceCadence: segmentSequenceCadence || parent.segmentSequenceCadence, }; @@ -2368,8 +2820,72 @@ shaka.dash.DashParser = class { this.operationManager_.manage(op); return op.promise; } + + /** + * @param {!shaka.extern.xml.Node} patchNode + * @private + */ + updatePatchLocationNodes_(patchNode) { + const TXml = shaka.util.TXml; + TXml.modifyNodes(this.patchLocationNodes_, patchNode); + } + + /** + * @return {!Array} + * @private + */ + getPatchLocationUris_() { + const TXml = shaka.util.TXml; + const mpdId = this.manifestPatchContext_.mpdId; + const publishTime = this.manifestPatchContext_.publishTime; + if (!mpdId || !publishTime || !this.patchLocationNodes_.length) { + return []; + } + const now = Date.now() / 1000; + const patchLocations = this.patchLocationNodes_.filter((patchLocation) => { + const ttl = TXml.parseNonNegativeInt(patchLocation.attributes['ttl']); + return !ttl || publishTime + ttl > now; + }) + .map(TXml.getContents) + .filter(shaka.util.Functional.isNotNull); + + if (!patchLocations.length) { + return []; + } + return shaka.util.ManifestParserUtils.resolveUris( + this.manifestUris_, patchLocations); + } }; +/** + * @typedef {{ + * mpdId: string, + * type: string, + * mediaPresentationDuration: ?number, + * profiles: !Array., + * availabilityTimeOffset: number, + * getBaseUris: ?function():!Array., + * publishTime: number + * }} + * + * @property {string} mpdId + * ID of the original MPD file. + * @property {string} type + * Specifies the type of the dash manifest i.e. "static" + * @property {?number} mediaPresentationDuration + * Media presentation duration, or null if unknown. + * @property {!Array.} profiles + * Profiles of DASH are defined to enable interoperability and the + * signaling of the use of features. + * @property {number} availabilityTimeOffset + * Specifies the total availabilityTimeOffset of the segment. + * @property {?function():!Array.} getBaseUris + * An array of absolute base URIs. + * @property {number} publishTime + * Time when manifest has been published, in seconds. + */ +shaka.dash.DashParser.PatchContext; + /** * @const {string} @@ -2406,6 +2922,8 @@ shaka.dash.DashParser.RequestSegmentCallback; * numChannels: ?number, * audioSamplingRate: ?number, * availabilityTimeOffset: number, + * initialization: ?string, + * aesKey: (shaka.extern.aesKey|undefined), * segmentSequenceCadence: number * }} * @@ -2447,6 +2965,10 @@ shaka.dash.DashParser.RequestSegmentCallback; * Specifies the maximum sampling rate of the content, or null if unknown. * @property {number} availabilityTimeOffset * Specifies the total availabilityTimeOffset of the segment, or 0 if unknown. + * @property {?string} initialization + * Specifies the file where the init segment is located, or null. + * @property {(shaka.extern.aesKey|undefined)} aesKey + * AES-128 Content protection key * @property {number} segmentSequenceCadence * Specifies the cadence of independent segments in Segment Sequence * Representation. @@ -2465,6 +2987,7 @@ shaka.dash.DashParser.InheritanceFrame; * bandwidth: number, * indexRangeWarningGiven: boolean, * availabilityTimeOffset: number, + * mediaPresentationDuration: ?number, * profiles: !Array. * }} * @@ -2494,6 +3017,8 @@ shaka.dash.DashParser.InheritanceFrame; * @property {!Array.} profiles * Profiles of DASH are defined to enable interoperability and the signaling * of the use of features. + * @property {?number} mediaPresentationDuration + * Media presentation duration, or null if unknown. */ shaka.dash.DashParser.Context; diff --git a/lib/dash/mpd_utils.js b/lib/dash/mpd_utils.js index b53943050c..605f5029dd 100644 --- a/lib/dash/mpd_utils.js +++ b/lib/dash/mpd_utils.js @@ -123,7 +123,7 @@ shaka.dash.MpdUtils = class { * Expands a SegmentTimeline into an array-based timeline. The results are in * seconds. * - * @param {!shaka.extern.xml.Node} segmentTimeline + * @param {Array} timePoints * @param {number} timescale * @param {number} unscaledPresentationTimeOffset * @param {number} periodDuration The Period's duration in seconds. @@ -132,7 +132,7 @@ shaka.dash.MpdUtils = class { * @return {!Array.} */ static createTimeline( - segmentTimeline, timescale, unscaledPresentationTimeOffset, + timePoints, timescale, unscaledPresentationTimeOffset, periodDuration, startNumber) { goog.asserts.assert( timescale > 0 && timescale < Infinity, @@ -143,8 +143,6 @@ shaka.dash.MpdUtils = class { // Alias. const TXml = shaka.util.TXml; - const timePoints = TXml.findChildren(segmentTimeline, 'S'); - /** @type {!Array.} */ const timeline = []; let lastEndTime = -unscaledPresentationTimeOffset; @@ -296,13 +294,14 @@ shaka.dash.MpdUtils = class { startNumber = 1; } - const timelineNode = - MpdUtils.inheritChild(context, callback, 'SegmentTimeline'); /** @type {Array.} */ let timeline = null; + const timelineNode = + MpdUtils.inheritChild(context, callback, 'SegmentTimeline'); if (timelineNode) { + const timePoints = TXml.findChildren(timelineNode, 'S'); timeline = MpdUtils.createTimeline( - timelineNode, timescale, unscaledPresentationTimeOffset, + timePoints, timescale, unscaledPresentationTimeOffset, context.periodInfo.duration || Infinity, startNumber); } @@ -329,7 +328,7 @@ shaka.dash.MpdUtils = class { const Functional = shaka.util.Functional; goog.asserts.assert( callback(context.representation), - 'There must be at least one element of the given type', + 'There must be at least one element of the given type.', ); return [ diff --git a/lib/dash/segment_template.js b/lib/dash/segment_template.js index a6de284d46..d654095edc 100644 --- a/lib/dash/segment_template.js +++ b/lib/dash/segment_template.js @@ -18,6 +18,7 @@ goog.require('shaka.util.IReleasable'); goog.require('shaka.util.ManifestParserUtils'); goog.require('shaka.util.ObjectUtils'); goog.require('shaka.util.StringUtils'); +goog.require('shaka.util.TXml'); goog.requireType('shaka.dash.DashParser'); goog.requireType('shaka.media.PresentationTimeline'); @@ -39,18 +40,27 @@ shaka.dash.SegmentTemplate = class { * @param {!Object.} periodDurationMap * @param {shaka.extern.aesKey|undefined} aesKey * @param {?number} lastSegmentNumber + * @param {boolean} isPatchUpdate * @return {shaka.dash.DashParser.StreamInfo} */ static createStreamInfo( context, requestSegment, streamMap, isUpdate, segmentLimit, - periodDurationMap, aesKey, lastSegmentNumber) { + periodDurationMap, aesKey, lastSegmentNumber, isPatchUpdate) { goog.asserts.assert(context.representation.segmentTemplate, - 'Should only be called with SegmentTemplate'); + 'Should only be called with SegmentTemplate ' + + 'or segment info defined'); + const MpdUtils = shaka.dash.MpdUtils; const SegmentTemplate = shaka.dash.SegmentTemplate; const TimelineSegmentIndex = shaka.dash.TimelineSegmentIndex; - const initSegmentReference = - SegmentTemplate.createInitSegment_(context, aesKey); + if (!isPatchUpdate && !context.representation.initialization) { + context.representation.initialization = + MpdUtils.inheritAttribute( + context, SegmentTemplate.fromInheritance_, 'initialization'); + } + + const initSegmentReference = context.representation.initialization ? + SegmentTemplate.createInitSegment_(context, aesKey) : null; /** @type {shaka.dash.SegmentTemplate.SegmentTemplateInfo} */ const info = SegmentTemplate.parseSegmentTemplateInfo_(context); @@ -178,6 +188,42 @@ shaka.dash.SegmentTemplate = class { } } + /** + * Ingests Patch MPD segments into timeline. + * + * @param {!shaka.dash.DashParser.Context} context + * @param {shaka.extern.xml.Node} patchNode + */ + static modifyTimepoints(context, patchNode) { + const MpdUtils = shaka.dash.MpdUtils; + const SegmentTemplate = shaka.dash.SegmentTemplate; + const TXml = shaka.util.TXml; + + const timelineNode = MpdUtils.inheritChild(context, + SegmentTemplate.fromInheritance_, 'SegmentTimeline'); + goog.asserts.assert(timelineNode, 'timeline node not found'); + const timepoints = TXml.findChildren(timelineNode, 'S'); + + goog.asserts.assert(timepoints, 'timepoints should exist'); + TXml.modifyNodes(timepoints, patchNode); + timelineNode.children = timepoints; + } + + /** + * Removes all segments from timeline. + * + * @param {!shaka.dash.DashParser.Context} context + */ + static removeTimepoints(context) { + const MpdUtils = shaka.dash.MpdUtils; + const SegmentTemplate = shaka.dash.SegmentTemplate; + + const timelineNode = MpdUtils.inheritChild(context, + SegmentTemplate.fromInheritance_, 'SegmentTimeline'); + goog.asserts.assert(timelineNode, 'timeline node not found'); + timelineNode.children = []; + } + /** * @param {?shaka.dash.DashParser.InheritanceFrame} frame * @return {?shaka.extern.xml.Node} @@ -203,6 +249,7 @@ shaka.dash.SegmentTemplate = class { const media = MpdUtils.inheritAttribute( context, SegmentTemplate.fromInheritance_, 'media'); + const index = MpdUtils.inheritAttribute( context, SegmentTemplate.fromInheritance_, 'index'); @@ -547,8 +594,11 @@ shaka.dash.SegmentTemplate = class { const ManifestParserUtils = shaka.util.ManifestParserUtils; const SegmentTemplate = shaka.dash.SegmentTemplate; - let initialization = MpdUtils.inheritAttribute( - context, SegmentTemplate.fromInheritance_, 'initialization'); + let initialization = context.representation.initialization; + if (!initialization) { + initialization = MpdUtils.inheritAttribute( + context, SegmentTemplate.fromInheritance_, 'initialization'); + } if (!initialization) { return null; } @@ -717,10 +767,15 @@ shaka.dash.TimelineSegmentIndex = class extends shaka.media.SegmentIndex { this.templateInfo_.mediaTemplate = info.mediaTemplate; // Append timeline - const lastCurrentEntry = currentTimeline[currentTimeline.length - 1]; - const newEntries = info.timeline.filter((entry) => { - return entry.start >= lastCurrentEntry.end; - }); + let newEntries; + if (currentTimeline.length) { + const lastCurrentEntry = currentTimeline[currentTimeline.length - 1]; + newEntries = info.timeline.filter((entry) => { + return entry.start >= lastCurrentEntry.end; + }); + } else { + newEntries = info.timeline.slice(); + } if (newEntries.length > 0) { shaka.log.debug(`Appending ${newEntries.length} entries`); diff --git a/lib/util/error.js b/lib/util/error.js index 38d369567d..d5953d6e30 100644 --- a/lib/util/error.js +++ b/lib/util/error.js @@ -772,6 +772,11 @@ shaka.util.Error.Code = { */ 'DASH_UNSUPPORTED_AES_128': 4051, + /** + * Patch requested during an update did not match original manifest. + */ + 'DASH_INVALID_PATCH': 4052, + // RETIRED: 'INCONSISTENT_BUFFER_STATE': 5000, // RETIRED: 'INVALID_SEGMENT_INDEX': 5001, diff --git a/lib/util/periods.js b/lib/util/periods.js index 296a8ccb75..e6b6f5fd70 100644 --- a/lib/util/periods.js +++ b/lib/util/periods.js @@ -166,11 +166,12 @@ shaka.util.PeriodCombiner = class { /** * @param {!Array.} periods * @param {boolean} isDynamic + * @param {boolean=} isPatchUpdate * @return {!Promise} * * @export */ - async combinePeriods(periods, isDynamic) { + async combinePeriods(periods, isDynamic, isPatchUpdate = false) { const ContentType = shaka.util.ManifestParserUtils.ContentType; // Optimization: for single-period VOD, do nothing. This makes sure @@ -190,6 +191,8 @@ shaka.util.PeriodCombiner = class { this.textStreams_ = Array.from(textStreamsPerPeriod[0].values()); this.imageStreams_ = Array.from(imageStreamsPerPeriod[0].values()); } else { + // How many periods we've seen before which are not included in this call. + const periodsMissing = isPatchUpdate ? this.usedPeriodIds_.size : 0; // Find the first period we haven't seen before. Tag all the periods we // see now as "used". let firstNewPeriodIndex = -1; @@ -226,25 +229,29 @@ shaka.util.PeriodCombiner = class { audioStreamsPerPeriod, firstNewPeriodIndex, shaka.util.PeriodCombiner.cloneStream_, - shaka.util.PeriodCombiner.concatenateStreams_), + shaka.util.PeriodCombiner.concatenateStreams_, + periodsMissing), this.combine_( this.videoStreams_, videoStreamsPerPeriod, firstNewPeriodIndex, shaka.util.PeriodCombiner.cloneStream_, - shaka.util.PeriodCombiner.concatenateStreams_), + shaka.util.PeriodCombiner.concatenateStreams_, + periodsMissing), this.combine_( this.textStreams_, textStreamsPerPeriod, firstNewPeriodIndex, shaka.util.PeriodCombiner.cloneStream_, - shaka.util.PeriodCombiner.concatenateStreams_), + shaka.util.PeriodCombiner.concatenateStreams_, + periodsMissing), this.combine_( this.imageStreams_, imageStreamsPerPeriod, firstNewPeriodIndex, shaka.util.PeriodCombiner.cloneStream_, - shaka.util.PeriodCombiner.concatenateStreams_), + shaka.util.PeriodCombiner.concatenateStreams_, + periodsMissing), ]); } @@ -367,28 +374,32 @@ shaka.util.PeriodCombiner = class { audioStreamDbsPerPeriod, /* firstNewPeriodIndex= */ 0, shaka.util.PeriodCombiner.cloneStreamDB_, - shaka.util.PeriodCombiner.concatenateStreamDBs_); + shaka.util.PeriodCombiner.concatenateStreamDBs_, + /* periodsMissing= */ 0); const combinedVideoStreamDbs = await periodCombiner.combine_( /* outputStreams= */ [], videoStreamDbsPerPeriod, /* firstNewPeriodIndex= */ 0, shaka.util.PeriodCombiner.cloneStreamDB_, - shaka.util.PeriodCombiner.concatenateStreamDBs_); + shaka.util.PeriodCombiner.concatenateStreamDBs_, + /* periodsMissing= */ 0); const combinedTextStreamDbs = await periodCombiner.combine_( /* outputStreams= */ [], textStreamDbsPerPeriod, /* firstNewPeriodIndex= */ 0, shaka.util.PeriodCombiner.cloneStreamDB_, - shaka.util.PeriodCombiner.concatenateStreamDBs_); + shaka.util.PeriodCombiner.concatenateStreamDBs_, + /* periodsMissing= */ 0); const combinedImageStreamDbs = await periodCombiner.combine_( /* outputStreams= */ [], imageStreamDbsPerPeriod, /* firstNewPeriodIndex= */ 0, shaka.util.PeriodCombiner.cloneStreamDB_, - shaka.util.PeriodCombiner.concatenateStreamDBs_); + shaka.util.PeriodCombiner.concatenateStreamDBs_, + /* periodsMissing= */ 0); // Recreate variantIds from scratch in the output. // HLS content is always single-period, so the early return at the top of @@ -435,6 +446,7 @@ shaka.util.PeriodCombiner = class { * @param {function(T):T} clone Make a clone of an input stream. * @param {function(T, T)} concat Concatenate the second stream onto the end * of the first. + * @param {number} periodsMissing The number of periods missing * * @return {!Promise.>} The same array passed to outputStreams, * modified to include any newly-created streams. @@ -445,15 +457,18 @@ shaka.util.PeriodCombiner = class { * @private */ async combine_( - outputStreams, streamsPerPeriod, firstNewPeriodIndex, clone, concat) { + outputStreams, streamsPerPeriod, firstNewPeriodIndex, clone, concat, + periodsMissing) { const unusedStreamsPerPeriod = []; - for (let i = 0; i < firstNewPeriodIndex; i++) { - // This period's streams have all been used already. - unusedStreamsPerPeriod.push(new Set()); - } - for (let i = firstNewPeriodIndex; i < streamsPerPeriod.length; i++) { - // This periods streams are all new. - unusedStreamsPerPeriod.push(new Set(streamsPerPeriod[i].values())); + + for (let i = 0; i < streamsPerPeriod.length; i++) { + if (i >= firstNewPeriodIndex) { + // This periods streams are all new. + unusedStreamsPerPeriod.push(new Set(streamsPerPeriod[i].values())); + } else { + // This period's streams have all been used already. + unusedStreamsPerPeriod.push(new Set()); + } } // First, extend all existing output Streams into the new periods. @@ -461,7 +476,7 @@ shaka.util.PeriodCombiner = class { // eslint-disable-next-line no-await-in-loop const ok = await this.extendExistingOutputStream_( outputStream, streamsPerPeriod, firstNewPeriodIndex, concat, - unusedStreamsPerPeriod); + unusedStreamsPerPeriod, periodsMissing); if (!ok) { // This output Stream was not properly extended to include streams from // the new period. This is likely a bug in our algorithm, so throw an @@ -532,6 +547,7 @@ shaka.util.PeriodCombiner = class { * of the first. * @param {!Array.>} unusedStreamsPerPeriod An array of sets of * unused streams from each period. + * @param {number} periodsMissing How many periods are missing in this update. * * @return {!Promise.} * @@ -543,8 +559,9 @@ shaka.util.PeriodCombiner = class { */ async extendExistingOutputStream_( outputStream, streamsPerPeriod, firstNewPeriodIndex, concat, - unusedStreamsPerPeriod) { - this.findMatchesInAllPeriods_(streamsPerPeriod, outputStream); + unusedStreamsPerPeriod, periodsMissing) { + this.findMatchesInAllPeriods_(streamsPerPeriod, + outputStream, periodsMissing > 0); // This only exists where T == Stream, and this should only ever be called // on Stream types. StreamDB should not have pre-existing output streams. @@ -561,11 +578,11 @@ shaka.util.PeriodCombiner = class { // the output's MetaSegmentIndex. if (outputStream.segmentIndex) { await shaka.util.PeriodCombiner.extendOutputSegmentIndex_(outputStream, - firstNewPeriodIndex); + firstNewPeriodIndex + periodsMissing); } shaka.util.PeriodCombiner.extendOutputStream_(outputStream, - firstNewPeriodIndex, concat, unusedStreamsPerPeriod); + firstNewPeriodIndex, concat, unusedStreamsPerPeriod, periodsMissing); return true; } @@ -664,7 +681,8 @@ shaka.util.PeriodCombiner = class { return null; } shaka.util.PeriodCombiner.extendOutputStream_(outputStream, - /* firstNewPeriodIndex= */ 0, concat, unusedStreamsPerPeriod); + /* firstNewPeriodIndex= */ 0, concat, unusedStreamsPerPeriod, + /* periodsMissing= */ 0); return outputStream; } @@ -678,6 +696,7 @@ shaka.util.PeriodCombiner = class { * of the first. * @param {!Array.>} unusedStreamsPerPeriod An array of sets of * unused streams from each period. + * @param {number} periodsMissing How many periods are missing in this update * * @template T * Accepts either a StreamDB or Stream type. @@ -685,7 +704,8 @@ shaka.util.PeriodCombiner = class { * @private */ static extendOutputStream_( - outputStream, firstNewPeriodIndex, concat, unusedStreamsPerPeriod) { + outputStream, firstNewPeriodIndex, concat, unusedStreamsPerPeriod, + periodsMissing) { const ContentType = shaka.util.ManifestParserUtils.ContentType; const LanguageUtils = shaka.util.LanguageUtils; const matches = outputStream.matchedStreams; @@ -697,7 +717,8 @@ shaka.util.PeriodCombiner = class { // Concatenate the new matches onto the stream, starting at the first new // period. - for (let i = firstNewPeriodIndex; i < matches.length; i++) { + const start = firstNewPeriodIndex + periodsMissing; + for (let i = start; i < matches.length; i++) { const match = matches[i]; concat(outputStream, match); @@ -715,7 +736,7 @@ shaka.util.PeriodCombiner = class { } if (used) { - unusedStreamsPerPeriod[i].delete(match); + unusedStreamsPerPeriod[i - periodsMissing].delete(match); // Add the full mimetypes to the stream. if (match.fullMimeTypes) { for (const fullMimeType of match.fullMimeTypes.values()) { @@ -937,14 +958,16 @@ shaka.util.PeriodCombiner = class { * * @param {!Array>} streamsPerPeriod * @param {T} outputStream + * @param {boolean=} shouldAppend * * @template T * Accepts either a StreamDB or Stream type. * * @private */ - findMatchesInAllPeriods_(streamsPerPeriod, outputStream) { - const matches = []; + findMatchesInAllPeriods_(streamsPerPeriod, outputStream, + shouldAppend = false) { + const matches = shouldAppend ? outputStream.matchedStreams : []; for (const streams of streamsPerPeriod) { const match = this.findBestMatchInPeriod_(streams, outputStream); if (!match) { diff --git a/lib/util/tXml.js b/lib/util/tXml.js index 01d24bcbd5..2d5e01e427 100644 --- a/lib/util/tXml.js +++ b/lib/util/tXml.js @@ -741,28 +741,94 @@ shaka.util.TXml = class { * @return {!Array} */ static parseXpath(exprString) { + const StringUtils = shaka.util.StringUtils; const returnPaths = []; // Split string by paths but ignore '/' in quotes - const paths = exprString + const paths = StringUtils.htmlUnescape(exprString) .split(/\/+(?=(?:[^'"]*['"][^'"]*['"])*[^'"]*$)/); for (const path of paths) { - const nodeName = path.match(/\b([A-Z])\w+/); + const nodeName = path.match(/^([\w]+)/); - // We only want the id attribute in which case - // /'(.*?)'/ will suffice to get it. - const idAttr = path.match(/(@id='(.*?)')/); if (nodeName) { + // We only want the id attribute in which case + // /'(.*?)'/ will suffice to get it. + const idAttr = path.match(/(@id='(.*?)')/); + const position = path.match(/\[(\d+)\]/); returnPaths.push({ name: nodeName[0], id: idAttr ? idAttr[0].match(/'(.*?)'/)[0].replace(/'/gm, '') : null, + // position is counted from 1, so make it readable for devs + position: position ? Number(position[1]) - 1 : null, + attribute: path.split('/@')[1] || null, }); + } else if (path.startsWith('@') && returnPaths.length) { + returnPaths[returnPaths.length - 1].attribute = path.slice(1); } } return returnPaths; } + /** + * Modifies nodes in specified array by adding or removing nodes + * and updating attributes. + * @param {!Array} nodes + * @param {!shaka.extern.xml.Node} patchNode + */ + static modifyNodes(nodes, patchNode) { + const TXml = shaka.util.TXml; + + const paths = TXml.parseXpath(patchNode.attributes['sel'] || ''); + if (!paths.length) { + return; + } + const lastNode = paths[paths.length - 1]; + const position = patchNode.attributes['pos'] || null; + + let index = lastNode.position; + if (index === null) { + index = position === 'prepend' ? 0 : nodes.length; + } else if (position === 'prepend') { + --index; + } else if (position === 'after') { + ++index; + } + const action = patchNode.tagName; + const attribute = lastNode.attribute; + + // Modify attribute + if (attribute) { + TXml.modifyNodeAttribute(nodes[index], action, attribute, + TXml.getContents(patchNode) || ''); + // Rearrange nodes + } else { + if (action === 'remove' || action === 'replace') { + nodes.splice(index, 1); + } + if (action === 'add' || action === 'replace') { + const newNodes = patchNode.children; + nodes.splice(index, 0, ...newNodes); + } + } + } + + + /** + * @param {!shaka.extern.xml.Node} node + * @param {string} action + * @param {string} attribute + * @param {string} value + */ + static modifyNodeAttribute(node, action, attribute, value) { + if (action === 'remove') { + delete node.attributes[attribute]; + } else if (action === 'add' || action === 'replace') { + node.attributes[attribute] = value; + } + } + + /** * Converts a tXml node to DOM element. * @param {shaka.extern.xml.Node} node @@ -808,7 +874,9 @@ shaka.util.TXml.knownNameSpaces_ = new Map([]); /** * @typedef {{ * name: string, - * id: ?string + * id: ?string, + * position: ?number, + * attribute: ?string * }} */ shaka.util.TXml.PathNode; diff --git a/test/dash/dash_parser_patch_unit.js b/test/dash/dash_parser_patch_unit.js new file mode 100644 index 0000000000..d4a5c75e3d --- /dev/null +++ b/test/dash/dash_parser_patch_unit.js @@ -0,0 +1,466 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +describe('DashParser Patch', () => { + const Util = shaka.test.Util; + const ManifestParser = shaka.test.ManifestParser; + + const oldNow = Date.now; + const mpdId = 'foo'; + const updateTime = 5; + const ttl = 60; + const originalUri = 'http://example.com/'; + const manifestRequest = shaka.net.NetworkingEngine.RequestType.MANIFEST; + const manifestContext = { + type: shaka.net.NetworkingEngine.AdvancedRequestType.MPD, + }; + + /** @type {!shaka.test.FakeNetworkingEngine} */ + let fakeNetEngine; + /** @type {!shaka.dash.DashParser} */ + let parser; + /** @type {shaka.extern.ManifestParser.PlayerInterface} */ + let playerInterface; + /** @type {!Date} */ + let publishTime; + + beforeEach(() => { + publishTime = new Date(2024, 0, 1); + fakeNetEngine = new shaka.test.FakeNetworkingEngine(); + parser = new shaka.dash.DashParser(); + parser.configure(shaka.util.PlayerConfiguration.createDefault().manifest); + playerInterface = { + networkingEngine: fakeNetEngine, + filter: (manifest) => Promise.resolve(), + makeTextStreamsForClosedCaptions: (manifest) => {}, + onTimelineRegionAdded: fail, // Should not have any EventStream elements. + onEvent: fail, + onError: fail, + isLowLatencyMode: () => false, + isAutoLowLatencyMode: () => false, + enableLowLatencyMode: () => {}, + updateDuration: () => {}, + newDrmInfo: (stream) => {}, + onManifestUpdated: () => {}, + getBandwidthEstimate: () => 1e6, + }; + Date.now = () => publishTime.getTime() + 10; + + const manifestText = [ + ``, + ` dummy://bar`, + ' ', + ' ', + ' ', + ' http://example.com', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + '', + ].join('\n'); + fakeNetEngine.setResponseText('dummy://foo', manifestText); + }); + + afterEach(() => { + // Dash parser stop is synchronous. + parser.stop(); + Date.now = oldNow; + }); + + /** + * Trigger a manifest update. + * @suppress {accessControls} + */ + async function updateManifest() { + if (parser.updateTimer_) { + parser.updateTimer_.tickNow(); + } + await Util.shortDelay(); // Allow update to complete. + } + + /** + * Gets a spy on the function that sets the update period. + * @return {!jasmine.Spy} + * @suppress {accessControls} + */ + function updateTickSpy() { + return spyOn(parser.updateTimer_, 'tickAfter'); + } + + describe('MPD', () => { + it('rolls back to regular update if id mismatches', async () => { + const patchText = [ + '', + ].join('\n'); + fakeNetEngine.setResponseText('dummy://bar', patchText); + + /** @type {!jasmine.Spy} */ + const onError = jasmine.createSpy('onError'); + playerInterface.onError = Util.spyFunc(onError); + + await parser.start('dummy://foo', playerInterface); + + expect(fakeNetEngine.request).toHaveBeenCalledTimes(1); + fakeNetEngine.expectRequest('dummy://foo', manifestRequest, manifestContext); + fakeNetEngine.request.calls.reset(); + + await updateManifest(); + expect(fakeNetEngine.request).toHaveBeenCalledTimes(1); + fakeNetEngine.expectRequest('dummy://bar', manifestRequest, manifestContext); + expect(onError).toHaveBeenCalledWith(new shaka.util.Error( + shaka.util.Error.Severity.RECOVERABLE, + shaka.util.Error.Category.MANIFEST, + shaka.util.Error.Code.DASH_INVALID_PATCH)); + + fakeNetEngine.request.calls.reset(); + + await updateManifest(); + expect(fakeNetEngine.request).toHaveBeenCalledTimes(1); + fakeNetEngine.expectRequest('dummy://foo', manifestRequest, manifestContext); + }); + + it('rolls back to regular update if publishTime mismatches', async () => { + const publishTime = new Date(1992, 5, 2); + const patchText = [ + `', + ].join('\n'); + fakeNetEngine.setResponseText('dummy://bar', patchText); + + /** @type {!jasmine.Spy} */ + const onError = jasmine.createSpy('onError'); + playerInterface.onError = Util.spyFunc(onError); + + await parser.start('dummy://foo', playerInterface); + + expect(fakeNetEngine.request).toHaveBeenCalledTimes(1); + fakeNetEngine.expectRequest('dummy://foo', manifestRequest, manifestContext); + fakeNetEngine.request.calls.reset(); + + await updateManifest(); + expect(fakeNetEngine.request).toHaveBeenCalledTimes(1); + fakeNetEngine.expectRequest('dummy://bar', manifestRequest, manifestContext); + expect(onError).toHaveBeenCalledWith(new shaka.util.Error( + shaka.util.Error.Severity.RECOVERABLE, + shaka.util.Error.Category.MANIFEST, + shaka.util.Error.Code.DASH_INVALID_PATCH)); + + fakeNetEngine.request.calls.reset(); + + await updateManifest(); + expect(fakeNetEngine.request).toHaveBeenCalledTimes(1); + fakeNetEngine.expectRequest('dummy://foo', manifestRequest, manifestContext); + }); + + it('transforms from dynamic to static', async () => { + const patchText = [ + ``, + ' ', + ' static', + ' ', + ' ', + ' PT28462.033599998S', + ' ', + '', + ].join('\n'); + fakeNetEngine.setResponseText('dummy://bar', patchText); + + const manifest = await parser.start('dummy://foo', playerInterface); + expect(manifest.presentationTimeline.isLive()).toBe(true); + expect(manifest.presentationTimeline.getDuration()).toBe(Infinity); + + /** @type {!jasmine.Spy} */ + const tickAfter = updateTickSpy(); + await updateManifest(); + expect(manifest.presentationTimeline.isLive()).toBe(false); + expect(manifest.presentationTimeline.getDuration()).not.toBe(Infinity); + // should stop updates after transition to static + expect(tickAfter).not.toHaveBeenCalled(); + }); + }); + + describe('PatchLocation', () => { + beforeEach(() => { + const patchText = [ + `', + ].join('\n'); + fakeNetEngine.setResponseText('dummy://bar', patchText); + }); + + it('uses PatchLocation', async () => { + await parser.start('dummy://foo', playerInterface); + + expect(fakeNetEngine.request).toHaveBeenCalledTimes(1); + fakeNetEngine.expectRequest('dummy://foo', manifestRequest, manifestContext); + fakeNetEngine.request.calls.reset(); + + await updateManifest(); + expect(fakeNetEngine.request).toHaveBeenCalledTimes(1); + fakeNetEngine.expectRequest('dummy://bar', manifestRequest, manifestContext); + }); + + it('does not use PatchLocation if publishTime is not defined', async () => { + const manifestText = [ + ``, + ` dummy://bar`, + ' ', + ' ', + ' ', + ' http://example.com', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + '', + ].join('\n'); + fakeNetEngine.setResponseText('dummy://foo', manifestText); + + await parser.start('dummy://foo', playerInterface); + + expect(fakeNetEngine.request).toHaveBeenCalledTimes(1); + fakeNetEngine.expectRequest('dummy://foo', manifestRequest, manifestContext); + fakeNetEngine.request.calls.reset(); + + await updateManifest(); + expect(fakeNetEngine.request).toHaveBeenCalledTimes(1); + fakeNetEngine.expectRequest('dummy://foo', manifestRequest, manifestContext); + fakeNetEngine.expectNoRequest('dummy://bar', manifestRequest, manifestContext); + }); + + it('does not use PatchLocation if it expired', async () => { + await parser.start('dummy://foo', playerInterface); + + expect(fakeNetEngine.request).toHaveBeenCalledTimes(1); + fakeNetEngine.expectRequest('dummy://foo', manifestRequest, manifestContext); + fakeNetEngine.request.calls.reset(); + + // Make current time exceed Patch's TTL. + Date.now = () => publishTime.getTime() + (ttl * 2) * 1000; + await updateManifest(); + expect(fakeNetEngine.request).toHaveBeenCalledTimes(1); + fakeNetEngine.expectRequest('dummy://foo', manifestRequest, manifestContext); + fakeNetEngine.expectNoRequest('dummy://bar', manifestRequest, manifestContext); + }); + + it('replaces PatchLocation with new URL', async () => { + await parser.start('dummy://foo', playerInterface); + + expect(fakeNetEngine.request).toHaveBeenCalledTimes(1); + fakeNetEngine.expectRequest('dummy://foo', manifestRequest, manifestContext); + fakeNetEngine.request.calls.reset(); + + const patchText = [ + ``, + ' ', + ` dummy://bar2`, + ' ', + '', + ].join('\n'); + fakeNetEngine.setResponseText('dummy://bar', patchText); + + await updateManifest(); + expect(fakeNetEngine.request).toHaveBeenCalledTimes(1); + fakeNetEngine.expectRequest('dummy://bar', manifestRequest, manifestContext); + fakeNetEngine.request.calls.reset(); + + fakeNetEngine.setResponseText('dummy://bar2', patchText); + // Another request should be made to new URI. + await updateManifest(); + expect(fakeNetEngine.request).toHaveBeenCalledTimes(1); + fakeNetEngine.expectRequest('dummy://bar2', manifestRequest, manifestContext); + }); + }); + + describe('Period', () => { + it('adds new period as an MPD child', async () => { + const manifest = await parser.start('dummy://foo', playerInterface); + const stream = manifest.variants[0].video; + const patchText = [ + ``, + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + '', + ].join('\n'); + fakeNetEngine.setResponseText('dummy://bar', patchText); + + await stream.createSegmentIndex(); + + expect(stream.matchedStreams.length).toBe(1); + + await updateManifest(); + await stream.createSegmentIndex(); + + expect(stream.matchedStreams.length).toBe(2); + }); + + it('adds new period as a Period successor', async () => { + const manifest = await parser.start('dummy://foo', playerInterface); + const stream = manifest.variants[0].video; + const patchText = [ + ``, + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + '', + ].join('\n'); + fakeNetEngine.setResponseText('dummy://bar', patchText); + + await stream.createSegmentIndex(); + + expect(stream.matchedStreams.length).toBe(1); + + await updateManifest(); + await stream.createSegmentIndex(); + + expect(stream.matchedStreams.length).toBe(2); + }); + }); + + describe('SegmentTimeline', () => { + it('adds new S elements as SegmentTimeline children', async () => { + const xPath = '/' + [ + 'MPD', + 'Period[@id=\'1\']', + 'AdaptationSet[@id=\'1\']', + 'Representation[@id=\'3\']', + 'SegmentTemplate', + 'SegmentTimeline', + ].join('/'); + const patchText = [ + ``, + ` `, + ' ', + ' ', + '', + ].join('\n'); + fakeNetEngine.setResponseText('dummy://bar', patchText); + + const manifest = await parser.start('dummy://foo', playerInterface); + const stream = manifest.variants[0].video; + expect(stream).toBeTruthy(); + await stream.createSegmentIndex(); + ManifestParser.verifySegmentIndex(stream, [ + ManifestParser.makeReference('s1.mp4', 0, 1, originalUri), + ]); + + await updateManifest(); + ManifestParser.verifySegmentIndex(stream, [ + ManifestParser.makeReference('s1.mp4', 0, 1, originalUri), + ManifestParser.makeReference('s2.mp4', 1, 2, originalUri), + ]); + }); + + it('adds new S elements as S successor', async () => { + const xPath = '/' + [ + 'MPD', + 'Period[@id=\'1\']', + 'AdaptationSet[@id=\'1\']', + 'Representation[@id=\'3\']', + 'SegmentTemplate', + 'SegmentTimeline', + 'S', + ].join('/'); + const patchText = [ + ``, + ` `, + ' ', + ' ', + '', + ].join('\n'); + fakeNetEngine.setResponseText('dummy://bar', patchText); + + const manifest = await parser.start('dummy://foo', playerInterface); + const stream = manifest.variants[0].video; + expect(stream).toBeTruthy(); + await stream.createSegmentIndex(); + ManifestParser.verifySegmentIndex(stream, [ + ManifestParser.makeReference('s1.mp4', 0, 1, originalUri), + ]); + + await updateManifest(); + ManifestParser.verifySegmentIndex(stream, [ + ManifestParser.makeReference('s1.mp4', 0, 1, originalUri), + ManifestParser.makeReference('s2.mp4', 1, 2, originalUri), + ]); + }); + + it('modify @r attribute of an S element', async () => { + const xPath = '/' + [ + 'MPD', + 'Period[@id=\'1\']', + 'AdaptationSet[@id=\'1\']', + 'Representation[@id=\'3\']', + 'SegmentTemplate', + 'SegmentTimeline', + 'S[1]/@r', + ].join('/'); + const patchText = [ + ``, + ` `, + ' 2', + ' ', + '', + ].join('\n'); + fakeNetEngine.setResponseText('dummy://bar', patchText); + + const manifest = await parser.start('dummy://foo', playerInterface); + const stream = manifest.variants[0].video; + expect(stream).toBeTruthy(); + await stream.createSegmentIndex(); + ManifestParser.verifySegmentIndex(stream, [ + ManifestParser.makeReference('s1.mp4', 0, 1, originalUri), + ]); + + await updateManifest(); + ManifestParser.verifySegmentIndex(stream, [ + ManifestParser.makeReference('s1.mp4', 0, 1, originalUri), + ManifestParser.makeReference('s2.mp4', 1, 2, originalUri), + ManifestParser.makeReference('s3.mp4', 2, 3, originalUri), + ]); + }); + }); +}); diff --git a/test/dash/dash_parser_segment_template_unit.js b/test/dash/dash_parser_segment_template_unit.js index fa0a3c08dc..14ab181fd8 100644 --- a/test/dash/dash_parser_segment_template_unit.js +++ b/test/dash/dash_parser_segment_template_unit.js @@ -790,6 +790,19 @@ describe('DashParser SegmentTemplate', () => { expect(index.find(newStart)).toBe(10); expect(index.find(newEnd - 1.0)).toBe(19); }); + + it('appends new timeline to empty one', async () => { + const info = makeTemplateInfo([]); + const index = await makeTimelineSegmentIndex(info, false); + + const newRanges = makeRanges(0, 2.0, 10); + const newTemplateInfo = makeTemplateInfo(newRanges); + + const newEnd = newRanges[newRanges.length - 1].end; + index.appendTemplateInfo(newTemplateInfo, /* periodStart= */ 0, newEnd); + expect(index.find(0)).toBe(0); + expect(index.find(newEnd - 1.0)).toBe(9); + }); }); describe('evict', () => { @@ -814,8 +827,10 @@ describe('DashParser SegmentTemplate', () => { */ async function makeTimelineSegmentIndex(info, delayPeriodEnd = true, shouldFit = false) { + const isTimeline = info.timeline.length > 0; // Period end may be a bit after the last timeline entry - let periodEnd = info.timeline[info.timeline.length - 1].end; + let periodEnd = isTimeline ? + info.timeline[info.timeline.length - 1].end : 0; if (delayPeriodEnd) { periodEnd += 1.0; } @@ -824,7 +839,7 @@ describe('DashParser SegmentTemplate', () => { '', ' ', - ' ', + isTimeline ? ' ' : '', ' ', '', ], /* duration= */ 45); @@ -841,7 +856,7 @@ describe('DashParser SegmentTemplate', () => { /** @type {?} */ const index = stream.segmentIndex; index.release(); - index.appendTemplateInfo(info, info.timeline[0].start, + index.appendTemplateInfo(info, isTimeline ? info.timeline[0].start : 0, periodEnd, shouldFit); return index; diff --git a/test/dash/mpd_utils_unit.js b/test/dash/mpd_utils_unit.js index 6cbab088bd..c63d16a0eb 100644 --- a/test/dash/mpd_utils_unit.js +++ b/test/dash/mpd_utils_unit.js @@ -456,9 +456,10 @@ describe('MpdUtils', () => { shaka.util.TXml.parseXmlString(xmlLines.join('\n'), 'SegmentTimeline')); + const timePoints = shaka.util.TXml.findChildren(segmentTimeline, 'S'); + const timeline = MpdUtils.createTimeline( - segmentTimeline, timescale, presentationTimeOffset, - periodDuration, 0); + timePoints, timescale, presentationTimeOffset, periodDuration, 0); expect(timeline).toEqual( expected.map((c) => jasmine.objectContaining(c))); } diff --git a/test/util/tXml_unit.js b/test/util/tXml_unit.js index 1a580b4052..7977e92cc8 100644 --- a/test/util/tXml_unit.js +++ b/test/util/tXml_unit.js @@ -418,9 +418,10 @@ describe('tXml', () => { }); it('parseXpath', () => { - expect(TXml.parseXpath('/MPD')).toEqual([{name: 'MPD', id: null}]); + expect(TXml.parseXpath('/MPD')) + .toEqual([{name: 'MPD', id: null, position: null, attribute: null}]); expect(TXml.parseXpath('/MPD/@type')) - .toEqual([{name: 'MPD', id: null}]); + .toEqual([{name: 'MPD', id: null, position: null, attribute: 'type'}]); const timelinePath = '/' + [ 'MPD', @@ -428,13 +429,15 @@ describe('tXml', () => { 'AdaptationSet[@id=\'7\']', 'SegmentTemplate', 'SegmentTimeline', + 'S[2]', ].join('/'); expect(TXml.parseXpath(timelinePath)).toEqual([ - {name: 'MPD', id: null}, - {name: 'Period', id: '6469'}, - {name: 'AdaptationSet', id: '7'}, - {name: 'SegmentTemplate', id: null}, - {name: 'SegmentTimeline', id: null}, + {name: 'MPD', id: null, position: null, attribute: null}, + {name: 'Period', id: '6469', position: null, attribute: null}, + {name: 'AdaptationSet', id: '7', position: null, attribute: null}, + {name: 'SegmentTemplate', id: null, position: null, attribute: null}, + {name: 'SegmentTimeline', id: null, position: null, attribute: null}, + {name: 'S', id: null, position: 1, attribute: null}, ]); }); });