UNPKG

shaka-player

Version:
1,433 lines (1,276 loc) 141 kB
/*! @license * Shaka Player * Copyright 2016 Google LLC * SPDX-License-Identifier: Apache-2.0 */ goog.provide('shaka.dash.DashParser'); goog.require('goog.asserts'); goog.require('shaka.abr.Ewma'); goog.require('shaka.dash.ContentProtection'); goog.require('shaka.dash.MpdUtils'); goog.require('shaka.dash.SegmentBase'); goog.require('shaka.dash.SegmentList'); goog.require('shaka.dash.SegmentTemplate'); goog.require('shaka.log'); goog.require('shaka.media.ManifestParser'); goog.require('shaka.media.PresentationTimeline'); goog.require('shaka.media.SegmentIndex'); goog.require('shaka.media.SegmentUtils'); goog.require('shaka.net.NetworkingEngine'); goog.require('shaka.net.NetworkingUtils'); goog.require('shaka.text.TextEngine'); goog.require('shaka.util.ArrayUtils'); goog.require('shaka.util.ContentSteeringManager'); goog.require('shaka.util.Error'); goog.require('shaka.util.EventManager'); goog.require('shaka.util.FakeEvent'); goog.require('shaka.util.Functional'); goog.require('shaka.util.LanguageUtils'); goog.require('shaka.util.ManifestParserUtils'); goog.require('shaka.util.MimeUtils'); goog.require('shaka.util.ObjectUtils'); goog.require('shaka.util.OperationManager'); goog.require('shaka.util.PeriodCombiner'); goog.require('shaka.util.PlayerConfiguration'); goog.require('shaka.util.StreamUtils'); goog.require('shaka.util.StringUtils'); goog.require('shaka.util.TimeUtils'); goog.require('shaka.util.Timer'); goog.require('shaka.util.TXml'); goog.require('shaka.util.URL'); /** * Creates a new DASH parser. * * @implements {shaka.extern.ManifestParser} * @export */ shaka.dash.DashParser = class { /** Creates a new DASH parser. */ constructor() { /** @private {?shaka.extern.ManifestConfiguration} */ this.config_ = null; /** @private {?shaka.extern.ManifestParser.PlayerInterface} */ this.playerInterface_ = null; /** @private {!Array<string>} */ this.manifestUris_ = []; /** @private {?shaka.extern.Manifest} */ this.manifest_ = null; /** @private {number} */ this.globalId_ = 1; /** @private {!Array<shaka.extern.xml.Node>} */ 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<string, !shaka.dash.DashParser.Context>} */ this.contextCache_ = new Map(); /** * @private { * !Map<string, {endTime: number, timeline: number, reps: Array<string>}> * } */ this.continuityCache_ = new Map(); /** * Cache of parsed periods keyed by Period ID. * @private {!Map<string, !shaka.extern.Period>} */ this.parsedPeriodCache_ = new Map(); /** * A map of IDs to Stream objects. * ID: Period@id,Representation@id * e.g.: '1,23' * @private {!Map<string, !shaka.extern.Stream>} */ this.streamMap_ = new Map(); /** * A map of Period IDs to Stream Map IDs. * Use to have direct access to streamMap key. * @private {!Map<string, !Array<string>>} */ this.indexStreamMap_ = new Map(); /** * A map of period ids to their durations * @private {!Map<string, number>} */ this.periodDurations_ = new Map(); /** @private {shaka.util.PeriodCombiner} */ this.periodCombiner_ = new shaka.util.PeriodCombiner(); /** * The update period in seconds, or 0 for no updates. * @private {number} */ this.updatePeriod_ = 0; /** * An ewma that tracks how long updates take. * This is to mitigate issues caused by slow parsing on embedded devices. * @private {!shaka.abr.Ewma} */ this.averageUpdateDuration_ = new shaka.abr.Ewma(5); /** @private {shaka.util.Timer} */ this.updateTimer_ = new shaka.util.Timer(() => { if (this.mediaElement_ && !this.config_.continueLoadingWhenPaused) { this.eventManager_.unlisten(this.mediaElement_, 'timeupdate'); if (this.mediaElement_.paused) { this.eventManager_.listenOnce( this.mediaElement_, 'timeupdate', () => this.onUpdate_()); return; } } this.onUpdate_(); }); /** @private {!shaka.util.OperationManager} */ this.operationManager_ = new shaka.util.OperationManager(); /** * Largest period start time seen. * @private {?number} */ this.largestPeriodStartTime_ = null; /** * Period IDs seen in previous manifest. * @private {!Array<string>} */ this.lastManifestUpdatePeriodIds_ = []; /** * The minimum of the availabilityTimeOffset values among the adaptation * sets. * @private {number} */ this.minTotalAvailabilityTimeOffset_ = Infinity; /** @private {boolean} */ this.lowLatencyMode_ = false; /** @private {?shaka.util.ContentSteeringManager} */ this.contentSteeringManager_ = null; /** @private {number} */ this.gapCount_ = 0; /** @private {boolean} */ this.isLowLatency_ = false; /** @private {shaka.util.EventManager} */ this.eventManager_ = new shaka.util.EventManager(); /** @private {HTMLMediaElement} */ this.mediaElement_ = null; /** @private {boolean} */ this.isTransitionFromDynamicToStatic_ = false; /** @private {string} */ this.lastManifestQueryParams_ = ''; /** @private {?(function():string)} */ this.mpdpatchUrlParamsFn_ = null; /** @private {?(function():string)} */ this.mpdUrlParamsFn_ = null; /** @private {function():boolean} */ this.isPreloadFn_ = () => false; /** @private {?Array<string>} */ this.lastCalculatedBaseUris_ = []; /** * Used to track which prft nodes have been already parsed to avoid * duplicating work for all representations. * @private {!Set<!shaka.extern.xml.Node>} */ this.parsedPrftNodes_ = new Set(); /** @private {!shaka.dash.ContentProtection} */ this.contentProtection_ = new shaka.dash.ContentProtection(); } /** * @param {shaka.extern.ManifestConfiguration} config * @param {(function():boolean)=} isPreloadFn * @override * @exportInterface */ configure(config, isPreloadFn) { goog.asserts.assert(config.dash != null, 'DashManifestConfiguration should not be null!'); const needFireUpdate = this.playerInterface_ && config.updatePeriod != this.config_.updatePeriod && config.updatePeriod >= 0; this.config_ = config; if (isPreloadFn) { this.isPreloadFn_ = isPreloadFn; } if (needFireUpdate && this.manifest_ && this.manifest_.presentationTimeline.isLive()) { this.updateNow_(); } if (this.contentSteeringManager_) { this.contentSteeringManager_.configure(this.config_); } if (this.periodCombiner_) { this.periodCombiner_.setUseStreamOnce( this.config_.dash.useStreamOnceInPeriodFlattening); } } /** * @override * @exportInterface */ async start(uri, playerInterface) { goog.asserts.assert(this.config_, 'Must call configure() before start()!'); this.lowLatencyMode_ = playerInterface.isLowLatencyMode(); this.manifestUris_ = [uri]; this.playerInterface_ = playerInterface; const updateDelay = await this.requestManifest_(); if (this.playerInterface_) { this.setUpdateTimer_(updateDelay); } // Make sure that the parser has not been destroyed. if (!this.playerInterface_) { throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.PLAYER, shaka.util.Error.Code.OPERATION_ABORTED); } goog.asserts.assert(this.manifest_, 'Manifest should be non-null!'); return this.manifest_; } /** * @override * @exportInterface */ stop() { // When the parser stops, release all segment indexes, which stops their // timers, as well. for (const stream of this.streamMap_.values()) { if (stream.segmentIndex) { stream.segmentIndex.release(); } } if (this.periodCombiner_) { this.periodCombiner_.release(); } this.playerInterface_ = null; this.config_ = null; this.manifestUris_ = []; this.manifest_ = null; this.streamMap_.clear(); this.indexStreamMap_.clear(); this.contextCache_.clear(); this.continuityCache_.clear(); this.parsedPeriodCache_.clear(); this.manifestPatchContext_ = { mpdId: '', type: '', profiles: [], mediaPresentationDuration: null, availabilityTimeOffset: 0, getBaseUris: null, publishTime: 0, }; this.periodCombiner_ = null; if (this.updateTimer_ != null) { this.updateTimer_.stop(); this.updateTimer_ = null; } if (this.contentSteeringManager_) { this.contentSteeringManager_.destroy(); } if (this.eventManager_) { this.eventManager_.release(); this.eventManager_ = null; } this.parsedPrftNodes_.clear(); this.contentProtection_.release(); return this.operationManager_.destroy(); } /** * @override * @exportInterface */ async update() { try { await this.requestManifest_(); } catch (error) { if (!this.playerInterface_ || !error) { return; } goog.asserts.assert(error instanceof shaka.util.Error, 'Bad error type'); this.playerInterface_.onError(error); } } /** * @override * @exportInterface */ onExpirationUpdated(sessionId, expiration) { // No-op } /** * @override * @exportInterface */ onInitialVariantChosen(variant) { // For live it is necessary that the first time we update the manifest with // a shorter time than indicated to take into account that the last segment // added could be halfway, for example if (this.manifest_ && this.manifest_.presentationTimeline.isLive()) { const stream = variant.video || variant.audio; if (stream && stream.segmentIndex) { const availabilityEnd = this.manifest_.presentationTimeline.getSegmentAvailabilityEnd(); const position = stream.segmentIndex.find(availabilityEnd); if (position == null) { return; } const reference = stream.segmentIndex.get(position); if (!reference) { return; } this.updatePeriod_ = reference.endTime - availabilityEnd; this.setUpdateTimer_(/* offset= */ 0); } } } /** * @override * @exportInterface */ banLocation(uri) { if (this.contentSteeringManager_) { this.contentSteeringManager_.banLocation(uri); } } /** * @override * @exportInterface */ setMediaElement(mediaElement) { this.mediaElement_ = mediaElement; } /** * Makes a network request for the manifest and parses the resulting data. * * @return {!Promise<number>} Resolves with the time it took, in seconds, to * fulfill the request and parse the data. * @private */ async requestManifest_() { const requestType = shaka.net.NetworkingEngine.RequestType.MANIFEST; let type = shaka.net.NetworkingEngine.AdvancedRequestType.MPD; let rootElement = 'MPD'; const patchLocationUris = this.getPatchLocationUris_(); let manifestUris = this.manifestUris_; if (patchLocationUris.length) { manifestUris = patchLocationUris.map((uri) => shaka.util.URL.applyUrlParams(uri, this.mpdpatchUrlParamsFn_)); rootElement = 'Patch'; type = shaka.net.NetworkingEngine.AdvancedRequestType.MPD_PATCH; } else { if (this.manifestUris_.length > 1 && this.contentSteeringManager_) { const locations = this.contentSteeringManager_.getLocations( 'Location', /* ignoreBaseUrls= */ true); if (locations.length) { manifestUris = locations; } } if (this.mpdUrlParamsFn_) { manifestUris = manifestUris.map( (uri) => shaka.util.URL.applyUrlParams(uri, this.mpdUrlParamsFn_)); } } const request = shaka.net.NetworkingEngine.makeRequest( manifestUris, this.config_.retryParameters); const startTime = Date.now(); const response = await this.makeNetworkRequest_( request, requestType, {type}); // Detect calls to stop(). if (!this.playerInterface_) { return 0; } // For redirections add the response uri to the first entry in the // Manifest Uris array. if (response.uri && response.uri != response.originalUri && !this.manifestUris_.includes(response.uri)) { this.manifestUris_.unshift(response.uri); } const url = new URL(response.uri); this.lastManifestQueryParams_ = url.searchParams.toString(); // This may throw, but it will result in a failed promise. 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; this.averageUpdateDuration_.sample(1, updateDuration); this.parsedPrftNodes_.clear(); // Let the caller know how long this update took. return updateDuration; } /** * Parses the manifest XML. This also handles updates and will update the * stored manifest. * * @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} */ parseManifest(data, finalManifestUri, rootElement) { const mpd = shaka.util.TXml.parseXml(data, rootElement); return this.processParsedMpd(mpd, finalManifestUri, rootElement); } /** * Common point for processing an already parsed MPD (whether it comes from * direct XML or from JSON converted to XML by the subclass). * * @param {?shaka.extern.xml.Node} mpd * @param {string} finalManifestUri * @param {string} rootElement * @return {!Promise} */ async processParsedMpd(mpd, finalManifestUri, rootElement) { if (!mpd) { throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.MANIFEST, shaka.util.Error.Code.DASH_INVALID_XML, finalManifestUri); } const manifestPreprocessorTXml = this.config_.dash.manifestPreprocessorTXml; const defaultManifestPreprocessorTXml = shaka.util.PlayerConfiguration.defaultManifestPreprocessorTXml; if (manifestPreprocessorTXml != defaultManifestPreprocessorTXml) { manifestPreprocessorTXml(mpd); } if (rootElement === 'Patch') { return this.processPatchManifest_(mpd); } // Resolve Linked Periods (ImportedMPD elements, DASH 6th ed. §5.3.2.6). let resolvedMpd = /** @type {!shaka.extern.xml.Node} */ (mpd); if (shaka.dash.MpdUtils.hasLinkedPeriods(resolvedMpd)) { const linkedPeriodOp = shaka.dash.MpdUtils.processLinkedPeriods( resolvedMpd, finalManifestUri, this.config_.retryParameters, this.playerInterface_.networkingEngine); this.operationManager_.manage(linkedPeriodOp); resolvedMpd = await linkedPeriodOp.promise; } if (!shaka.dash.MpdUtils.hasXlinks(resolvedMpd)) { return this.processManifest_(resolvedMpd, finalManifestUri); } // Process the mpd to account for xlink connections. const failGracefully = this.config_.dash.xlinkFailGracefully; const xlinkOperation = shaka.dash.MpdUtils.processXlinks( resolvedMpd, this.config_.retryParameters, failGracefully, finalManifestUri, this.playerInterface_.networkingEngine); this.operationManager_.manage(xlinkOperation); const finalMpd = await xlinkOperation.promise; return this.processManifest_(finalMpd, finalManifestUri); } /** * Takes a formatted MPD and converts it into a manifest. * * @param {!shaka.extern.xml.Node} mpd * @param {string} finalManifestUri The final manifest URI, which may * differ from this.manifestUri_ if there has been a redirect. * @return {!Promise} * @private */ async processManifest_(mpd, finalManifestUri) { const TXml = shaka.util.TXml; goog.asserts.assert(this.config_, 'Must call configure() before processManifest_()!'); if (this.contentSteeringManager_) { this.contentSteeringManager_.clearPreviousLocations(); } // Enforce the MPD-level EssentialProperty conformance rule before doing any // heavy processing (ISO/IEC 23009-1 clause 5.8.4.8). this.checkMpdEssentialProperties_(mpd); // Get any Location elements. This will update the manifest location and // the base URI. /** @type {!Array<string>} */ let manifestBaseUris = [finalManifestUri]; /** @type {!Array<string>} */ const locations = []; /** @type {!Map<string, string>} */ const locationsMapping = new Map(); const locationsObjs = TXml.findChildren(mpd, 'Location'); for (const locationsObj of locationsObjs) { const serviceLocation = locationsObj.attributes['serviceLocation']; const uri = TXml.getContents(locationsObj); if (!uri) { continue; } const finalUri = shaka.util.URL.resolveUris(manifestBaseUris, [uri])[0]; if (serviceLocation) { if (this.contentSteeringManager_) { this.contentSteeringManager_.addLocation( 'Location', serviceLocation, finalUri); } else { locationsMapping.set(serviceLocation, finalUri); } } locations.push(finalUri); } if (this.contentSteeringManager_) { const steeringLocations = this.contentSteeringManager_.getLocations( 'Location', /* ignoreBaseUrls= */ true); if (steeringLocations.length > 0) { this.manifestUris_ = steeringLocations; manifestBaseUris = steeringLocations; } } else if (locations.length) { this.manifestUris_ = locations; manifestBaseUris = locations; } this.manifestPatchContext_.mpdId = mpd.attributes['id'] || ''; this.manifestPatchContext_.publishTime = TXml.parseAttr(mpd, 'publishTime', TXml.parseDate) || 0; this.patchLocationNodes_ = TXml.findChildren(mpd, 'PatchLocation'); this.mpdpatchUrlParamsFn_ = this.findRequestParamFn_(mpd, 'mpdpatch') || null; this.mpdUrlParamsFn_ = this.findRequestParamFn_(mpd, 'mpd') || null; let contentSteeringPromise = Promise.resolve(); const contentSteering = TXml.findChild(mpd, 'ContentSteering'); if (contentSteering && this.playerInterface_) { const defaultPathwayId = contentSteering.attributes['defaultServiceLocation']; if (!this.contentSteeringManager_) { this.contentSteeringManager_ = new shaka.util.ContentSteeringManager(this.playerInterface_); this.contentSteeringManager_.configure(this.config_); this.contentSteeringManager_.setManifestType( shaka.media.ManifestParser.DASH); this.contentSteeringManager_.setBaseUris(manifestBaseUris); this.contentSteeringManager_.setDefaultPathwayId(defaultPathwayId); let uri = TXml.getContents(contentSteering); if (uri) { uri = shaka.util.URL.applyUrlParams( uri, this.findRequestParamFn_(mpd, 'steering')); const queryBeforeStart = TXml.parseAttr(contentSteering, 'queryBeforeStart', TXml.parseBoolean, /* defaultValue= */ false); if (queryBeforeStart) { contentSteeringPromise = this.contentSteeringManager_.requestInfo(uri); } else { this.contentSteeringManager_.requestInfo(uri); } } } else { this.contentSteeringManager_.setBaseUris(manifestBaseUris); this.contentSteeringManager_.setDefaultPathwayId(defaultPathwayId); } for (const serviceLocation of locationsMapping.keys()) { const uri = locationsMapping.get(serviceLocation); this.contentSteeringManager_.addLocation( 'Location', serviceLocation, uri); } } const uriObjs = TXml.findChildren(mpd, 'BaseURL'); let someLocationValid = false; if (this.contentSteeringManager_) { for (const uriObj of uriObjs) { const serviceLocation = uriObj.attributes['serviceLocation']; const uri = TXml.getContents(uriObj); if (serviceLocation && uri) { this.contentSteeringManager_.addLocation( 'BaseURL', serviceLocation, uri); someLocationValid = true; } } } // Clean the array instead of creating a new one. By doing this we ensure // that references to the array does not change in callback functions. this.lastCalculatedBaseUris_.splice(0); if (!someLocationValid || !this.contentSteeringManager_) { const uris = uriObjs.map(TXml.getContents); this.lastCalculatedBaseUris_.push( ...shaka.util.URL.resolveUris(manifestBaseUris, uris)); } // Here we are creating local variables to avoid direct references to `this` // in a callback function. By doing this we can ensure that garbage // collector can clean up `this` object when it is no longer needed. const contentSteeringManager = this.contentSteeringManager_; const lastCalculatedBaseUris = this.lastCalculatedBaseUris_; // cspell:words minifiers // Calling makeBaseUriGetter dynamically to prevent makeBaseUriGetter_ from // being inlined by downstream minifiers (e.g. Terser) when Shaka Player is // bundled into other projects. If the function gets inlined, it captures // `mpd` from the lexical scope and leaks memory. const makeBaseUriGetter_ = shaka.dash.DashParser.makeBaseUriGetter_; // eslint-disable-next-line no-restricted-syntax const getBaseUris = makeBaseUriGetter_.call(shaka.dash.DashParser, contentSteeringManager, someLocationValid, lastCalculatedBaseUris); this.manifestPatchContext_.getBaseUris = getBaseUris; let availabilityTimeOffset = 0; if (uriObjs && uriObjs.length) { availabilityTimeOffset = TXml.parseAttr(uriObjs[0], 'availabilityTimeOffset', TXml.parseFloat) || 0; } this.manifestPatchContext_.availabilityTimeOffset = availabilityTimeOffset; this.updatePeriod_ = /** @type {number} */ (TXml.parseAttr( mpd, 'minimumUpdatePeriod', TXml.parseDuration, -1)); const presentationStartTime = TXml.parseAttr( mpd, 'availabilityStartTime', TXml.parseDate); let segmentAvailabilityDuration = TXml.parseAttr( mpd, 'timeShiftBufferDepth', TXml.parseDuration); const ignoreSuggestedPresentationDelay = this.config_.dash.ignoreSuggestedPresentationDelay; let suggestedPresentationDelay = null; if (!ignoreSuggestedPresentationDelay) { suggestedPresentationDelay = TXml.parseAttr( mpd, 'suggestedPresentationDelay', TXml.parseDuration); } const ignoreMaxSegmentDuration = this.config_.dash.ignoreMaxSegmentDuration; let maxSegmentDuration = null; if (!ignoreMaxSegmentDuration) { maxSegmentDuration = TXml.parseAttr( mpd, 'maxSegmentDuration', TXml.parseDuration); } const mpdType = mpd.attributes['type'] || 'static'; // The ISO-BMFF List Profile (DASH 6th ed.) uses type="list", which is a // static/on-demand profile. Treat it as static for timeline purposes. const isStaticType = mpdType == 'static' || mpdType == 'list'; if (this.manifest_ && this.manifest_.presentationTimeline) { this.isTransitionFromDynamicToStatic_ = this.manifest_.presentationTimeline.isLive() && isStaticType; } this.manifestPatchContext_.type = mpdType; /** @type {!shaka.media.PresentationTimeline} */ let presentationTimeline; if (this.manifest_) { presentationTimeline = this.manifest_.presentationTimeline; // Before processing an update, evict from all segment indexes. Some of // them may not get updated otherwise if their corresponding Period // element has been dropped from the manifest since the last update. // Without this, playback will still work, but this is necessary to // maintain conditions that we assert on for multi-Period content. // This gives us confidence that our state is maintained correctly, and // that the complex logic of multi-Period eviction and period-flattening // is correct. See also: // https://github.com/shaka-project/shaka-player/issues/3169#issuecomment-823580634 const availabilityStart = presentationTimeline.getSegmentAvailabilityStart(); for (const stream of this.streamMap_.values()) { if (stream.segmentIndex) { stream.segmentIndex.evict(availabilityStart); } } } else { const ignoreMinBufferTime = this.config_.dash.ignoreMinBufferTime; let minBufferTime = 0; if (!ignoreMinBufferTime) { minBufferTime = TXml.parseAttr(mpd, 'minBufferTime', TXml.parseDuration) || 0; } // DASH IOP v3.0 suggests using a default delay between minBufferTime // and timeShiftBufferDepth. This is literally the range of all // feasible choices for the value. Nothing older than // timeShiftBufferDepth is still available, and anything less than // minBufferTime will cause buffering issues. let delay = 0; if (suggestedPresentationDelay != null) { // 1. If a suggestedPresentationDelay is provided by the manifest, that // will be used preferentially. // This is given a minimum bound of segmentAvailabilityDuration. // Content providers should provide a suggestedPresentationDelay // whenever possible to optimize the live streaming experience. delay = Math.min( suggestedPresentationDelay, segmentAvailabilityDuration || Infinity); } else if (this.config_.defaultPresentationDelay > 0) { // 2. If the developer provides a value for // "manifest.defaultPresentationDelay", that is used as a fallback. delay = this.config_.defaultPresentationDelay; } else { // 3. Otherwise, we default to the lower of segmentAvailabilityDuration // and 1.5 * minBufferTime. This is fairly conservative. delay = Math.min( minBufferTime * 1.5, segmentAvailabilityDuration || Infinity); } presentationTimeline = new shaka.media.PresentationTimeline( presentationStartTime, delay, this.config_.dash.autoCorrectDrift); } presentationTimeline.setStatic(isStaticType); const isLive = presentationTimeline.isLive(); // If it's live, we check for an override. if (isLive && !isNaN(this.config_.availabilityWindowOverride)) { segmentAvailabilityDuration = this.config_.availabilityWindowOverride; } // If it's null, that means segments are always available. This is always // the case for VOD, and sometimes the case for live. if (segmentAvailabilityDuration == null) { segmentAvailabilityDuration = Infinity; } presentationTimeline.setSegmentAvailabilityDuration( segmentAvailabilityDuration); const profiles = mpd.attributes['profiles'] || ''; this.manifestPatchContext_.profiles = profiles.split(','); /** @type {shaka.dash.DashParser.Context} */ const context = { // Don't base on updatePeriod_ since emsg boxes can cause manifest // updates. dynamic: !isStaticType, presentationTimeline: presentationTimeline, period: null, periodInfo: null, adaptationSet: null, representation: null, bandwidth: 0, indexRangeWarningGiven: false, availabilityTimeOffset: availabilityTimeOffset, mediaPresentationDuration: null, profiles: profiles.split(','), roles: null, urlParams: shaka.dash.DashParser.defaultUrlParams_, name: '', }; await contentSteeringPromise; // Parse MPD-level urlparam:2014 / urlparam:2016 descriptors (their // UrlQueryInfo / ExtUrlQueryInfo child, ISO/IEC 23009-1 Annex I). const essentials = shaka.dash.MpdUtils.parseEssentialProperties(mpd); const supplementals = shaka.dash.MpdUtils.parseSupplementalProperties(mpd); const isUrlParamDescriptor = (d) => shaka.dash.DashParser.URL_PARAM_DESCRIPTOR_SCHEMES_.has(d.schemeIdUri); const mpdUrlParamDescriptor = essentials.find(isUrlParamDescriptor) || supplementals.find(isUrlParamDescriptor); if (mpdUrlParamDescriptor) { const descriptorUrlParams = this.getURLParametersFunction_(mpdUrlParamDescriptor.element); if (descriptorUrlParams) { context.urlParams = descriptorUrlParams; } } // Parse MPD-level RequestParam elements (urlparam:2025, DASH 6th ed.) const mpdUrlParams = this.findRequestParamFn_(mpd, 'segment'); if (mpdUrlParams) { context.urlParams = mpdUrlParams; } this.gapCount_ = 0; const periodsAndDuration = this.parsePeriods_( context, getBaseUris, mpd, /* newPeriod= */ false); const duration = periodsAndDuration.duration; const periods = periodsAndDuration.periods; if ((isStaticType && !this.isTransitionFromDynamicToStatic_) || !periodsAndDuration.durationDerivedFromPeriods) { // Ignore duration calculated from Period lengths if this is dynamic. presentationTimeline.setDuration(duration || Infinity); } if (this.isLowLatency_ && this.lowLatencyMode_) { presentationTimeline.setAvailabilityTimeOffset( this.minTotalAvailabilityTimeOffset_); } // Use @maxSegmentDuration to override smaller, derived values. presentationTimeline.notifyMaxSegmentDuration(maxSegmentDuration || 1); if (goog.DEBUG && !this.isTransitionFromDynamicToStatic_) { presentationTimeline.assertIsValid(); } if (this.isLowLatency_ && this.lowLatencyMode_) { const presentationDelay = suggestedPresentationDelay != null ? suggestedPresentationDelay : this.config_.defaultPresentationDelay; presentationTimeline.setDelay(presentationDelay); } // 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(), textStreams: this.periodCombiner_.getTextStreams(), imageStreams: this.periodCombiner_.getImageStreams(), chapterStreams: [], offlineSessionIds: [], sequenceMode: this.config_.dash.sequenceMode, ignoreManifestTimestampsInSegmentsMode: false, type: shaka.media.ManifestParser.DASH, serviceDescription: this.parseServiceDescription_(mpd), nextUrl: this.parseMpdChaining_(mpd), periodCount: periods.length, gapCount: this.gapCount_, isLowLatency: this.isLowLatency_, startTime: null, }; // We only need to do clock sync when we're using presentation start // time. This condition also excludes VOD streams. if (presentationTimeline.usingPresentationStartTime()) { const TXml = shaka.util.TXml; const timingElements = TXml.findChildren(mpd, 'UTCTiming'); const offset = await this.parseUtcTiming_(getBaseUris, timingElements); // Detect calls to stop(). if (!this.playerInterface_) { return; } presentationTimeline.setClockOffset(offset); } // This is the first point where we have a meaningful presentation start // time, and we need to tell PresentationTimeline that so that it can // maintain consistency from here on. presentationTimeline.lockStartTime(); if (this.periodCombiner_ && !this.manifest_.presentationTimeline.isLive()) { this.periodCombiner_.release(); } const programInformation = shaka.util.TXml.findChild(mpd, 'ProgramInformation'); if (programInformation) { const eventName = shaka.util.FakeEvent.EventName.ProgramInformation; const data = (new Map()).set('detail', programInformation); const event = new shaka.util.FakeEvent(eventName, data); this.playerInterface_.onEvent(event); } } else { this.manifest_.periodCount = periods.length; this.manifest_.gapCount = this.gapCount_; await this.postPeriodProcessing_(periods, /* isPatchUpdate= */ false); } // Add text streams to correspond to closed captions. This happens right // after period combining, while we still have a direct reference, so that // any new streams will appear in the period combiner. this.playerInterface_.makeTextStreamsForClosedCaptions(this.manifest_); this.cleanStreamMap_(); this.cleanContinuityCache_(periods); } /** * Handles common procedures after processing new periods. * * @param {!Array<shaka.extern.Period>} 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<shaka.extern.Period>} */ const newPeriods = []; /** @type {!Array<shaka.extern.xml.Node>} */ const periodAdditions = []; /** @type {!Set<string>} */ 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) { this.manifest_.periodCount += newPeriods.length; this.manifest_.gapCount = this.gapCount_; 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 * "dynamic" 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, roles: null, urlParams: shaka.dash.DashParser.defaultUrlParams_, name: '', }; const periodsAndDuration = this.parsePeriods_(context, this.manifestPatchContext_.getBaseUris, periods, /* newPeriod= */ true); return periodsAndDuration.periods; } /** * @param {string} periodId * @private */ removePatchPeriod_(periodId) { const SegmentTemplate = shaka.dash.SegmentTemplate; this.manifest_.periodCount--; 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); } } const newPeriods = this.lastManifestUpdatePeriodIds_.filter((pID) => { return pID !== periodId; }); this.lastManifestUpdatePeriodIds_ = newPeriods; this.parsedPeriodCache_.delete(periodId); } /** * @param {!Array<shaka.util.TXml.PathNode>} paths * @return {!Array<string>} * @private */ getContextIdsFromPath_(paths) { let periodId = ''; let adaptationSetId = ''; let adaptationSetPosition = -1; let representationId = ''; for (const node of paths) { if (node.name === 'Period') { periodId = node.id; } else if (node.name === 'AdaptationSet') { adaptationSetId = node.id; if (node.position !== null) { adaptationSetPosition = node.position; } } else if (node.name === 'Representation') { representationId = node.id; } } /** @type {!Array<string>} */ const contextIds = []; if (representationId) { contextIds.push(periodId + ',' + representationId); } else { if (adaptationSetId) { 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); } } } else { if (adaptationSetPosition > -1) { for (const context of this.contextCache_.values()) { if (context.period.id === periodId && context.adaptationSet.position === adaptationSetPosition && 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<string>} 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<string>} 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_.get(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, this.continuityCache_); 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. * * @param {!shaka.extern.xml.Node} mpd * @return {?shaka.extern.ServiceDescription} * @private */ parseServiceDescription_(mpd) { const TXml = shaka.util.TXml; const elem = TXml.findChild(mpd, 'ServiceDescription'); if (!elem ) { return null; } const latencyNode = TXml.findChild(elem, 'Latency'); const playbackRateNode = TXml.findChild(elem, 'PlaybackRate'); if (!latencyNode && !playbackRateNode) { return null; } const description = {}; if (latencyNode) { if ('target' in latencyNode.attributes) { description.targetLatency = parseInt(latencyNode.attributes['target'], 10) / 1000; } if ('max' in latencyNode.attributes) { description.maxLatency = parseInt(latencyNode.attributes['max'], 10) / 1000; } if ('min' in latencyNode.attributes) { description.minLatency = parseInt(latencyNode.attributes['min'], 10) / 1000; } } if (playbackRateNode) { if ('max' in playbackRateNode.attributes) { description.maxPlaybackRate = parseFloat(playbackRateNode.attributes['max']); } if ('min' in playbackRateNode.attributes) { description.minPlaybackRate = parseFloat(playbackRateNode.attributes['min']); } } return description; } /** * Enforces the EssentialProperty conformance rule at MPD level * (ISO/IEC 23009-1 clause 5.8.4.8). EssentialProperty descriptors are * grouped by their id attribute, and for each group at least one scheme must * be recognized by the parser. If a whole group is unrecognized, the content * cannot be presented and parsing fails. * * @param {!shaka.extern.xml.Node} mpd * @private */ checkMpdEssentialProperties_(mpd) { const supported = shaka.dash.DashParser.SUPPORTED_ESSENTIAL_PROPERTIES_; const essentials = shaka.dash.MpdUtils.parseEssentialProperties(mpd); if (!essentials.length) { return; } // Group EssentialProperty descriptors by @id. Descriptors without an @id // do not share an @id with any other, so each is its own group and must be // recognized on its own. /** @type {!Map<string, !Array<shaka.dash.MpdUtils.Descriptor>>} */ const groups = new Map(); let noIdCounter = 0; for (const descriptor of essentials) { const key = descriptor.id != null ? 'id:' + descriptor.id : 'no-id:' + (noIdCounter++); groups.getOrInsert(key, []).push(descriptor); } for (const group of groups.values()) { const recognized = group.some((d) => supported.has(d.schemeIdUri)); if (!recognized) { throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.MANIFEST, shaka.util.Error.Code.DASH_UNSUPPORTED_ESSENTIAL_PROPERTY, group.map((d) => d.schemeIdUri)); } } } /** * Reads chaining url. * * @param {!shaka.extern.xml.Node} mpd * @return {?string} * @private */ parseMpdChaining_(mpd) { const supplementals = shaka.dash.MpdUtils.parseSupplementalProperties(mpd); const chaining = shaka.dash.MpdUtils.getDescriptor( supplementals, 'urn:mpeg:dash:chaining:2016'); if (!chaining || !chaining.value) { return null; } return shaka.util.URL.applyUrlParams( chaining.value, this.findRequestParamFn_(mpd, 'chaining')); } /** * Reads and parses the periods from the manifest. This first does some * partial parsing so the st