Files
shaka-player/lib/dash/dash_parser.js
T
Matthias 369916489e feat: Add config to allow reset MSE on cross boundary (#8156)
There's devices out there that are not compliant with the MSE spec. Such
as halting MSE when a secondary init segment is appended (webOS 3), or
failing to transition from a plain to encrypted init segment (Tizen
2017). While we initially prefer content workarounds, it's a time
consuming and trial & error process. For some devices it might not be
worth investing time into finding a proper workaround due to low usage.
We're giving people an alternative by resetting MSE when needed
(configurable). dash.js offers somewhat similar behavior
[here](https://github.com/Dash-Industry-Forum/dash.js/blob/a656ec709e7f92f76b392bf196ee9883da7928ce/src/streaming/controllers/StreamController.js#L672),
where MSE is reset before applying an encrypted init segment.

This PR introduces `crossBoundaryStrategy` in `StreamingConfiguration`.
It can be configured as following:

- KEEP - we're keeping MSE active, this is the default and the current
behavior.
- RESET - we'll always reset MSE when it crosses a boundary.
- RESET_TO_ENCRYPTED - we reset MSE when it crosses an encrypted
boundary, and we keep MSE afterwards. Additionally, we're not going to
reset when we're crossing a plain to plain boundary.

Each initSegmentReference now holds an `encrypted` and `boundaryEnd`
value. When configured with a different value than KEEP,
`StreamingEngine` will be instructed to fetch and append segment
references up until the boundary of the currently applied init segment.

We detect whether we're at a boundary in a few ways:

- Listening to the HTML5 MediaElement's `waiting` event, this'll
indicate that we do not have enough buffer to advance. If we're pretty
close to the boundary, we assume we're at the boundary.
- Due to subtle differences in the segment alignments, waiting wasn't
reliable. When close to a boundary, a timer is fired with the assumption
that "we'll reach the boundary at soon". I've set the threshold to 1
second, when playhead is further than the threshold, we'll skip checking
whether an MSE reset is due.

The implementation relies on the added properties in the init segment
reference, and the concept of a "Period" is avoided in StreamingEngine
to ensure it's compatible with HLS too.

---------

Co-authored-by: Álvaro Velad Galván <ladvan91@hotmail.com>
Co-authored-by: Wojciech Tyczyński <tykus160@gmail.com>
2025-03-07 10:45:31 +01:00

3524 lines
121 KiB
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);
}
}
} else {
// Replace previous seen periods with the current one.
this.lastManifestUpdatePeriodIds_ = periods.map((el) => el.id);
}
if (presentationDuration != null) {
if (prevEnd != null) {
const threshold =
shaka.util.ManifestParserUtils.GAP_OVERLAP_TOLERANCE_SECONDS;
const difference = prevEnd - seekRangeStart - presentationDuration;
if (Math.abs(difference) > threshold) {
shaka.log.warning(
'@mediaPresentationDuration does not match the total duration ',
'of all Periods.');
// Assume @mediaPresentationDuration is correct.
}
}
return {
periods: periods,
duration: presentationDuration + seekRangeStart,
durationDerivedFromPeriods: false,
};
} else {
return {
periods: periods,
duration: prevEnd,
durationDerivedFromPeriods: true,
};
}
}
/**
* Clean StreamMap Object to remove reference of deleted Stream Object
* @private
*/
cleanStreamMap_() {
const oldPeriodIds = Array.from(this.indexStreamMap_.keys());
const diffPeriodsIDs = oldPeriodIds.filter((pId) => {
return !this.lastManifestUpdatePeriodIds_.includes(pId);
});
for (const pId of diffPeriodsIDs) {
let shouldDeleteIndex = true;
for (const contextId of this.indexStreamMap_.get(pId)) {
const stream = this.streamMap_.get(contextId);
if (!stream) {
continue;
}
if (stream.segmentIndex && !stream.segmentIndex.isEmpty()) {
shouldDeleteIndex = false;
continue;
}
if (this.periodCombiner_) {
this.periodCombiner_.deleteStream(stream, pId);
}
this.streamMap_.delete(contextId);
}
if (shouldDeleteIndex) {
this.indexStreamMap_.delete(pId);
}
}
}
/**
* Parses a Period XML element. Unlike the other parse methods, this is not
* given the Node; it is given a PeriodInfo structure. Also, partial parsing
* was done before this was called so start and duration are valid.
*
* @param {shaka.dash.DashParser.Context} context
* @param {function(): !Array<string>} getBaseUris
* @param {shaka.dash.DashParser.PeriodInfo} periodInfo
* @return {shaka.extern.Period}
* @private
*/
parsePeriod_(context, getBaseUris, periodInfo) {
const Functional = shaka.util.Functional;
const TXml = shaka.util.TXml;
const ContentType = shaka.util.ManifestParserUtils.ContentType;
goog.asserts.assert(periodInfo.node, 'periodInfo.node should exist');
context.period = this.createFrame_(periodInfo.node, null, getBaseUris);
context.periodInfo = periodInfo;
context.period.availabilityTimeOffset = context.availabilityTimeOffset;
// If the period doesn't have an ID, give it one based on its start time.
if (!context.period.id) {
shaka.log.info(
'No Period ID given for Period with start time ' + periodInfo.start +
', Assigning a default');
context.period.id = '__shaka_period_' + periodInfo.start;
}
const eventStreamNodes =
TXml.findChildren(periodInfo.node, 'EventStream');
const availabilityStart =
context.presentationTimeline.getSegmentAvailabilityStart();
for (const node of eventStreamNodes) {
this.parseEventStream_(
periodInfo.start, periodInfo.duration, node, availabilityStart);
}
const supplementalProperties =
TXml.findChildren(periodInfo.node, 'SupplementalProperty');
for (const prop of supplementalProperties) {
const schemeId = prop.attributes['schemeIdUri'];
if (schemeId == 'urn:mpeg:dash:urlparam:2014') {
const urlParams = this.getURLParametersFunction_(prop);
if (urlParams) {
context.urlParams = urlParams;
}
}
}
const adaptationSets =
TXml.findChildren(periodInfo.node, 'AdaptationSet')
.map((node, position) =>
this.parseAdaptationSet_(context, position, node))
.filter(Functional.isNotNull);
// For dynamic manifests, we use rep IDs internally, and they must be
// unique.
if (context.dynamic) {
const ids = [];
for (const set of adaptationSets) {
for (const id of set.representationIds) {
ids.push(id);
}
}
const uniqueIds = new Set(ids);
if (ids.length != uniqueIds.size) {
throw new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.MANIFEST,
shaka.util.Error.Code.DASH_DUPLICATE_REPRESENTATION_ID);
}
}
/** @type {!Map<string, shaka.extern.Stream>} */
const dependencyStreamMap = new Map();
for (const adaptationSet of adaptationSets) {
for (const [dependencyId, stream] of adaptationSet.dependencyStreamMap) {
dependencyStreamMap.set(dependencyId, stream);
}
}
if (dependencyStreamMap.size) {
let duplicateAdaptationSets = null;
for (const adaptationSet of adaptationSets) {
const streamsWithDependencyStream = [];
for (const stream of adaptationSet.streams) {
if (dependencyStreamMap.has(stream.originalId)) {
if (!duplicateAdaptationSets) {
duplicateAdaptationSets =
TXml.findChildren(periodInfo.node, 'AdaptationSet')
.map((node, position) =>
this.parseAdaptationSet_(context, position, node))
.filter(Functional.isNotNull);
}
for (const duplicateAdaptationSet of duplicateAdaptationSets) {
const newStream = duplicateAdaptationSet.streams.find(
(s) => s.originalId == stream.originalId);
if (newStream) {
newStream.dependencyStream =
dependencyStreamMap.get(newStream.originalId);
streamsWithDependencyStream.push(newStream);
}
}
}
}
if (streamsWithDependencyStream.length) {
adaptationSet.streams.push(...streamsWithDependencyStream);
}
}
}
const normalAdaptationSets = adaptationSets
.filter((as) => { return !as.trickModeFor; });
const trickModeAdaptationSets = adaptationSets
.filter((as) => { return as.trickModeFor; });
// Attach trick mode tracks to normal tracks.
if (!this.config_.disableIFrames) {
for (const trickModeSet of trickModeAdaptationSets) {
const targetIds = trickModeSet.trickModeFor.split(' ');
for (const normalSet of normalAdaptationSets) {
if (targetIds.includes(normalSet.id)) {
for (const stream of normalSet.streams) {
shaka.util.StreamUtils.setBetterIFrameStream(
stream, trickModeSet.streams);
}
}
}
}
}
const audioStreams = this.getStreamsFromSets_(
this.config_.disableAudio,
normalAdaptationSets,
ContentType.AUDIO);
const videoStreams = this.getStreamsFromSets_(
this.config_.disableVideo,
normalAdaptationSets,
ContentType.VIDEO);
const textStreams = this.getStreamsFromSets_(
this.config_.disableText,
normalAdaptationSets,
ContentType.TEXT);
const imageStreams = this.getStreamsFromSets_(
this.config_.disableThumbnails,
normalAdaptationSets,
ContentType.IMAGE);
if (videoStreams.length === 0 && audioStreams.length === 0) {
throw new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.MANIFEST,
shaka.util.Error.Code.DASH_EMPTY_PERIOD,
);
}
return {
id: context.period.id,
audioStreams,
videoStreams,
textStreams,
imageStreams,
};
}
/**
* Gets the streams from the given sets or returns an empty array if disabled
* or no streams are found.
* @param {boolean} disabled
* @param {!Array<!shaka.dash.DashParser.AdaptationInfo>} adaptationSets
* @param {string} contentType
* @private
*/
getStreamsFromSets_(disabled, adaptationSets, contentType) {
if (disabled || !adaptationSets.length) {
return [];
}
return adaptationSets.reduce((all, part) => {
if (part.contentType != contentType) {
return all;
}
all.push(...part.streams);
return all;
}, []);
}
/**
* Parses an AdaptationSet XML element.
*
* @param {shaka.dash.DashParser.Context} context
* @param {number} position
* @param {!shaka.extern.xml.Node} elem The AdaptationSet element.
* @return {?shaka.dash.DashParser.AdaptationInfo}
* @private
*/
parseAdaptationSet_(context, position, elem) {
const TXml = shaka.util.TXml;
const Functional = shaka.util.Functional;
const ManifestParserUtils = shaka.util.ManifestParserUtils;
const ContentType = ManifestParserUtils.ContentType;
const ContentProtection = shaka.dash.ContentProtection;
context.adaptationSet = this.createFrame_(elem, context.period, null);
context.adaptationSet.position = position;
let main = false;
const roleElements = TXml.findChildren(elem, 'Role');
const roleValues = roleElements.map((role) => {
return role.attributes['value'];
}).filter(Functional.isNotNull);
// Default kind for text streams is 'subtitle' if unspecified in the
// manifest.
let kind = undefined;
const isText = context.adaptationSet.contentType == ContentType.TEXT;
if (isText) {
kind = ManifestParserUtils.TextStreamKind.SUBTITLE;
}
for (const roleElement of roleElements) {
const scheme = roleElement.attributes['schemeIdUri'];
if (scheme == null || scheme == 'urn:mpeg:dash:role:2011') {
// These only apply for the given scheme, but allow them to be specified
// if there is no scheme specified.
// See: DASH section 5.8.5.5
const value = roleElement.attributes['value'];
switch (value) {
case 'main':
main = true;
break;
case 'caption':
case 'subtitle':
kind = value;
break;
}
}
}
// Parallel for HLS VIDEO-RANGE as defined in DASH-IF IOP v4.3 6.2.5.1.
let videoRange;
let colorGamut;
// Ref. https://dashif.org/docs/DASH-IF-IOP-v4.3.pdf
// If signaled, a Supplemental or Essential Property descriptor
// shall be used, with the schemeIdUri set to
// urn:mpeg:mpegB:cicp:<Parameter> as defined in
// ISO/IEC 23001-8 [49] and <Parameter> one of the
// following: ColourPrimaries, TransferCharacteristics,
// or MatrixCoefficients.
const scheme = 'urn:mpeg:mpegB:cicp';
const transferCharacteristicsScheme = `${scheme}:TransferCharacteristics`;
const colourPrimariesScheme = `${scheme}:ColourPrimaries`;
const matrixCoefficientsScheme = `${scheme}:MatrixCoefficients`;
const getVideoRangeFromTransferCharacteristicCICP = (cicp) => {
switch (cicp) {
case 1:
case 6:
case 13:
case 14:
case 15:
return 'SDR';
case 16:
return 'PQ';
case 18:
return 'HLG';
}
return undefined;
};
const getColorGamutFromColourPrimariesCICP = (cicp) => {
switch (cicp) {
case 1:
case 5:
case 6:
case 7:
return 'srgb';
case 9:
return 'rec2020';
case 11:
case 12:
return 'p3';
}
return undefined;
};
const parseFont = (prop) => {
const fontFamily = prop.attributes['dvb:fontFamily'];
const fontUrl = prop.attributes['dvb:url'];
if (fontFamily && fontUrl) {
const uris = shaka.util.ManifestParserUtils.resolveUris(
context.adaptationSet.getBaseUris(), [fontUrl],
context.urlParams());
this.playerInterface_.addFont(fontFamily, uris[0]);
}
};
const essentialProperties =
TXml.findChildren(elem, 'EssentialProperty');
// ID of real AdaptationSet if this is a trick mode set:
let trickModeFor = null;
let isFastSwitching = false;
let adaptationSetUrlParams = null;
let unrecognizedEssentialProperty = false;
for (const prop of essentialProperties) {
const schemeId = prop.attributes['schemeIdUri'];
if (schemeId == 'http://dashif.org/guidelines/trickmode') {
trickModeFor = prop.attributes['value'];
} else if (schemeId == transferCharacteristicsScheme) {
videoRange = getVideoRangeFromTransferCharacteristicCICP(
parseInt(prop.attributes['value'], 10),
);
} else if (schemeId == colourPrimariesScheme) {
colorGamut = getColorGamutFromColourPrimariesCICP(
parseInt(prop.attributes['value'], 10),
);
} else if (schemeId == matrixCoefficientsScheme) {
continue;
} else if (schemeId == 'urn:mpeg:dash:ssr:2023' &&
this.config_.dash.enableFastSwitching) {
isFastSwitching = true;
} else if (schemeId == 'urn:dvb:dash:fontdownload:2014') {
parseFont(prop);
} else if (schemeId == 'urn:mpeg:dash:urlparam:2014') {
adaptationSetUrlParams = this.getURLParametersFunction_(prop);
if (!adaptationSetUrlParams) {
unrecognizedEssentialProperty = true;
}
} else {
unrecognizedEssentialProperty = true;
}
}
// According to DASH spec (2014) section 5.8.4.8, "the successful processing
// of the descriptor is essential to properly use the information in the
// parent element". According to DASH IOP v3.3, section 3.3.4, "if the
// scheme or the value" for EssentialProperty is not recognized, "the DASH
// client shall ignore the parent element."
if (unrecognizedEssentialProperty) {
// Stop parsing this AdaptationSet and let the caller filter out the
// nulls.
return null;
}
let lastSegmentNumber = null;
const supplementalProperties =
TXml.findChildren(elem, 'SupplementalProperty');
for (const prop of supplementalProperties) {
const schemeId = prop.attributes['schemeIdUri'];
if (schemeId == 'http://dashif.org/guidelines/last-segment-number') {
lastSegmentNumber = parseInt(prop.attributes['value'], 10) - 1;
} else if (schemeId == transferCharacteristicsScheme) {
videoRange = getVideoRangeFromTransferCharacteristicCICP(
parseInt(prop.attributes['value'], 10),
);
} else if (schemeId == colourPrimariesScheme) {
colorGamut = getColorGamutFromColourPrimariesCICP(
parseInt(prop.attributes['value'], 10),
);
} else if (schemeId == 'urn:dvb:dash:fontdownload:2014') {
parseFont(prop);
} else if (schemeId == 'urn:mpeg:dash:urlparam:2014') {
adaptationSetUrlParams = this.getURLParametersFunction_(prop);
}
}
if (adaptationSetUrlParams) {
context.urlParams = adaptationSetUrlParams;
}
const accessibilities = TXml.findChildren(elem, 'Accessibility');
const LanguageUtils = shaka.util.LanguageUtils;
const closedCaptions = new Map();
/** @type {?shaka.media.ManifestParser.AccessibilityPurpose} */
let accessibilityPurpose;
for (const prop of accessibilities) {
const schemeId = prop.attributes['schemeIdUri'];
const value = prop.attributes['value'];
if (schemeId == 'urn:scte:dash:cc:cea-608:2015' &&
!this.config_.disableText) {
let channelId = 1;
if (value != null) {
const channelAssignments = value.split(';');
for (const captionStr of channelAssignments) {
let channel;
let language;
// Some closed caption descriptions have channel number and
// language ("CC1=eng") others may only have language ("eng,spa").
if (!captionStr.includes('=')) {
// When the channel assignments are not explicitly provided and
// there are only 2 values provided, it is highly likely that the
// assignments are CC1 and CC3 (most commonly used CC streams).
// Otherwise, cycle through all channels arbitrarily (CC1 - CC4)
// in order of provided langs.
channel = `CC${channelId}`;
if (channelAssignments.length == 2) {
channelId += 2;
} else {
channelId ++;
}
language = captionStr;
} else {
const channelAndLanguage = captionStr.split('=');
// The channel info can be '1' or 'CC1'.
// If the channel info only has channel number(like '1'), add 'CC'
// as prefix so that it can be a full channel id (like 'CC1').
channel = channelAndLanguage[0].startsWith('CC') ?
channelAndLanguage[0] : `CC${channelAndLanguage[0]}`;
// 3 letters (ISO 639-2). In b/187442669, we saw a blank string
// (CC2=;CC3=), so default to "und" (the code for "undetermined").
language = channelAndLanguage[1] || 'und';
}
closedCaptions.set(channel, LanguageUtils.normalize(language));
}
} else {
// If channel and language information has not been provided, assign
// 'CC1' as channel id and 'und' as language info.
closedCaptions.set('CC1', 'und');
}
} else if (schemeId == 'urn:scte:dash:cc:cea-708:2015' &&
!this.config_.disableText) {
let serviceNumber = 1;
if (value != null) {
for (const captionStr of value.split(';')) {
let service;
let language;
// Similar to CEA-608, it is possible that service # assignments
// are not explicitly provided e.g. "eng;deu;swe" In this case,
// we just cycle through the services for each language one by one.
if (!captionStr.includes('=')) {
service = `svc${serviceNumber}`;
serviceNumber ++;
language = captionStr;
} else {
// Otherwise, CEA-708 caption values take the form "
// 1=lang:eng;2=lang:deu" i.e. serviceNumber=lang:threeLetterCode.
const serviceAndLanguage = captionStr.split('=');
service = `svc${serviceAndLanguage[0]}`;
// The language info can be different formats, lang:eng',
// or 'lang:eng,war:1,er:1'. Extract the language info.
language = serviceAndLanguage[1].split(',')[0].split(':').pop();
}
closedCaptions.set(service, LanguageUtils.normalize(language));
}
} else {
// If service and language information has not been provided, assign
// 'svc1' as service number and 'und' as language info.
closedCaptions.set('svc1', 'und');
}
} else if (schemeId == 'urn:mpeg:dash:role:2011') {
// See DASH IOP 3.9.2 Table 4.
if (value != null) {
roleValues.push(value);
if (value == 'captions') {
kind = ManifestParserUtils.TextStreamKind.CLOSED_CAPTION;
}
}
} else if (schemeId == 'urn:tva:metadata:cs:AudioPurposeCS:2007') {
// See DASH DVB Document A168 Rev.6 Table 5.
if (value == '1') {
accessibilityPurpose =
shaka.media.ManifestParser.AccessibilityPurpose.VISUALLY_IMPAIRED;
} else if (value == '2') {
accessibilityPurpose =
shaka.media.ManifestParser.AccessibilityPurpose.HARD_OF_HEARING;
}
}
}
const contentProtectionElements =
TXml.findChildren(elem, 'ContentProtection');
const contentProtection = ContentProtection.parseFromAdaptationSet(
contentProtectionElements,
this.config_.ignoreDrmInfo,
this.config_.dash.keySystemsByURI);
// We us contentProtectionElements instead of drmInfos as the latter is
// not populated yet, and we need the encrypted flag for the upcoming
// parseRepresentation that will set the encrypted flag to the init seg.
context.adaptationSet.encrypted = contentProtectionElements.length > 0;
const language = shaka.util.LanguageUtils.normalize(
context.adaptationSet.language || 'und');
const label = context.adaptationSet.label;
/** @type {!Map<string, shaka.extern.Stream>} */
const dependencyStreamMap = new Map();
// Parse Representations into Streams.
const representations = TXml.findChildren(elem, 'Representation');
if (!this.config_.ignoreSupplementalCodecs) {
const supplementalRepresentations = [];
for (const rep of representations) {
const supplementalCodecs = TXml.getAttributeNS(
rep, shaka.dash.DashParser.SCTE214_, 'supplementalCodecs');
if (supplementalCodecs) {
// Duplicate representations with their supplementalCodecs
const obj = shaka.util.ObjectUtils.cloneObject(rep);
obj.attributes['codecs'] = supplementalCodecs.split(' ').join(',');
if (obj.attributes['id']) {
obj.attributes['supplementalId'] =
obj.attributes['id'] + '_supplementalCodecs';
}
supplementalRepresentations.push(obj);
}
}
representations.push(...supplementalRepresentations);
}
const streams = representations.map((representation) => {
const parsedRepresentation = this.parseRepresentation_(context,
contentProtection, kind, language, label, main, roleValues,
closedCaptions, representation, accessibilityPurpose,
lastSegmentNumber);
if (parsedRepresentation) {
parsedRepresentation.hdr = parsedRepresentation.hdr || videoRange;
parsedRepresentation.colorGamut =
parsedRepresentation.colorGamut || colorGamut;
parsedRepresentation.fastSwitching = isFastSwitching;
const dependencyId = representation.attributes['dependencyId'];
if (dependencyId) {
parsedRepresentation.baseOriginalId = dependencyId;
dependencyStreamMap.set(dependencyId, parsedRepresentation);
return null;
}
}
return parsedRepresentation;
}).filter((s) => !!s);
if (streams.length == 0 && dependencyStreamMap.size == 0) {
const isImage = context.adaptationSet.contentType == ContentType.IMAGE;
// Ignore empty AdaptationSets if ignoreEmptyAdaptationSet is true
// or they are for text/image content.
if (this.config_.dash.ignoreEmptyAdaptationSet || isText || isImage) {
return null;
}
throw new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.MANIFEST,
shaka.util.Error.Code.DASH_EMPTY_ADAPTATION_SET);
}
// If AdaptationSet's type is unknown or is ambiguously "application",
// guess based on the information in the first stream. If the attributes
// mimeType and codecs are split across levels, they will both be inherited
// down to the stream level by this point, so the stream will have all the
// necessary information.
if (!context.adaptationSet.contentType ||
context.adaptationSet.contentType == ContentType.APPLICATION) {
const mimeType = streams[0].mimeType;
const codecs = streams[0].codecs;
context.adaptationSet.contentType =
shaka.dash.DashParser.guessContentType_(mimeType, codecs);
for (const stream of streams) {
stream.type = context.adaptationSet.contentType;
}
}
const adaptationId = context.adaptationSet.id ||
('__fake__' + this.globalId_++);
for (const stream of streams) {
// Some DRM license providers require that we have a default
// key ID from the manifest in the wrapped license request.
// Thus, it should be put in drmInfo to be accessible to request filters.
for (const drmInfo of contentProtection.drmInfos) {
drmInfo.keyIds = drmInfo.keyIds && stream.keyIds ?
new Set([...drmInfo.keyIds, ...stream.keyIds]) :
drmInfo.keyIds || stream.keyIds;
}
stream.groupId = adaptationId;
}
const repIds = representations
.map((node) => {
return node.attributes['supplementalId'] || node.attributes['id'];
}).filter(shaka.util.Functional.isNotNull);
return {
id: adaptationId,
contentType: context.adaptationSet.contentType,
language: language,
main: main,
streams: streams,
drmInfos: contentProtection.drmInfos,
trickModeFor: trickModeFor,
representationIds: repIds,
dependencyStreamMap,
};
}
/**
* @param {!shaka.extern.xml.Node} elem
* @return {?function():string}
* @private
*/
getURLParametersFunction_(elem) {
const TXml = shaka.util.TXml;
const urlQueryInfo = TXml.findChildNS(
elem, shaka.dash.DashParser.UP_NAMESPACE_, 'UrlQueryInfo');
if (urlQueryInfo && TXml.parseAttr(urlQueryInfo, 'useMPDUrlQuery',
TXml.parseBoolean, /* defaultValue= */ false)) {
const queryTemplate = urlQueryInfo.attributes['queryTemplate'];
if (queryTemplate) {
return () => {
if (queryTemplate == '$querypart$') {
return this.lastManifestQueryParams_;
}
const parameters = queryTemplate.split('&').map((param) => {
if (param == '$querypart$') {
return this.lastManifestQueryParams_;
} else {
const regex = /\$query:(.*?)\$/g;
const parts = regex.exec(param);
if (parts && parts.length == 2) {
const paramName = parts[1];
const queryData =
new goog.Uri.QueryData(this.lastManifestQueryParams_);
const value = queryData.get(paramName);
if (value.length) {
return paramName + '=' + value[0];
}
}
return param;
}
});
return parameters.join('&');
};
}
}
return null;
}
/**
* Parses a Representation XML element.
*
* @param {shaka.dash.DashParser.Context} context
* @param {shaka.dash.ContentProtection.Context} contentProtection
* @param {(string|undefined)} kind
* @param {string} language
* @param {?string} label
* @param {boolean} isPrimary
* @param {!Array<string>} roles
* @param {Map<string, string>} closedCaptions
* @param {!shaka.extern.xml.Node} node
* @param {?shaka.media.ManifestParser.AccessibilityPurpose
* } accessibilityPurpose
* @param {?number} lastSegmentNumber
*
* @return {?shaka.extern.Stream} The Stream, or null when there is a
* non-critical parsing error.
* @private
*/
parseRepresentation_(context, contentProtection, kind, language, label,
isPrimary, roles, closedCaptions, node, accessibilityPurpose,
lastSegmentNumber) {
const TXml = shaka.util.TXml;
const ContentType = shaka.util.ManifestParserUtils.ContentType;
context.representation =
this.createFrame_(node, context.adaptationSet, null);
const representationId = context.representation.id;
this.minTotalAvailabilityTimeOffset_ =
Math.min(this.minTotalAvailabilityTimeOffset_,
context.representation.availabilityTimeOffset);
this.isLowLatency_ = this.minTotalAvailabilityTimeOffset_ > 0;
if (!this.verifyRepresentation_(context.representation)) {
shaka.log.warning('Skipping Representation', context.representation);
return null;
}
const periodStart = context.periodInfo.start;
// NOTE: bandwidth is a mandatory attribute according to the spec, and zero
// does not make sense in the DASH spec's bandwidth formulas.
// In some content, however, the attribute is missing or zero.
// To avoid NaN at the variant level on broken content, fall back to zero.
// https://github.com/shaka-project/shaka-player/issues/938#issuecomment-317278180
context.bandwidth =
TXml.parseAttr(node, 'bandwidth', TXml.parsePositiveInt) || 0;
context.roles = roles;
const supplementalPropertyElements =
TXml.findChildren(node, 'SupplementalProperty');
const essentialPropertyElements =
TXml.findChildren(node, 'EssentialProperty');
const contentProtectionElements =
TXml.findChildren(node, 'ContentProtection');
let representationUrlParams = null;
let urlParamsElement = essentialPropertyElements.find((element) => {
const schemeId = element.attributes['schemeIdUri'];
return schemeId == 'urn:mpeg:dash:urlparam:2014';
});
if (urlParamsElement) {
representationUrlParams =
this.getURLParametersFunction_(urlParamsElement);
} else {
urlParamsElement = supplementalPropertyElements.find((element) => {
const schemeId = element.attributes['schemeIdUri'];
return schemeId == 'urn:mpeg:dash:urlparam:2014';
});
if (urlParamsElement) {
representationUrlParams =
this.getURLParametersFunction_(urlParamsElement);
}
}
if (representationUrlParams) {
context.urlParams = representationUrlParams;
}
/** @type {?shaka.dash.DashParser.StreamInfo} */
let streamInfo;
const contentType = context.representation.contentType;
const isText = contentType == ContentType.TEXT ||
contentType == ContentType.APPLICATION;
const isImage = contentType == ContentType.IMAGE;
if (contentProtectionElements.length) {
context.adaptationSet.encrypted = true;
}
try {
/** @type {shaka.extern.aesKey|undefined} */
let aesKey = undefined;
if (contentProtection.aes128Info) {
const getBaseUris = context.representation.getBaseUris;
const urlParams = context.urlParams;
const uris = shaka.util.ManifestParserUtils.resolveUris(
getBaseUris(), [contentProtection.aes128Info.keyUri], urlParams());
const requestType = shaka.net.NetworkingEngine.RequestType.KEY;
const request = shaka.net.NetworkingEngine.makeRequest(
uris, this.config_.retryParameters);
aesKey = {
bitsKey: 128,
blockCipherMode: 'CBC',
iv: contentProtection.aes128Info.iv,
firstMediaSequenceNumber: 0,
};
// Don't download the key object until the segment is parsed, to
// avoid a startup delay for long manifests with lots of keys.
aesKey.fetchKey = async () => {
const keyResponse =
await this.makeNetworkRequest_(request, requestType);
// keyResponse.status is undefined when URI is
// "data:text/plain;base64,"
if (!keyResponse.data || keyResponse.data.byteLength != 16) {
throw new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.MANIFEST,
shaka.util.Error.Code.AES_128_INVALID_KEY_LENGTH);
}
const algorithm = {
name: 'AES-CBC',
};
aesKey.cryptoKey = await window.crypto.subtle.importKey(
'raw', keyResponse.data, algorithm, true, ['decrypt']);
aesKey.fetchKey = undefined; // No longer needed.
};
}
context.representation.aesKey = aesKey;
const requestSegment = (uris, startByte, endByte, isInit) => {
return this.requestSegment_(uris, startByte, endByte, isInit);
};
if (context.representation.segmentBase) {
streamInfo = shaka.dash.SegmentBase.createStreamInfo(
context, requestSegment, aesKey);
} else if (context.representation.segmentList) {
streamInfo = shaka.dash.SegmentList.createStreamInfo(
context, this.streamMap_, aesKey);
} else if (context.representation.segmentTemplate) {
const hasManifest = !!this.manifest_;
streamInfo = shaka.dash.SegmentTemplate.createStreamInfo(
context, requestSegment, this.streamMap_, hasManifest,
this.config_.dash.initialSegmentLimit, this.periodDurations_,
aesKey, lastSegmentNumber, /* isPatchUpdate= */ false);
} else {
goog.asserts.assert(isText,
'Must have Segment* with non-text streams.');
const duration = context.periodInfo.duration || 0;
const getBaseUris = context.representation.getBaseUris;
const mimeType = context.representation.mimeType;
const codecs = context.representation.codecs;
streamInfo = {
generateSegmentIndex: () => {
const segmentIndex = shaka.media.SegmentIndex.forSingleSegment(
periodStart, duration, getBaseUris());
segmentIndex.forEachTopLevelReference((ref) => {
ref.mimeType = mimeType;
ref.codecs = codecs;
});
return Promise.resolve(segmentIndex);
},
};
}
} catch (error) {
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
// streams.
return null;
}
// For anything else, re-throw.
throw error;
}
const keyId = shaka.dash.ContentProtection.parseFromRepresentation(
contentProtectionElements, contentProtection,
this.config_.ignoreDrmInfo,
this.config_.dash.keySystemsByURI);
const keyIds = new Set(keyId ? [keyId] : []);
// Detect the presence of E-AC3 JOC audio content, using DD+JOC signaling.
// See: ETSI TS 103 420 V1.2.1 (2018-10)
const hasJoc = supplementalPropertyElements.some((element) => {
const expectedUri = 'tag:dolby.com,2018:dash:EC3_ExtensionType:2018';
const expectedValue = 'JOC';
return element.attributes['schemeIdUri'] == expectedUri &&
element.attributes['value'] == expectedValue;
});
let spatialAudio = false;
if (hasJoc) {
spatialAudio = true;
}
let forced = false;
if (isText) {
// See: https://github.com/shaka-project/shaka-player/issues/2122 and
// https://github.com/Dash-Industry-Forum/DASH-IF-IOP/issues/165
forced = roles.includes('forced_subtitle') ||
roles.includes('forced-subtitle');
}
let tilesLayout;
if (isImage) {
const thumbnailTileElem = essentialPropertyElements.find((element) => {
const expectedUris = [
'http://dashif.org/thumbnail_tile',
'http://dashif.org/guidelines/thumbnail_tile',
];
return expectedUris.includes(element.attributes['schemeIdUri']);
});
if (thumbnailTileElem) {
tilesLayout = thumbnailTileElem.attributes['value'];
}
// Filter image adaptation sets that has no tilesLayout.
if (!tilesLayout) {
return null;
}
}
let hdr;
const profiles = context.profiles;
const codecs = context.representation.codecs;
const hevcHDR = 'http://dashif.org/guidelines/dash-if-uhd#hevc-hdr-pq10';
if (profiles.includes(hevcHDR) && (codecs.includes('hvc1.2.4.L153.B0') ||
codecs.includes('hev1.2.4.L153.B0'))) {
hdr = 'PQ';
}
const contextId = context.representation.id ?
context.period.id + ',' + context.representation.id : '';
if (this.patchLocationNodes_.length && representationId) {
this.contextCache_.set(`${context.period.id},${representationId}`,
this.cloneContext_(context));
}
/** @type {shaka.extern.Stream} */
let stream;
if (contextId && this.streamMap_.has(contextId)) {
stream = this.streamMap_.get(contextId);
} else {
stream = {
id: this.globalId_++,
originalId: context.representation.id,
groupId: null,
createSegmentIndex: () => Promise.resolve(),
closeSegmentIndex: () => {
if (stream.segmentIndex) {
stream.segmentIndex.release();
stream.segmentIndex = null;
}
},
segmentIndex: null,
mimeType: context.representation.mimeType,
codecs,
frameRate: context.representation.frameRate,
pixelAspectRatio: context.representation.pixelAspectRatio,
bandwidth: context.bandwidth,
width: context.representation.width,
height: context.representation.height,
kind,
encrypted: contentProtection.drmInfos.length > 0,
drmInfos: contentProtection.drmInfos,
keyIds,
language,
originalLanguage: context.adaptationSet.language,
label,
type: context.adaptationSet.contentType,
primary: isPrimary,
trickModeVideo: null,
dependencyStream: null,
emsgSchemeIdUris:
context.representation.emsgSchemeIdUris,
roles,
forced,
channelsCount: context.representation.numChannels,
audioSamplingRate: context.representation.audioSamplingRate,
spatialAudio,
closedCaptions,
hdr,
colorGamut: undefined,
videoLayout: undefined,
tilesLayout,
accessibilityPurpose,
external: false,
fastSwitching: false,
fullMimeTypes: new Set([shaka.util.MimeUtils.getFullType(
context.representation.mimeType, context.representation.codecs)]),
isAudioMuxedInVideo: false,
baseOriginalId: null,
};
}
stream.createSegmentIndex = async () => {
if (!stream.segmentIndex) {
stream.segmentIndex = await streamInfo.generateSegmentIndex();
}
};
if (contextId && context.dynamic && !this.streamMap_.has(contextId)) {
const periodId = context.period.id || '';
if (!this.indexStreamMap_.has(periodId)) {
this.indexStreamMap_.set(periodId, []);
}
this.streamMap_.set(contextId, stream);
this.indexStreamMap_.get(periodId).push(contextId);
}
return stream;
}
/**
* Clone context and remove xml document references.
*
* @param {!shaka.dash.DashParser.Context} context
* @return {!shaka.dash.DashParser.Context}
* @private
*/
cloneContext_(context) {
/**
* @param {?shaka.dash.DashParser.InheritanceFrame} frame
* @return {?shaka.dash.DashParser.InheritanceFrame}
*/
const cloneFrame = (frame) => {
if (!frame) {
return null;
}
const clone = shaka.util.ObjectUtils.shallowCloneObject(frame);
clone.segmentBase = null;
clone.segmentList = null;
clone.segmentTemplate = shaka.util.TXml.cloneNode(clone.segmentTemplate);
return clone;
};
const contextClone = shaka.util.ObjectUtils.shallowCloneObject(context);
contextClone.period = cloneFrame(contextClone.period);
contextClone.adaptationSet = cloneFrame(contextClone.adaptationSet);
contextClone.representation = cloneFrame(contextClone.representation);
if (contextClone.periodInfo) {
contextClone.periodInfo =
shaka.util.ObjectUtils.shallowCloneObject(contextClone.periodInfo);
contextClone.periodInfo.node = null;
}
return contextClone;
}
/**
* Called when the update timer ticks.
*
* @return {!Promise}
* @private
*/
async onUpdate_() {
goog.asserts.assert(this.updatePeriod_ >= 0,
'There should be an update period');
shaka.log.info('Updating manifest...');
// Default the update delay to 0 seconds so that if there is an error we can
// try again right away.
let updateDelay = 0;
try {
updateDelay = await this.requestManifest_();
} catch (error) {
goog.asserts.assert(error instanceof shaka.util.Error,
'Should only receive a Shaka error');
// Try updating again, but ensure we haven't been destroyed.
if (this.playerInterface_) {
if (this.config_.raiseFatalErrorOnManifestUpdateRequestFailure) {
this.playerInterface_.onError(error);
return;
}
// We will retry updating, so override the severity of the error.
error.severity = shaka.util.Error.Severity.RECOVERABLE;
this.playerInterface_.onError(error);
}
}
// Detect a call to stop()
if (!this.playerInterface_) {
return;
}
this.playerInterface_.onManifestUpdated();
this.setUpdateTimer_(updateDelay);
}
/**
* Update now the manifest
*
* @private
*/
updateNow_() {
this.updateTimer_.tickNow();
}
/**
* Sets the update timer. Does nothing if the manifest does not specify an
* update period.
*
* @param {number} offset An offset, in seconds, to apply to the manifest's
* update period.
* @private
*/
setUpdateTimer_(offset) {
// NOTE: An updatePeriod_ of -1 means the attribute was missing.
// An attribute which is present and set to 0 should still result in
// periodic updates. For more, see:
// https://github.com/Dash-Industry-Forum/Guidelines-TimingModel/issues/48
if (this.updatePeriod_ < 0) {
return;
}
let updateTime = this.updatePeriod_;
if (this.config_.updatePeriod >= 0) {
updateTime = this.config_.updatePeriod;
}
const finalDelay = Math.max(
updateTime - offset,
this.averageUpdateDuration_.getEstimate());
// We do not run the timer as repeating because part of update is async and
// we need schedule the update after it finished.
this.updateTimer_.tickAfter(/* seconds= */ finalDelay);
}
/**
* Creates a new inheritance frame for the given element.
*
* @param {!shaka.extern.xml.Node} elem
* @param {?shaka.dash.DashParser.InheritanceFrame} parent
* @param {?function(): !Array<string>} getBaseUris
* @return {shaka.dash.DashParser.InheritanceFrame}
* @private
*/
createFrame_(elem, parent, getBaseUris) {
goog.asserts.assert(parent || getBaseUris,
'Must provide either parent or getBaseUris');
const SegmentUtils = shaka.media.SegmentUtils;
const ManifestParserUtils = shaka.util.ManifestParserUtils;
const TXml = shaka.util.TXml;
parent = parent || /** @type {shaka.dash.DashParser.InheritanceFrame} */ ({
contentType: '',
mimeType: '',
codecs: '',
emsgSchemeIdUris: [],
frameRate: undefined,
pixelAspectRatio: undefined,
numChannels: null,
audioSamplingRate: null,
availabilityTimeOffset: 0,
segmentSequenceCadence: 0,
encrypted: false,
});
getBaseUris = getBaseUris || parent.getBaseUris;
const parseNumber = TXml.parseNonNegativeInt;
const evalDivision = TXml.evalDivision;
const id = elem.attributes['id'];
const supplementalId = elem.attributes['supplementalId'];
const uriObjs = TXml.findChildren(elem, 'BaseURL');
let calculatedBaseUris;
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(
id, serviceLocation, uri);
someLocationValid = true;
}
}
}
if (!someLocationValid || !this.contentSteeringManager_) {
calculatedBaseUris = uriObjs.map(TXml.getContents);
}
const getFrameUris = () => {
if (!uriObjs.length) {
return [];
}
if (this.contentSteeringManager_ && someLocationValid) {
return this.contentSteeringManager_.getLocations(id);
}
if (calculatedBaseUris) {
return calculatedBaseUris;
}
return [];
};
let contentType = elem.attributes['contentType'] || parent.contentType;
const mimeType = elem.attributes['mimeType'] || parent.mimeType;
const allCodecs = [
elem.attributes['codecs'] || parent.codecs,
];
const codecs = SegmentUtils.codecsFiltering(allCodecs).join(',');
const frameRate =
TXml.parseAttr(elem, 'frameRate', evalDivision) || parent.frameRate;
const pixelAspectRatio =
elem.attributes['sar'] || parent.pixelAspectRatio;
const emsgSchemeIdUris = this.emsgSchemeIdUris_(
TXml.findChildren(elem, 'InbandEventStream'),
parent.emsgSchemeIdUris);
const audioChannelConfigs =
TXml.findChildren(elem, 'AudioChannelConfiguration');
const numChannels =
this.parseAudioChannels_(audioChannelConfigs) || parent.numChannels;
const audioSamplingRate =
TXml.parseAttr(elem, 'audioSamplingRate', parseNumber) ||
parent.audioSamplingRate;
if (!contentType) {
contentType = shaka.dash.DashParser.guessContentType_(mimeType, codecs);
}
const segmentBase = TXml.findChild(elem, 'SegmentBase');
const segmentTemplate = TXml.findChild(elem, 'SegmentTemplate');
// The availabilityTimeOffset is the sum of all @availabilityTimeOffset
// values that apply to the adaptation set, via BaseURL, SegmentBase,
// or SegmentTemplate elements.
const segmentBaseAto = segmentBase ?
(TXml.parseAttr(segmentBase, 'availabilityTimeOffset',
TXml.parseFloat) || 0) : 0;
const segmentTemplateAto = segmentTemplate ?
(TXml.parseAttr(segmentTemplate, 'availabilityTimeOffset',
TXml.parseFloat) || 0) : 0;
const baseUriAto = uriObjs && uriObjs.length ?
(TXml.parseAttr(uriObjs[0], 'availabilityTimeOffset',
TXml.parseFloat) || 0) : 0;
const availabilityTimeOffset = parent.availabilityTimeOffset + baseUriAto +
segmentBaseAto + segmentTemplateAto;
let segmentSequenceCadence = null;
const segmentSequenceProperties =
TXml.findChild(elem, 'SegmentSequenceProperties');
if (segmentSequenceProperties) {
const sap = TXml.findChild(segmentSequenceProperties, 'SAP');
if (sap) {
segmentSequenceCadence = TXml.parseAttr(sap, 'cadence',
TXml.parseInt);
}
}
// This attribute is currently non-standard, but it is supported by Kaltura.
let label = elem.attributes['label'];
// See DASH IOP 4.3 here https://dashif.org/docs/DASH-IF-IOP-v4.3.pdf (page 35)
const labelElements = TXml.findChildren(elem, 'Label');
if (labelElements && labelElements.length) {
// NOTE: Right now only one label field is supported.
const firstLabelElement = labelElements[0];
if (TXml.getTextContents(firstLabelElement)) {
label = TXml.getTextContents(firstLabelElement);
}
}
return {
getBaseUris:
() => ManifestParserUtils.resolveUris(getBaseUris(), getFrameUris()),
segmentBase: segmentBase || parent.segmentBase,
segmentList:
TXml.findChild(elem, 'SegmentList') || parent.segmentList,
segmentTemplate: segmentTemplate || parent.segmentTemplate,
width: TXml.parseAttr(elem, 'width', parseNumber) || parent.width,
height: TXml.parseAttr(elem, 'height', parseNumber) || parent.height,
contentType: contentType,
mimeType: mimeType,
codecs: codecs,
frameRate: frameRate,
pixelAspectRatio: pixelAspectRatio,
emsgSchemeIdUris: emsgSchemeIdUris,
id: supplementalId || id,
originalId: id,
language: elem.attributes['lang'],
numChannels: numChannels,
audioSamplingRate: audioSamplingRate,
availabilityTimeOffset: availabilityTimeOffset,
initialization: null,
segmentSequenceCadence:
segmentSequenceCadence || parent.segmentSequenceCadence,
label: label || null,
encrypted: false,
};
}
/**
* Returns a new array of InbandEventStream schemeIdUri containing the union
* of the ones parsed from inBandEventStreams and the ones provided in
* emsgSchemeIdUris.
*
* @param {!Array<!shaka.extern.xml.Node>} inBandEventStreams
* Array of InbandEventStream
* elements to parse and add to the returned array.
* @param {!Array<string>} emsgSchemeIdUris Array of parsed
* InbandEventStream schemeIdUri attributes to add to the returned array.
* @return {!Array<string>} schemeIdUris Array of parsed
* InbandEventStream schemeIdUri attributes.
* @private
*/
emsgSchemeIdUris_(inBandEventStreams, emsgSchemeIdUris) {
const schemeIdUris = emsgSchemeIdUris.slice();
for (const event of inBandEventStreams) {
const schemeIdUri = event.attributes['schemeIdUri'];
if (!schemeIdUris.includes(schemeIdUri)) {
schemeIdUris.push(schemeIdUri);
}
}
return schemeIdUris;
}
/**
* @param {!Array<!shaka.extern.xml.Node>} audioChannelConfigs An array of
* AudioChannelConfiguration elements.
* @return {?number} The number of audio channels, or null if unknown.
* @private
*/
parseAudioChannels_(audioChannelConfigs) {
for (const elem of audioChannelConfigs) {
const scheme = elem.attributes['schemeIdUri'];
if (!scheme) {
continue;
}
const value = elem.attributes['value'];
if (!value) {
continue;
}
switch (scheme) {
case 'urn:mpeg:dash:outputChannelPositionList:2012':
// A space-separated list of speaker positions, so the number of
// channels is the length of this list.
return value.trim().split(/ +/).length;
case 'urn:mpeg:dash:23003:3:audio_channel_configuration:2011':
case 'urn:dts:dash:audio_channel_configuration:2012': {
// As far as we can tell, this is a number of channels.
const intValue = parseInt(value, 10);
if (!intValue) { // 0 or NaN
shaka.log.warning('Channel parsing failure! ' +
'Ignoring scheme and value', scheme, value);
continue;
}
return intValue;
}
case 'tag:dolby.com,2015:dash:audio_channel_configuration:2015': {
// ETSI TS 103 190-2 v1.2.1, Annex G.3
// LSB-to-MSB order
const channelCountMapping =
[2, 1, 2, 2, 2, 2, 1, 2, 2, 1, 1, 1, 1, 2, 1, 1, 2, 2];
const hexValue = parseInt(value, 16);
if (!hexValue) { // 0 or NaN
shaka.log.warning('Channel parsing failure! ' +
'Ignoring scheme and value', scheme, value);
continue;
}
let numBits = 0;
for (let i = 0; i < channelCountMapping.length; i++) {
if (hexValue & (1<<i)) {
numBits += channelCountMapping[i];
}
}
if (numBits) {
return numBits;
}
continue;
}
case 'tag:dolby.com,2014:dash:audio_channel_configuration:2011':
case 'urn:dolby:dash:audio_channel_configuration:2011': {
// Defined by https://ott.dolby.com/OnDelKits/DDP/Dolby_Digital_Plus_Online_Delivery_Kit_v1.5/Documentation/Content_Creation/SDM/help_files/topics/ddp_mpeg_dash_c_mpd_auchlconfig.html
// keep list in order of the spec; reverse for LSB-to-MSB order
const channelCountMapping =
[1, 1, 1, 1, 1, 2, 2, 1, 1, 2, 2, 2, 1, 2, 1, 1].reverse();
const hexValue = parseInt(value, 16);
if (!hexValue) { // 0 or NaN
shaka.log.warning('Channel parsing failure! ' +
'Ignoring scheme and value', scheme, value);
continue;
}
let numBits = 0;
for (let i = 0; i < channelCountMapping.length; i++) {
if (hexValue & (1<<i)) {
numBits += channelCountMapping[i];
}
}
if (numBits) {
return numBits;
}
continue;
}
// Defined by https://dashif.org/identifiers/audio_source_metadata/ and clause 8.2, in ISO/IEC 23001-8.
case 'urn:mpeg:mpegB:cicp:ChannelConfiguration': {
const noValue = 0;
const channelCountMapping = [
noValue, 1, 2, 3, 4, 5, 6, 8, 2, 3, /* 0--9 */
4, 7, 8, 24, 8, 12, 10, 12, 14, 12, /* 10--19 */
14, /* 20 */
];
const intValue = parseInt(value, 10);
if (!intValue) { // 0 or NaN
shaka.log.warning('Channel parsing failure! ' +
'Ignoring scheme and value', scheme, value);
continue;
}
if (intValue > noValue && intValue < channelCountMapping.length) {
return channelCountMapping[intValue];
}
continue;
}
default:
shaka.log.warning(
'Unrecognized audio channel scheme:', scheme, value);
continue;
}
}
return null;
}
/**
* Verifies that a Representation has exactly one Segment* element. Prints
* warnings if there is a problem.
*
* @param {shaka.dash.DashParser.InheritanceFrame} frame
* @return {boolean} True if the Representation is usable; otherwise return
* false.
* @private
*/
verifyRepresentation_(frame) {
const ContentType = shaka.util.ManifestParserUtils.ContentType;
let n = 0;
n += frame.segmentBase ? 1 : 0;
n += frame.segmentList ? 1 : 0;
n += frame.segmentTemplate ? 1 : 0;
if (n == 0) {
// TODO: Extend with the list of MIME types registered to TextEngine.
if (frame.contentType == ContentType.TEXT ||
frame.contentType == ContentType.APPLICATION) {
return true;
} else {
shaka.log.warning(
'Representation does not contain a segment information source:',
'the Representation must contain one of SegmentBase, SegmentList,',
'SegmentTemplate, or explicitly indicate that it is "text".',
frame);
return false;
}
}
if (n != 1) {
shaka.log.warning(
'Representation contains multiple segment information sources:',
'the Representation should only contain one of SegmentBase,',
'SegmentList, or SegmentTemplate.',
frame);
if (frame.segmentBase) {
shaka.log.info('Using SegmentBase by default.');
frame.segmentList = null;
frame.segmentTemplate = null;
} else {
goog.asserts.assert(frame.segmentList, 'There should be a SegmentList');
shaka.log.info('Using SegmentList by default.');
frame.segmentTemplate = null;
}
}
return true;
}
/**
* Makes a request to the given URI and calculates the clock offset.
*
* @param {function(): !Array<string>} getBaseUris
* @param {string} uri
* @param {string} method
* @return {!Promise<number>}
* @private
*/
async requestForTiming_(getBaseUris, uri, method) {
const uris = [shaka.util.StringUtils.htmlUnescape(uri)];
const requestUris =
shaka.util.ManifestParserUtils.resolveUris(getBaseUris(), uris);
const request = shaka.net.NetworkingEngine.makeRequest(
requestUris, this.config_.retryParameters);
request.method = method;
const type = shaka.net.NetworkingEngine.RequestType.TIMING;
const operation =
this.playerInterface_.networkingEngine.request(
type, request, {isPreload: this.isPreloadFn_()});
this.operationManager_.manage(operation);
const response = await operation.promise;
let text;
if (method == 'HEAD') {
if (!response.headers || !response.headers['date']) {
shaka.log.warning('UTC timing response is missing',
'expected date header');
return 0;
}
text = response.headers['date'];
} else {
text = shaka.util.StringUtils.fromUTF8(response.data);
}
const date = Date.parse(text);
if (isNaN(date)) {
shaka.log.warning('Unable to parse date from UTC timing response');
return 0;
}
return (date - Date.now());
}
/**
* Parses an array of UTCTiming elements.
*
* @param {function(): !Array<string>} getBaseUris
* @param {!Array<!shaka.extern.xml.Node>} elements
* @return {!Promise<number>}
* @private
*/
async parseUtcTiming_(getBaseUris, elements) {
const schemesAndValues = elements.map((elem) => {
return {
scheme: elem.attributes['schemeIdUri'],
value: elem.attributes['value'],
};
});
// If there's nothing specified in the manifest, but we have a default from
// the config, use that.
const clockSyncUri = this.config_.dash.clockSyncUri;
if (!schemesAndValues.length && clockSyncUri) {
schemesAndValues.push({
scheme: 'urn:mpeg:dash:utc:http-head:2014',
value: clockSyncUri,
});
}
for (const sv of schemesAndValues) {
try {
const scheme = sv.scheme;
const value = sv.value;
switch (scheme) {
// See DASH IOP Guidelines Section 4.7
// https://bit.ly/DashIop3-2
// Some old ISO23009-1 drafts used 2012.
case 'urn:mpeg:dash:utc:http-head:2014':
case 'urn:mpeg:dash:utc:http-head:2012':
// eslint-disable-next-line no-await-in-loop
return await this.requestForTiming_(getBaseUris, value, 'HEAD');
case 'urn:mpeg:dash:utc:http-xsdate:2014':
case 'urn:mpeg:dash:utc:http-iso:2014':
case 'urn:mpeg:dash:utc:http-xsdate:2012':
case 'urn:mpeg:dash:utc:http-iso:2012':
// eslint-disable-next-line no-await-in-loop
return await this.requestForTiming_(getBaseUris, value, 'GET');
case 'urn:mpeg:dash:utc:direct:2014':
case 'urn:mpeg:dash:utc:direct:2012': {
const date = Date.parse(value);
return isNaN(date) ? 0 : (date - Date.now());
}
case 'urn:mpeg:dash:utc:http-ntp:2014':
case 'urn:mpeg:dash:utc:ntp:2014':
case 'urn:mpeg:dash:utc:sntp:2014':
shaka.log.alwaysWarn('NTP UTCTiming scheme is not supported');
break;
default:
shaka.log.alwaysWarn(
'Unrecognized scheme in UTCTiming element', scheme);
break;
}
} catch (e) {
shaka.log.warning('Error fetching time from UTCTiming elem', e.message);
}
}
shaka.log.alwaysWarn(
'A UTCTiming element should always be given in live manifests! ' +
'This content may not play on clients with bad clocks!');
return 0;
}
/**
* Parses an EventStream element.
*
* @param {number} periodStart
* @param {?number} periodDuration
* @param {!shaka.extern.xml.Node} elem
* @param {number} availabilityStart
* @private
*/
parseEventStream_(periodStart, periodDuration, elem, availabilityStart) {
const TXml = shaka.util.TXml;
const parseNumber = shaka.util.TXml.parseNonNegativeInt;
const schemeIdUri = elem.attributes['schemeIdUri'] || '';
const value = elem.attributes['value'] || '';
const timescale = TXml.parseAttr(elem, 'timescale', parseNumber) || 1;
const presentationTimeOffset =
TXml.parseAttr(elem, 'presentationTimeOffset', parseNumber) || 0;
for (const eventNode of TXml.findChildren(elem, 'Event')) {
const presentationTime =
TXml.parseAttr(eventNode, 'presentationTime', parseNumber) || 0;
const duration =
TXml.parseAttr(eventNode, 'duration', parseNumber) || 0;
// Ensure start time won't be lower than period start.
let startTime = Math.max(
(presentationTime - presentationTimeOffset) / timescale + periodStart,
periodStart);
let endTime = startTime + (duration / timescale);
if (periodDuration != null) {
// An event should not go past the Period, even if the manifest says so.
// See: Dash sec. 5.10.2.1
startTime = Math.min(startTime, periodStart + periodDuration);
endTime = Math.min(endTime, periodStart + periodDuration);
}
// Don't add unavailable regions to the timeline.
if (endTime < availabilityStart) {
continue;
}
/** @type {shaka.extern.TimelineRegionInfo} */
const region = {
schemeIdUri: schemeIdUri,
value: value,
startTime: startTime,
endTime: endTime,
id: eventNode.attributes['id'] || '',
timescale: timescale,
eventElement: TXml.txmlNodeToDomElement(eventNode),
eventNode: TXml.cloneNode(eventNode),
};
this.playerInterface_.onTimelineRegionAdded(region);
}
}
/**
* Makes a network request on behalf of SegmentBase.createStreamInfo.
*
* @param {!Array<string>} uris
* @param {?number} startByte
* @param {?number} endByte
* @param {boolean} isInit
* @return {!Promise<BufferSource>}
* @private
*/
async requestSegment_(uris, startByte, endByte, isInit) {
const requestType = shaka.net.NetworkingEngine.RequestType.SEGMENT;
const type = isInit ?
shaka.net.NetworkingEngine.AdvancedRequestType.INIT_SEGMENT :
shaka.net.NetworkingEngine.AdvancedRequestType.MEDIA_SEGMENT;
const request = shaka.util.Networking.createSegmentRequest(
uris,
startByte,
endByte,
this.config_.retryParameters);
const response = await this.makeNetworkRequest_(
request, requestType, {type});
return response.data;
}
/**
* Guess the content type based on MIME type and codecs.
*
* @param {string} mimeType
* @param {string} codecs
* @return {string}
* @private
*/
static guessContentType_(mimeType, codecs) {
const fullMimeType = shaka.util.MimeUtils.getFullType(mimeType, codecs);
if (shaka.text.TextEngine.isTypeSupported(fullMimeType)) {
// If it's supported by TextEngine, it's definitely text.
// We don't check MediaSourceEngine, because that would report support
// for platform-supported video and audio types as well.
return shaka.util.ManifestParserUtils.ContentType.TEXT;
}
// Otherwise, just split the MIME type. This handles video and audio
// types well.
return mimeType.split('/')[0];
}
/**
* Create a networking request. This will manage the request using the
* parser's operation manager.
*
* @param {shaka.extern.Request} request
* @param {shaka.net.NetworkingEngine.RequestType} type
* @param {shaka.extern.RequestContext=} context
* @return {!Promise<shaka.extern.Response>}
* @private
*/
makeNetworkRequest_(request, type, context) {
if (!context) {
context = {};
}
context.isPreload = this.isPreloadFn_();
const op = this.playerInterface_.networkingEngine.request(
type, request, context);
this.operationManager_.manage(op);
return op.promise;
}
/**
* @param {!shaka.extern.xml.Node} patchNode
* @private
*/
updatePatchLocationNodes_(patchNode) {
const TXml = shaka.util.TXml;
TXml.modifyNodes(this.patchLocationNodes_, patchNode);
}
/**
* @return {!Array<string>}
* @private
*/
getPatchLocationUris_() {
const TXml = shaka.util.TXml;
const mpdId = this.manifestPatchContext_.mpdId;
const publishTime = this.manifestPatchContext_.publishTime;
if (!mpdId || !publishTime || !this.patchLocationNodes_.length) {
return [];
}
const now = Date.now() / 1000;
const patchLocations = this.patchLocationNodes_.filter((patchLocation) => {
const ttl = TXml.parseNonNegativeInt(patchLocation.attributes['ttl']);
return !ttl || publishTime + ttl > now;
})
.map(TXml.getContents)
.filter(shaka.util.Functional.isNotNull);
if (!patchLocations.length) {
return [];
}
return shaka.util.ManifestParserUtils.resolveUris(
this.manifestUris_, patchLocations);
}
};
/**
* @typedef {{
* mpdId: string,
* type: string,
* mediaPresentationDuration: ?number,
* profiles: !Array<string>,
* availabilityTimeOffset: number,
* getBaseUris: ?function():!Array<string>,
* publishTime: number
* }}
*
* @property {string} mpdId
* ID of the original MPD file.
* @property {string} type
* Specifies the type of the dash manifest i.e. "static"
* @property {?number} mediaPresentationDuration
* Media presentation duration, or null if unknown.
* @property {!Array<string>} profiles
* Profiles of DASH are defined to enable interoperability and the
* signaling of the use of features.
* @property {number} availabilityTimeOffset
* Specifies the total availabilityTimeOffset of the segment.
* @property {?function():!Array<string>} getBaseUris
* An array of absolute base URIs.
* @property {number} publishTime
* Time when manifest has been published, in seconds.
*/
shaka.dash.DashParser.PatchContext;
/**
* @const {string}
* @private
*/
shaka.dash.DashParser.SCTE214_ = 'urn:scte:dash:scte214-extensions';
/**
* @const {string}
* @private
*/
shaka.dash.DashParser.UP_NAMESPACE_ = 'urn:mpeg:dash:schema:urlparam:2014';
/**
* @typedef {
* function(!Array<string>, ?number, ?number, boolean):
* !Promise<BufferSource>
* }
*/
shaka.dash.DashParser.RequestSegmentCallback;
/**
* @typedef {{
* segmentBase: ?shaka.extern.xml.Node,
* segmentList: ?shaka.extern.xml.Node,
* segmentTemplate: ?shaka.extern.xml.Node,
* getBaseUris: function():!Array<string>,
* width: (number|undefined),
* height: (number|undefined),
* contentType: string,
* mimeType: string,
* codecs: string,
* frameRate: (number|undefined),
* pixelAspectRatio: (string|undefined),
* emsgSchemeIdUris: !Array<string>,
* id: ?string,
* originalId: ?string,
* position: (number|undefined),
* language: ?string,
* numChannels: ?number,
* audioSamplingRate: ?number,
* availabilityTimeOffset: number,
* initialization: ?string,
* aesKey: (shaka.extern.aesKey|undefined),
* segmentSequenceCadence: number,
* label: ?string,
* encrypted: boolean
* }}
*
* @description
* A collection of elements and properties which are inherited across levels
* of a DASH manifest.
*
* @property {?shaka.extern.xml.Node} segmentBase
* The XML node for SegmentBase.
* @property {?shaka.extern.xml.Node} segmentList
* The XML node for SegmentList.
* @property {?shaka.extern.xml.Node} segmentTemplate
* The XML node for SegmentTemplate.
* @property {function():!Array<string>} getBaseUris
* Function than returns an array of absolute base URIs for the frame.
* @property {(number|undefined)} width
* The inherited width value.
* @property {(number|undefined)} height
* The inherited height value.
* @property {string} contentType
* The inherited media type.
* @property {string} mimeType
* The inherited MIME type value.
* @property {string} codecs
* The inherited codecs value.
* @property {(number|undefined)} frameRate
* The inherited framerate value.
* @property {(string|undefined)} pixelAspectRatio
* The inherited pixel aspect ratio value.
* @property {!Array<string>} emsgSchemeIdUris
* emsg registered schemeIdUris.
* @property {?string} id
* The ID of the element.
* @property {?string} originalId
* The original ID of the element.
* @property {number|undefined} position
* Position of the element used for indexing in case of no id
* @property {?string} language
* The original language of the element.
* @property {?number} numChannels
* The number of audio channels, or null if unknown.
* @property {?number} audioSamplingRate
* Specifies the maximum sampling rate of the content, or null if unknown.
* @property {number} availabilityTimeOffset
* Specifies the total availabilityTimeOffset of the segment, or 0 if unknown.
* @property {?string} initialization
* Specifies the file where the init segment is located, or null.
* @property {(shaka.extern.aesKey|undefined)} aesKey
* AES-128 Content protection key
* @property {number} segmentSequenceCadence
* Specifies the cadence of independent segments in Segment Sequence
* Representation.
* @property {?string} label
* Label or null if unknown.
* @property {boolean} encrypted
* Specifies is encrypted or not.
*/
shaka.dash.DashParser.InheritanceFrame;
/**
* @typedef {{
* dynamic: boolean,
* presentationTimeline: !shaka.media.PresentationTimeline,
* period: ?shaka.dash.DashParser.InheritanceFrame,
* periodInfo: ?shaka.dash.DashParser.PeriodInfo,
* adaptationSet: ?shaka.dash.DashParser.InheritanceFrame,
* representation: ?shaka.dash.DashParser.InheritanceFrame,
* bandwidth: number,
* indexRangeWarningGiven: boolean,
* availabilityTimeOffset: number,
* mediaPresentationDuration: ?number,
* profiles: !Array<string>,
* roles: ?Array<string>,
* urlParams: function():string
* }}
*
* @description
* Contains context data for the streams. This is designed to be
* shallow-copyable, so the parser must overwrite (not modify) each key as the
* parser moves through the manifest and the parsing context changes.
*
* @property {boolean} dynamic
* True if the MPD is dynamic (not all segments available at once)
* @property {!shaka.media.PresentationTimeline} presentationTimeline
* The PresentationTimeline.
* @property {?shaka.dash.DashParser.InheritanceFrame} period
* The inheritance from the Period element.
* @property {?shaka.dash.DashParser.PeriodInfo} periodInfo
* The Period info for the current Period.
* @property {?shaka.dash.DashParser.InheritanceFrame} adaptationSet
* The inheritance from the AdaptationSet element.
* @property {?shaka.dash.DashParser.InheritanceFrame} representation
* The inheritance from the Representation element.
* @property {number} bandwidth
* The bandwidth of the Representation, or zero if missing.
* @property {boolean} indexRangeWarningGiven
* True if the warning about SegmentURL@indexRange has been printed.
* @property {number} availabilityTimeOffset
* The sum of the availabilityTimeOffset values that apply to the element.
* @property {!Array<string>} profiles
* Profiles of DASH are defined to enable interoperability and the signaling
* of the use of features.
* @property {?number} mediaPresentationDuration
* Media presentation duration, or null if unknown.
* @property {function():string} urlParams
* The query params for the segments.
*/
shaka.dash.DashParser.Context;
/**
* @typedef {{
* start: number,
* duration: ?number,
* node: ?shaka.extern.xml.Node,
* isLastPeriod: boolean
* }}
*
* @description
* Contains information about a Period element.
*
* @property {number} start
* The start time of the period.
* @property {?number} duration
* The duration of the period; or null if the duration is not given. This
* will be non-null for all periods except the last.
* @property {?shaka.extern.xml.Node} node
* The XML Node for the Period.
* @property {boolean} isLastPeriod
* Whether this Period is the last one in the manifest.
*/
shaka.dash.DashParser.PeriodInfo;
/**
* @typedef {{
* id: string,
* contentType: ?string,
* language: string,
* main: boolean,
* streams: !Array<shaka.extern.Stream>,
* drmInfos: !Array<shaka.extern.DrmInfo>,
* trickModeFor: ?string,
* representationIds: !Array<string>,
* dependencyStreamMap: !Map<string, shaka.extern.Stream>
* }}
*
* @description
* Contains information about an AdaptationSet element.
*
* @property {string} id
* The unique ID of the adaptation set.
* @property {?string} contentType
* The content type of the AdaptationSet.
* @property {string} language
* The language of the AdaptationSet.
* @property {boolean} main
* Whether the AdaptationSet has the 'main' type.
* @property {!Array<shaka.extern.Stream>} streams
* The streams this AdaptationSet contains.
* @property {!Array<shaka.extern.DrmInfo>} drmInfos
* The DRM info for the AdaptationSet.
* @property {?string} trickModeFor
* If non-null, this AdaptationInfo represents trick mode tracks. This
* property is the ID of the normal AdaptationSet these tracks should be
* associated with.
* @property {!Array<string>} representationIds
* An array of the IDs of the Representations this AdaptationSet contains.
* @property {!Map<string, string>} dependencyStreamMap
* A map of dependencyStream
*/
shaka.dash.DashParser.AdaptationInfo;
/**
* @typedef {function(): !Promise<shaka.media.SegmentIndex>}
* @description
* An async function which generates and returns a SegmentIndex.
*/
shaka.dash.DashParser.GenerateSegmentIndexFunction;
/**
* @typedef {{
* generateSegmentIndex: shaka.dash.DashParser.GenerateSegmentIndexFunction
* }}
*
* @description
* Contains information about a Stream. This is passed from the createStreamInfo
* methods.
*
* @property {shaka.dash.DashParser.GenerateSegmentIndexFunction
* } generateSegmentIndex
* An async function to create the SegmentIndex for the stream.
*/
shaka.dash.DashParser.StreamInfo;
shaka.media.ManifestParser.registerParserByMime(
'application/dash+xml', () => new shaka.dash.DashParser());
shaka.media.ManifestParser.registerParserByMime(
'video/vnd.mpeg.dash.mpd', () => new shaka.dash.DashParser());