UNPKG

shaka-player

Version:
1,454 lines (1,294 loc) 124 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('goog.Uri'); goog.require('shaka.Deprecate'); 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.Capabilities'); 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.text.TextEngine'); goog.require('shaka.util.ContentSteeringManager'); goog.require('shaka.util.Error'); goog.require('shaka.util.EventManager'); goog.require('shaka.util.Functional'); goog.require('shaka.util.LanguageUtils'); goog.require('shaka.util.ManifestParserUtils'); goog.require('shaka.util.MimeUtils'); goog.require('shaka.util.Networking'); 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.Timer'); goog.require('shaka.util.TXml'); goog.require('shaka.util.XmlUtils'); /** * 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(); /** * 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():boolean} */ this.isPreloadFn_ = () => false; /** @private {?Array<string>} */ this.lastCalculatedBaseUris_ = []; } /** * @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_.setAllowMultiTypeVariants( this.config_.dash.multiTypeVariantsAllowed && shaka.media.Capabilities.isChangeTypeSupported()); 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.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; } 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; 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; } } 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 uriObj = new goog.Uri(response.uri); this.lastManifestQueryParams_ = uriObj.getQueryData().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); // 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} * @private */ async parseManifest_(data, finalManifestUri, rootElement) { let manifestData = data; const manifestPreprocessor = this.config_.dash.manifestPreprocessor; const defaultManifestPreprocessor = shaka.util.PlayerConfiguration.defaultManifestPreprocessor; if (manifestPreprocessor != defaultManifestPreprocessor) { shaka.Deprecate.deprecateFeature(5, 'manifest.dash.manifestPreprocessor configuration', 'Please Use manifest.dash.manifestPreprocessorTXml instead.'); const mpdElement = shaka.util.XmlUtils.parseXml(manifestData, rootElement); if (!mpdElement) { throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.MANIFEST, shaka.util.Error.Code.DASH_INVALID_XML, finalManifestUri); } manifestPreprocessor(mpdElement); manifestData = shaka.util.XmlUtils.toArrayBuffer(mpdElement); } const mpd = shaka.util.TXml.parseXml(manifestData, 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); } const disableXlinkProcessing = this.config_.dash.disableXlinkProcessing; if (disableXlinkProcessing) { return this.processManifest_(mpd, finalManifestUri); } // Process the mpd to account for xlink connections. const failGracefully = this.config_.dash.xlinkFailGracefully; const xlinkOperation = shaka.dash.MpdUtils.processXlinks( mpd, 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(); } // 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.ManifestParserUtils.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'); 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); const uri = TXml.getContents(contentSteering); if (uri) { 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; } } } this.lastCalculatedBaseUris_ = null; if (!someLocationValid || !this.contentSteeringManager_) { const uris = uriObjs.map(TXml.getContents); this.lastCalculatedBaseUris_ = shaka.util.ManifestParserUtils.resolveUris( manifestBaseUris, uris); } const getBaseUris = () => { if (this.contentSteeringManager_ && someLocationValid) { return this.contentSteeringManager_.getLocations('BaseURL'); } if (this.lastCalculatedBaseUris_) { return this.lastCalculatedBaseUris_; } 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; 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'; if (this.manifest_ && this.manifest_.presentationTimeline) { this.isTransitionFromDynamicToStatic_ = this.manifest_.presentationTimeline.isLive() && mpdType == 'static'; } 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(mpdType == 'static'); 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: mpdType != 'static', presentationTimeline: presentationTimeline, period: null, periodInfo: null, adaptationSet: null, representation: null, bandwidth: 0, indexRangeWarningGiven: false, availabilityTimeOffset: availabilityTimeOffset, mediaPresentationDuration: null, profiles: profiles.split(','), roles: null, urlParams: () => '', }; await contentSteeringPromise; this.gapCount_ = 0; const periodsAndDuration = this.parsePeriods_( context, getBaseUris, mpd, /* newPeriod= */ false); const duration = periodsAndDuration.duration; const periods = periodsAndDuration.periods; if ((mpdType == 'static' && !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(), 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(); } 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_(); } /** * 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: () => '', }; 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; } /** * @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); 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; } /** * Reads chaining url. * * @param {!shaka.extern.xml.Node} mpd * @return {?string} * @private */ parseMpdChaining_(mpd) { const TXml = shaka.util.TXml; const supplementalProperties = TXml.findChildren(mpd, 'SupplementalProperty'); if (!supplementalProperties.length) { return null; } for (const prop of supplementalProperties) { const schemeId = prop.attributes['schemeIdUri']; if (schemeId == 'urn:mpeg:dash:chaining:2016') { return prop.attributes['value']; } } return null; } /** * Reads and parses the periods from the manifest. This first does some * partial parsing so the start and duration is available when parsing * children. * * @param {shaka.dash.DashParser.Context} context * @param {function(): !Array<string>} getBaseUris * @param {!shaka.extern.xml.Node} mpd * @param {!boolean} newPeriod * @return {{ * periods: !Array<shaka.extern.Period>, * duration: ?number, * durationDerivedFromPeriods: boolean * }} * @private */ parsePeriods_(context, getBaseUris, mpd, newPeriod) { const TXml = shaka.util.TXml; let presentationDuration = context.mediaPresentationDuration; if (!presentationDuration) { presentationDuration = TXml.parseAttr( mpd, 'mediaPresentationDuration', TXml.parseDuration); this.manifestPatchContext_.mediaPresentationDuration = presentationDuration; } let seekRangeStart = 0; if (this.manifest_ && this.manifest_.presentationTimeline && this.isTransitionFromDynamicToStatic_) { seekRangeStart = this.manifest_.presentationTimeline.getSeekRangeStart(); } const periods = []; let prevEnd = seekRangeStart; const periodNodes = TXml.findChildren(mpd, 'Period'); for (let i = 0; i < periodNodes.length; i++) { const elem = periodNodes[i]; const next = periodNodes[i + 1]; let start = /** @type {number} */ ( TXml.parseAttr(elem, 'start', TXml.parseDuration, prevEnd)); const periodId = elem.attributes['id']; const givenDuration = TXml.parseAttr(elem, 'duration', TXml.parseDuration); start = (i == 0 && start == 0 && this.isTransitionFromDynamicToStatic_) ? seekRangeStart : start; let periodDuration = null; if (next) { // "The difference between the start time of a Period and the start time // of the following Period is the duration of the media content // represented by this Period." const nextStart = TXml.parseAttr(next, 'start', TXml.parseDuration); if (nextStart != null) { periodDuration = nextStart - start + seekRangeStart; } } else if (presentationDuration != null) { // "The Period extends until the Period.start of the next Period, or // until the end of the Media Presentation in the case of the last // Period." periodDuration = presentationDuration - start + seekRangeStart; } const threshold = shaka.util.ManifestParserUtils.GAP_OVERLAP_TOLERANCE_SECONDS; if (periodDuration && givenDuration && Math.abs(periodDuration - givenDuration) > threshold) { shaka.log.warning('There is a gap/overlap between Periods', elem); // This means it's a gap, the distance between period starts is // larger than the period's duration if (periodDuration > givenDuration) { this.gapCount_++; } } // Only use the @duration in the MPD if we can't calculate it. We should // favor the @start of the following Period. This ensures that there // aren't gaps between Periods. if (periodDuration == null) { periodDuration = givenDuration; } /** * This is to improve robustness when the player observes manifest with * past periods that are inconsistent to previous ones. * * This may happen when a CDN or proxy server switches its upstream from * one encoder to another redundant encoder. * * Skip periods that match all of the following criteria: * - Start time is earlier than latest period start time ever seen * - Period ID is never seen in the previous manifest * - Not the last period in the manifest * * Periods that meet the aforementioned criteria are considered invalid * and should be safe to discard. */ if (this.largestPeriodStartTime_ !== null && periodId !== null && start !== null && start < this.largestPeriodStartTime_ && !this.lastManifestUpdatePeriodIds_.includes(periodId) && i + 1 != periodNodes.length) { shaka.log.debug( `Skipping Period with ID ${periodId} as its start time is smaller` + ' than the largest period start time that has been seen, and ID ' + 'is unseen before'); continue; } // Save maximum period start time if it is the last period if (start !== null && (this.largestPeriodStartTime_ === null || start > this.largestPeriodStartTime_)) { this.largestPeriodStartTime_ = start; } // Parse child nodes. const info = { start: start, duration: periodDuration, node: elem, isLastPeriod: periodDuration == null || !next, }; const period = this.parsePeriod_(context, getBaseUris, info); periods.push(period); if (context.period.id && periodDuration) { this.periodDurations_.set(context.period.id, periodDuration); } if (periodDuration == null) { if (next) { // If the duration is still null and we aren't at the end, then we // will skip any remaining periods. shaka.log.warning( 'Skipping Period', i + 1, 'and any subsequent Periods:', 'Period', i + 1, 'does not have a valid start time.', next); } // The duration is unknown, so the end is unknown. prevEnd = null; break; } prevEnd = start + periodDuration; } // end of period parsing loop if (newPeriod) { // append new period from the patch manifest for (const el of periods) { const periodID = el.id; if (!this.lastManifestUpdatePeriodIds_.includes(periodID)) { this.lastManifestUpdatePeriodIds_.push(periodID)