shaka-player
Version:
DASH/EME video player library
1,454 lines (1,294 loc) • 124 kB
JavaScript
/*! @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)