/*! @license
* Shaka Player
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
goog.provide('shaka.dash.MpdUtils');
goog.require('goog.asserts');
goog.require('shaka.log');
goog.require('shaka.net.NetworkingEngine');
goog.require('shaka.util.AbortableOperation');
goog.require('shaka.util.Error');
goog.require('shaka.util.Functional');
goog.require('shaka.util.ManifestParserUtils');
goog.require('shaka.util.TXml');
goog.require('shaka.util.URL');
goog.requireType('shaka.dash.DashParser');
goog.requireType('shaka.media.PresentationTimeline');
/**
* @summary MPD processing utility functions.
*/
shaka.dash.MpdUtils = class {
/**
* Fills a SegmentTemplate URI template. This function does not validate the
* resulting URI.
*
* @param {string} uriTemplate
* @param {?string} representationId
* @param {?number} number
* @param {?number} subNumber
* @param {?number} bandwidth
* @param {?(number|bigint)} time
* @return {string} A URI string.
* @see ISO/IEC 23009-1:2014 section 5.3.9.4.4
*/
static fillUriTemplate(
uriTemplate, representationId, number, subNumber, bandwidth, time) {
/** @type {!Map<string, ?number | ?string>} */
const valueTable = new Map()
.set('RepresentationID', representationId)
.set('Number', number)
.set('SubNumber', subNumber)
.set('Bandwidth', bandwidth)
.set('Time', time);
// cspell: disable-next-line
const re = /\$(RepresentationID|Number|SubNumber|Bandwidth|Time)?(?:%0([0-9]+)([diouxX]))?\$/g; // eslint-disable-line @stylistic/max-len
const uri = uriTemplate.replace(re, (match, name, widthStr, format) => {
if (match == '$$') {
return '$';
}
let value = valueTable.get(name);
goog.asserts.assert(value !== undefined, 'Unrecognized identifier');
// Note that |value| may be 0 or ''.
if (value == null) {
shaka.log.warning(
'URL template does not have an available substitution for ',
'identifier "' + name + '":',
uriTemplate);
return match;
}
if (name == 'RepresentationID' && widthStr) {
shaka.log.warning(
'URL template should not contain a width specifier for identifier',
'"RepresentationID":',
uriTemplate);
widthStr = undefined;
}
if (name == 'Time') {
if (typeof value != 'bigint') {
goog.asserts.assert(typeof value == 'number',
'Time value should be a number or bigint!');
if (Math.abs(value - Math.round(value)) >= 0.2) {
shaka.log.alwaysWarn(
'Calculated $Time$ values must be close to integers');
}
value = Math.round(value);
}
}
/** @type {string} */
let valueString;
switch (format) {
case undefined: // Happens if there is no format specifier.
case 'd':
case 'i':
case 'u':
valueString = value.toString();
break;
case 'o':
valueString = value.toString(8);
break;
case 'x':
valueString = value.toString(16);
break;
case 'X':
valueString = value.toString(16).toUpperCase();
break;
default:
goog.asserts.assert(false, 'Unhandled format specifier');
valueString = value.toString();
break;
}
// Create a padding string.
const width = window.parseInt(widthStr, 10) || 1;
const paddingSize = Math.max(0, width - valueString.length);
const padding = (new Array(paddingSize + 1)).join('0');
return padding + valueString;
});
return uri;
}
/**
* Expands a SegmentTimeline into an array-based timeline. The results are in
* seconds.
*
* @param {Array<!shaka.extern.xml.Node>} timePoints
* @param {number} timescale
* @param {number} unscaledPresentationTimeOffset
* @param {number} periodDuration The Period's duration in seconds.
* Infinity indicates that the Period continues indefinitely.
* @param {number} startNumber
* @return {!Array<shaka.media.PresentationTimeline.TimeRange>}
*/
static createTimeline(
timePoints, timescale, unscaledPresentationTimeOffset,
periodDuration, startNumber) {
goog.asserts.assert(
timescale > 0 && timescale < Infinity,
'timescale must be a positive, finite integer');
goog.asserts.assert(
periodDuration > 0, 'period duration must be a positive integer');
// Alias.
const TXml = shaka.util.TXml;
/** @type {!Array<shaka.media.PresentationTimeline.TimeRange>} */
const timeline = [];
let lastEndTime = -unscaledPresentationTimeOffset;
for (let i = 0; i < timePoints.length; ++i) {
const timePoint = timePoints[i];
const next = timePoints[i + 1];
let t = TXml.parseAttr(timePoint, 't', TXml.parseNonNegativeInt);
const d =
TXml.parseAttr(timePoint, 'd', TXml.parseNonNegativeInt);
const r = TXml.parseAttr(timePoint, 'r', TXml.parseInt);
const k = TXml.parseAttr(timePoint, 'k', TXml.parseInt);
const partialSegments = k || 0;
// Adjust the start time to account for the presentation time offset.
if (t != null) {
t -= unscaledPresentationTimeOffset;
}
if (!d) {
shaka.log.warning(
'"S" element must have a duration: ignoring this element.',
timePoint);
continue;
}
let startTime = t != null ? t : lastEndTime;
let repeat = r || 0;
if (repeat < 0) {
if (next) {
const nextStartTime =
TXml.parseAttr(next, 't', TXml.parseNonNegativeInt);
if (nextStartTime == null) {
shaka.log.warning(
'An "S" element cannot have a negative repeat',
'if the next "S" element does not have a valid start time:',
'ignoring the remaining "S" elements.', timePoint);
return timeline;
} else if (startTime >= nextStartTime) {
shaka.log.warning(
'An "S" element cannot have a negative repeat if its start ',
'time exceeds the next "S" element\'s start time:',
'ignoring the remaining "S" elements.', timePoint);
return timeline;
}
repeat = Math.ceil((nextStartTime - startTime) / d) - 1;
} else {
if (periodDuration == Infinity) {
// The DASH spec. actually allows the last "S" element to have a
// negative repeat value even when the Period has an infinite
// duration. No one uses this feature and no one ever should,
// ever.
shaka.log.warning(
'The last "S" element cannot have a negative repeat',
'if the Period has an infinite duration:',
'ignoring the last "S" element.', timePoint);
return timeline;
} else if (startTime / timescale >= periodDuration) {
shaka.log.warning(
'The last "S" element cannot have a negative repeat',
'if its start time exceeds the Period\'s duration:',
'ignoring the last "S" element.', timePoint);
return timeline;
}
repeat = Math.ceil((periodDuration * timescale - startTime) / d) - 1;
}
}
// The end of the last segment may be before the start of the current
// segment (a gap) or after the start of the current segment (an
// overlap). If there is a gap/overlap then stretch/compress the end of
// the last segment to the start of the current segment.
//
// Note: it is possible to move the start of the current segment to the
// end of the last segment, but this would complicate the computation of
// the $Time$ placeholder later on.
if ((timeline.length > 0) && (startTime != lastEndTime)) {
const delta = startTime - lastEndTime;
if (Math.abs(delta / timescale) >=
shaka.util.ManifestParserUtils.GAP_OVERLAP_TOLERANCE_SECONDS) {
shaka.log.warning(
'SegmentTimeline contains a large gap/overlap:',
'the content may have errors in it.', timePoint);
}
timeline[timeline.length - 1].end = startTime / timescale;
}
for (let j = 0; j <= repeat; ++j) {
const endTime = startTime + d;
const item = {
start: startTime / timescale,
unscaledStart: startTime,
end: endTime / timescale,
unscaledEnd: endTime,
partialSegments: partialSegments,
segmentPosition: timeline.length + startNumber,
};
timeline.push(item);
startTime = endTime;
lastEndTime = endTime;
}
}
return timeline;
}
/**
* Parses common segment info for SegmentList and SegmentTemplate.
*
* @param {shaka.dash.DashParser.Context} context
* @param {function(?shaka.dash.DashParser.InheritanceFrame):
* ?shaka.extern.xml.Node} callback
* Gets the element that contains the segment info.
* @return {shaka.dash.MpdUtils.SegmentInfo}
*/
static parseSegmentInfo(context, callback) {
goog.asserts.assert(
callback(context.representation),
'There must be at least one element of the given type.');
const MpdUtils = shaka.dash.MpdUtils;
const TXml = shaka.util.TXml;
const timescaleStr =
MpdUtils.inheritAttribute(context, callback, 'timescale');
let timescale = 1;
if (timescaleStr) {
timescale = TXml.parsePositiveInt(timescaleStr) || 1;
}
const durationStr =
MpdUtils.inheritAttribute(context, callback, 'duration');
const unscaledSegmentDuration = TXml.parsePositiveInt(durationStr || '');
const segmentDuration = unscaledSegmentDuration ?
unscaledSegmentDuration / timescale : null;
const startNumberStr =
MpdUtils.inheritAttribute(context, callback, 'startNumber');
const unscaledPresentationTimeOffset =
Number(MpdUtils.inheritAttribute(context, callback,
'presentationTimeOffset')) || 0;
let startNumber = TXml.parseNonNegativeInt(startNumberStr || '');
if (startNumberStr == null || startNumber == null) {
startNumber = 1;
}
/** @type {Array<shaka.media.PresentationTimeline.TimeRange>} */
let timeline = null;
const timelineNode =
MpdUtils.inheritChild(context, callback, 'SegmentTimeline');
if (timelineNode) {
let timePoints = [];
const patterns = MpdUtils.parsePatterns_(timelineNode);
const sNodes = TXml.findChildren(timelineNode, 'S');
if (patterns.size) {
for (const s of sNodes) {
const expanded = MpdUtils.expandSWithPattern_(s, patterns);
timePoints.push(...expanded);
}
} else {
timePoints = sNodes;
}
timeline = MpdUtils.createTimeline(
timePoints, timescale, unscaledPresentationTimeOffset,
context.periodInfo.duration || Infinity, startNumber);
}
const scaledPresentationTimeOffset =
(unscaledPresentationTimeOffset / timescale) || 0;
return {
timescale: timescale,
unscaledSegmentDuration: unscaledSegmentDuration,
segmentDuration: segmentDuration,
startNumber: startNumber,
scaledPresentationTimeOffset: scaledPresentationTimeOffset,
unscaledPresentationTimeOffset: unscaledPresentationTimeOffset,
timeline: timeline,
};
}
/**
* @param {!shaka.extern.xml.Node} segmentTimelineNode
* @return {!Map<string, !shaka.extern.xml.Node>}
* @private
*/
static parsePatterns_(segmentTimelineNode) {
const TXml = shaka.util.TXml;
const patterns = new Map();
const patternNodes = TXml.findChildren(segmentTimelineNode, 'Pattern');
for (const pattern of patternNodes) {
const id = pattern.attributes['id'];
if (id) {
patterns.set(id, pattern);
} else {
shaka.log.warning('Ignoring Pattern without id.', pattern);
}
}
return patterns;
}
/**
* Expands an <S> element that references a Pattern into one or more
* plain <S> elements without pattern usage.
*
* @param {!shaka.extern.xml.Node} sNode
* @param {!Map<string, !shaka.extern.xml.Node>} patterns
* @return {!Array<!shaka.extern.xml.Node>}
* @private
*/
static expandSWithPattern_(sNode, patterns) {
const TXml = shaka.util.TXml;
const patternId = sNode.attributes['p'];
if (!patternId) {
// S without pattern.
return [sNode];
}
if (!patterns.has(patternId)) {
shaka.log.error('Referenced Pattern not found:', patternId);
return [];
}
const patternNode = patterns.get(patternId);
const pNodes = TXml.findChildren(patternNode, 'P');
if (!pNodes.length) {
return [];
}
// --------------------------------------------------
// 1. Build duration array D[] from Pattern.P
// --------------------------------------------------
/** @type {!Array<number>} */
const patternDurations = [];
for (const p of pNodes) {
const d = Number(p.attributes['d']);
const r = Number(p.attributes['r'] || 0);
if (!Number.isFinite(d) || d <= 0) {
continue;
}
for (let i = 0; i <= r; i++) {
patternDurations.push(d);
}
}
if (!patternDurations.length) {
return [];
}
// --------------------------------------------------
// 2. Resolve S attributes
// --------------------------------------------------
const pE = Number(sNode.attributes['pE'] || 0);
const r = Number(sNode.attributes['r'] || 0);
const segmentCount = r + 1;
const startTime = Number(sNode.attributes['t'] || 0);
const k = Number(sNode.attributes['k'] || 1);
// --------------------------------------------------
// 3. Expand into exactly (r + 1) segments
// --------------------------------------------------
const result = [];
let currentTime = startTime;
const n = patternDurations.length;
for (let i = 0; i < segmentCount; i++) {
const index = (pE + i) % n;
const d = patternDurations[index];
/** @type {!shaka.extern.xml.Node} */
const newS = {
tagName: 'S',
attributes: {
t: String(currentTime),
d: String(d),
},
children: [],
parent: null,
};
if (k > 1) {
newS.attributes['k'] = String(k);
}
result.push(newS);
currentTime += d;
}
return result;
}
/**
* Parses common attributes for Representation, AdaptationSet, and Period.
* @param {shaka.dash.DashParser.Context} context
* @param {function(?shaka.dash.DashParser.InheritanceFrame):
* ?shaka.extern.xml.Node} callback
* @return {!Array<!shaka.extern.xml.Node>}
*/
static getNodes(context, callback) {
const Functional = shaka.util.Functional;
goog.asserts.assert(
callback(context.representation),
'There must be at least one element of the given type.',
);
return [
callback(context.representation),
callback(context.adaptationSet),
callback(context.period),
].filter(Functional.isNotNull);
}
/**
* Searches the inheritance for a Segment* with the given attribute.
*
* @param {shaka.dash.DashParser.Context} context
* @param {function(?shaka.dash.DashParser.InheritanceFrame):
* ?shaka.extern.xml.Node} callback
* Gets the Element that contains the attribute to inherit.
* @param {string} attribute
* @return {?string}
*/
static inheritAttribute(context, callback, attribute) {
const MpdUtils = shaka.dash.MpdUtils;
const nodes = MpdUtils.getNodes(context, callback);
let result = null;
for (const node of nodes) {
result = node.attributes[attribute];
if (result) {
break;
}
}
return result;
}
/**
* Searches the inheritance for a Segment* with the given child.
*
* @param {shaka.dash.DashParser.Context} context
* @param {function(?shaka.dash.DashParser.InheritanceFrame):
* ?shaka.extern.xml.Node} callback
* Gets the Element that contains the child to inherit.
* @param {string} child
* @return {?shaka.extern.xml.Node}
*/
static inheritChild(context, callback, child) {
const MpdUtils = shaka.dash.MpdUtils;
const nodes = MpdUtils.getNodes(context, callback);
const TXml = shaka.util.TXml;
let result = null;
for (const node of nodes) {
result = TXml.findChild(node, child);
if (result) {
break;
}
}
return result;
}
/**
* Checks whether the MPD has any Linked Periods (Periods with ImportedMPD
* children, as defined in DASH 6th edition §5.3.2.6).
*
* @param {!shaka.extern.xml.Node} mpd
* @return {boolean}
*/
static hasLinkedPeriods(mpd) {
const TXml = shaka.util.TXml;
for (const period of TXml.findChildren(mpd, 'Period')) {
if (TXml.findChild(period, 'ImportedMPD')) {
return true;
}
}
return false;
}
/**
* Resolves Linked Periods (ImportedMPD elements, DASH 6th ed. §5.3.2.6).
* For each Period with an ImportedMPD child, fetches the referenced
* single-period MPD and integrates its content into the Linked Period
* following the reference processing model of §5.3.2.6.3. When the
* resolution fails, the Linked Period falls back to a regular Period, or is
* removed if it has no valid content (step 2).
*
* @param {!shaka.extern.xml.Node} mpd
* @param {string} baseUri
* @param {!shaka.extern.RetryParameters} retryParameters
* @param {!shaka.net.NetworkingEngine} networkingEngine
* @return {!shaka.util.AbortableOperation.<!shaka.extern.xml.Node>}
*/
static processLinkedPeriods(mpd, baseUri, retryParameters, networkingEngine) {
const TXml = shaka.util.TXml;
const MpdUtils = shaka.dash.MpdUtils;
const requestType = shaka.net.NetworkingEngine.RequestType.MANIFEST;
const periodOperations = [];
for (const period of TXml.findChildren(mpd, 'Period')) {
const foundImportedMpdElem = TXml.findChild(period, 'ImportedMPD');
if (!foundImportedMpdElem) {
continue;
}
// Closure does not propagate the non-null narrowing above into the async
// callbacks below, so keep an explicitly non-null reference.
const importedMpdElem =
/** @type {!shaka.extern.xml.Node} */ (foundImportedMpdElem);
const href = TXml.getContents(importedMpdElem)?.trim();
if (!href) {
// There is no URI to resolve, so the import can never succeed: treat
// it as a failed resolution (§5.3.2.6.3 step 1.a / step 2).
MpdUtils.fallbackLinkedPeriod_(mpd, period, importedMpdElem);
continue;
}
const importedUri = shaka.util.URL.resolve(baseUri, href);
const request =
shaka.net.NetworkingEngine.makeRequest(
[importedUri], retryParameters);
const networkOperation =
/** @type {!shaka.util.AbortableOperation.<shaka.extern.Response>} */(
networkingEngine.request(requestType, request));
const op = networkOperation.chain((response) => {
// §5.3.2.6.3 step 1: the imported MPD has been retrieved.
let importedMpd = null;
try {
importedMpd = TXml.parseXml(response.data, 'MPD');
} catch (e) {
// Step 1.b: a syntactically invalid MPD means resolution fails.
}
const importedPeriod =
importedMpd ? TXml.findChild(importedMpd, 'Period') : null;
if (!importedMpd || !importedPeriod ||
MpdUtils.importedMpdResolutionFails_(importedMpd)) {
// Failures from steps 1.b and 1.c fall back per step 2.
MpdUtils.fallbackLinkedPeriod_(mpd, period, importedMpdElem);
} else {
// Step 3: integrate the imported content into the Linked Period.
// The ImportedMPD element is removed as part of this (step 4).
MpdUtils.mergeImportedPeriod_(
period, importedMpd, importedPeriod, response.uri);
}
return mpd;
}, (error) => {
// Step 1.a: the resolution (download) failed, possibly after retries.
// Step 2: fall back to a regular Period instead of failing the parse.
MpdUtils.fallbackLinkedPeriod_(mpd, period, importedMpdElem);
return mpd;
});
periodOperations.push(op);
}
if (!periodOperations.length) {
return shaka.util.AbortableOperation.completed(mpd);
}
return shaka.util.AbortableOperation.all(periodOperations).chain(() => mpd);
}
/**
* Handles a failed Linked Period resolution (§5.3.2.6.3 step 2): the
* ImportedMPD element is removed so the Linked Period becomes a regular
* Period. If the resulting Period has no valid content, the Period itself is
* removed from the MPD.
*
* @param {!shaka.extern.xml.Node} mpd
* @param {!shaka.extern.xml.Node} period
* @param {!shaka.extern.xml.Node} importedMpdElem
* @private
*/
static fallbackLinkedPeriod_(mpd, period, importedMpdElem) {
period.children = period.children.filter((c) => c !== importedMpdElem);
if (!shaka.dash.MpdUtils.isValidRegularPeriod_(period)) {
shaka.log.warning(
'Failed to resolve a Linked Period (ImportedMPD) and the Period ' +
'has no valid content; removing it from the MPD.');
mpd.children = mpd.children.filter((c) => c !== period);
} else {
shaka.log.warning(
'Failed to resolve a Linked Period (ImportedMPD); falling back ' +
'to a regular Period.');
}
}
/**
* A regular Period is valid if it contains at least one Adaptation Set, or if
* its @duration is zero (see DASH 6th ed. Table 4, AdaptationSet semantics).
*
* @param {!shaka.extern.xml.Node} period
* @return {boolean}
* @private
*/
static isValidRegularPeriod_(period) {
const TXml = shaka.util.TXml;
if (TXml.findChildren(period, 'AdaptationSet').length) {
return true;
}
return TXml.parseDuration(period.attributes['duration'] || '') === 0;
}
/**
* Checks whether a retrieved imported MPD must be rejected per §5.3.2.6.3:
* - step 1.b: it does not conform to the imported MPD restrictions. The
* Single-Period Static Profile (clause 8.15.2) requires MPD@type="static"
* (the default when the attribute is absent) — so a live/dynamic, or a
* "list", imported MPD is rejected — and exactly one Period element.
* - step 1.c: @availabilityEndTime is present and earlier than now.
*
* @param {!shaka.extern.xml.Node} importedMpd
* @return {boolean}
* @private
*/
static importedMpdResolutionFails_(importedMpd) {
const TXml = shaka.util.TXml;
const type = importedMpd.attributes['type'] || 'static';
if (type !== 'static') {
return true;
}
// Clause 8.15.2: one and only one Period element shall be present.
if (TXml.findChildren(importedMpd, 'Period').length !== 1) {
return true;
}
const availabilityEndTime =
TXml.parseDate(importedMpd.attributes['availabilityEndTime'] || '');
return availabilityEndTime != null &&
availabilityEndTime * 1000 < Date.now();
}
/**
* Integrates the content of an imported single-period MPD into a Linked
* Period, following DASH 6th ed. §5.3.2.6.3 step 3.
*
* @param {!shaka.extern.xml.Node} period The Linked Period.
* @param {!shaka.extern.xml.Node} importedMpd
* @param {!shaka.extern.xml.Node} importedPeriod
* @param {string} importedMpdUri The absolute URL of the imported MPD.
* @private
*/
static mergeImportedPeriod_(period, importedMpd, importedPeriod,
importedMpdUri) {
const TXml = shaka.util.TXml;
const MpdUtils = shaka.dash.MpdUtils;
// Step 3.b: keep only the allowed attributes (@id, @start, @duration) of
// the Linked Period; any other attribute is removed.
const keepAttrs = new Set(['id', 'start', 'duration']);
for (const name of Object.keys(period.attributes)) {
if (!keepAttrs.has(name) && !name.includes(':')) {
delete period.attributes[name];
}
}
// Step 3.b: keep only the allowed elements of the Linked Period; everything
// else (its own BaseURL/AdaptationSet/..., and the ImportedMPD element) is
// removed. Elements from a foreign namespace are also preserved.
const keepTags = new Set([
'ServiceDescription',
'SupplementalProperty',
'EssentialProperty',
'EventStream',
'RequestParam',
]);
const linkedKept = TXml.getChildNodes(period).filter(
(child) => keepTags.has(child.tagName) || child.tagName.includes(':'));
// Imported Period content, plus the imported MPD-level SupplementalProperty
// (step 3.a.iii) and EssentialProperty elements moved to Period level. The
// imported BaseURL elements are handled separately by the Base URL
// resolution (step 3.e).
const importedChildren = TXml.getChildNodes(importedPeriod)
.filter((child) => child.tagName !== 'BaseURL')
.concat(
TXml.findChildren(importedMpd, 'SupplementalProperty'),
TXml.findChildren(importedMpd, 'EssentialProperty'));
// Step 3.c: when an equivalent element is present in both Periods, the
// imported one overrides (replaces) the one from the Linked Period.
const importedKeys = new Set();
for (const child of importedChildren) {
const key = MpdUtils.linkedPeriodMergeKey_(child);
if (key != null) {
importedKeys.add(key);
}
}
const survivingLinked = linkedKept.filter((child) => {
const key = MpdUtils.linkedPeriodMergeKey_(child);
return key == null || !importedKeys.has(key);
});
// Step 3.e: resolve the Base URLs of the imported MPD (clause 5.6) into
// absolute URLs which become the Linked Period's BaseURL element(s).
const baseUrlNodes = MpdUtils.resolveImportedBaseUrls_(
importedMpd, importedPeriod, importedMpdUri).map((uri) => {
return /** @type {!shaka.extern.xml.Node} */ ({
tagName: 'BaseURL',
attributes: {},
children: [uri],
parent: period,
});
});
// Re-parent the imported children to the Linked Period.
for (const child of importedChildren) {
child.parent = period;
}
// Steps 4: the ImportedMPD element is dropped here, as it is not part of
// any of the kept sets.
period.children = baseUrlNodes.concat(importedChildren, survivingLinked);
// Step 3.d.iii: @duration becomes the smaller of the two when both Periods
// define one; otherwise the imported value is used if the Linked Period has
// none. (@id and @start of the Linked Period take precedence per steps
// 3.d.i / 3.d.ii, so they are intentionally left untouched.)
const importedDuration = importedPeriod.attributes['duration'];
if (importedDuration) {
const linkedDuration = period.attributes['duration'];
if (!linkedDuration) {
period.attributes['duration'] = importedDuration;
} else {
const linkedSeconds = TXml.parseDuration(linkedDuration);
const importedSeconds = TXml.parseDuration(importedDuration);
if (linkedSeconds != null && importedSeconds != null &&
importedSeconds < linkedSeconds) {
period.attributes['duration'] = importedDuration;
}
}
}
}
/**
* Returns the equivalence key used by §5.3.2.6.3 step 3.c to decide whether
* two elements are "the same" (so the imported one overrides the Linked
* Period one), or null if the element is not subject to override.
*
* @param {!shaka.extern.xml.Node} node
* @return {?string}
* @private
*/
static linkedPeriodMergeKey_(node) {
switch (node.tagName) {
case 'ServiceDescription':
return 'ServiceDescription:' + (node.attributes['id'] || '');
case 'SupplementalProperty':
case 'EssentialProperty':
case 'EventStream':
return node.tagName + ':' + (node.attributes['schemeIdUri'] || '') +
':' + (node.attributes['value'] || '');
default:
return null;
}
}
/**
* Resolves the Base URLs of an imported MPD into absolute URLs, following the
* Base URL resolution of clause 5.6 (§5.3.2.6.3 step 3.e). The imported MPD
* URL is the root, against which the imported MPD-level BaseURL elements are
* resolved, against which in turn the imported Period-level BaseURL elements
* are resolved.
*
* @param {!shaka.extern.xml.Node} importedMpd
* @param {!shaka.extern.xml.Node} importedPeriod
* @param {string} importedMpdUri
* @return {!Array<string>}
* @private
*/
static resolveImportedBaseUrls_(importedMpd, importedPeriod, importedMpdUri) {
const TXml = shaka.util.TXml;
const getBaseUris = (elem) => {
const uris = [];
for (const baseUrl of TXml.findChildren(elem, 'BaseURL')) {
const contents = TXml.getContents(baseUrl);
if (contents) {
uris.push(contents);
}
}
return uris;
};
let baseUris = [importedMpdUri];
const mpdBaseUris = getBaseUris(importedMpd);
if (mpdBaseUris.length) {
baseUris = shaka.util.URL.resolveUris(baseUris, mpdBaseUris);
}
const periodBaseUris = getBaseUris(importedPeriod);
if (periodBaseUris.length) {
baseUris = shaka.util.URL.resolveUris(baseUris, periodBaseUris);
}
return baseUris;
}
/**
* Fast detection of whether the MPD contains XLinks worth resolving.
* The DASH spec only allows XLink on the following elements:
* - Period
* - AdaptationSet
* - SegmentList
* We also avoid descending into SegmentTimeline for performance and
* because XLink is not valid there.
*
* @param {!shaka.extern.xml.Node} root
* @return {boolean}
*/
static hasXlinks(root) {
const TXml = shaka.util.TXml;
const NS = shaka.dash.MpdUtils.XlinkNamespaceUri_;
// If the MPD root does not declare the XLink namespace, we can skip
// scanning in the common case where the namespace is always declared at
// the root.
if (root.attributes['xmlns:xlink'] !== NS) {
return false;
}
// Elements where XLink is valid per spec.
const xlinkElements = new Set(['Period', 'AdaptationSet', 'SegmentList']);
// Containers that are worthwhile to traverse to reach the allowed elements.
const containers = new Set([
'MPD', 'Period', 'AdaptationSet', 'Representation', 'SegmentList',
]);
/** @type {!Array<!shaka.extern.xml.Node>} */
const stack = [root];
while (stack.length) {
const node = stack.pop();
if (xlinkElements.has(node.tagName)) {
if (TXml.getAttributeNS(node, NS, 'href')) {
// Early return on first XLink occurrence.
return true;
}
}
for (const child of TXml.getChildNodes(node)) {
if (containers.has(child.tagName)) {
stack.push(child);
}
}
}
return false;
}
/**
* Follow the xlink link contained in the given element.
* It also strips the xlink properties off of the element,
* even if the process fails.
*
* @param {!shaka.extern.xml.Node} element
* @param {!shaka.extern.RetryParameters} retryParameters
* @param {boolean} failGracefully
* @param {string} baseUri
* @param {!shaka.net.NetworkingEngine} networkingEngine
* @param {number} linkDepth
* @return {!shaka.util.AbortableOperation.<!shaka.extern.xml.Node>}
* @private
*/
static handleXlinkInElement_(
element, retryParameters, failGracefully, baseUri, networkingEngine,
linkDepth) {
const MpdUtils = shaka.dash.MpdUtils;
const TXml = shaka.util.TXml;
const Error = shaka.util.Error;
const NS = MpdUtils.XlinkNamespaceUri_;
const xlinkHref = TXml.getAttributeNS(element, NS, 'href');
const xlinkActuate =
TXml.getAttributeNS(element, NS, 'actuate') || 'onRequest';
// Remove the xlink properties, so it won't download again
// when re-processed.
for (const key of Object.keys(element.attributes)) {
const segments = key.split(':');
const namespace = shaka.util.TXml.getKnownNameSpace(NS);
if (segments[0] == namespace) {
delete element.attributes[key];
}
}
if (linkDepth >= 5) {
return shaka.util.AbortableOperation.failed(new Error(
Error.Severity.CRITICAL, Error.Category.MANIFEST,
Error.Code.DASH_XLINK_DEPTH_LIMIT));
}
if (xlinkActuate != 'onLoad') {
// Only xlink:actuate="onLoad" is supported.
// When no value is specified, the assumed value is "onRequest".
return shaka.util.AbortableOperation.failed(new Error(
Error.Severity.CRITICAL, Error.Category.MANIFEST,
Error.Code.DASH_UNSUPPORTED_XLINK_ACTUATE));
}
// Resolve the xlink href, in case it's a relative URL.
const uris = shaka.util.URL.resolveUris([baseUri], [xlinkHref]);
// Load in the linked elements.
const requestType = shaka.net.NetworkingEngine.RequestType.MANIFEST;
const request =
shaka.net.NetworkingEngine.makeRequest(uris, retryParameters);
const requestOperation = networkingEngine.request(requestType, request);
// The interface is abstract, but we know it was implemented with the
// more capable internal class.
goog.asserts.assert(
requestOperation instanceof shaka.util.AbortableOperation,
'Unexpected implementation of IAbortableOperation!');
// Satisfy the compiler with a cast.
const networkOperation =
/** @type {!shaka.util.AbortableOperation.<shaka.extern.Response>} */ (
requestOperation);
// Chain onto that operation.
return networkOperation.chain(
(response) => {
// This only supports the case where the loaded xml has a single
// top-level element. If there are multiple roots, it will be
// rejected.
const rootElem =
TXml.parseXml(response.data, element.tagName);
if (!rootElem) {
// It was not valid XML.
return shaka.util.AbortableOperation.failed(new Error(
Error.Severity.CRITICAL, Error.Category.MANIFEST,
Error.Code.DASH_INVALID_XML, xlinkHref));
}
// Now that there is no other possibility of the process erroring,
// the element can be changed further.
// Remove the current contents of the node.
element.children = [];
// Move the children of the loaded xml into the current element.
while (rootElem.children.length) {
const child = rootElem.children.shift();
element.children.push(child);
}
// Move the attributes of the loaded xml into the current element.
for (const key of Object.keys(rootElem.attributes)) {
element.attributes[key] = rootElem.attributes[key];
}
return shaka.dash.MpdUtils.processXlinks(
element, retryParameters, failGracefully, uris[0],
networkingEngine, linkDepth + 1);
});
}
/**
* Filter the contents of a node recursively, replacing xlink links
* with their associated online data.
*
* @param {!shaka.extern.xml.Node} element
* @param {!shaka.extern.RetryParameters} retryParameters
* @param {boolean} failGracefully
* @param {string} baseUri
* @param {!shaka.net.NetworkingEngine} networkingEngine
* @param {number=} linkDepth default set to 0
* @return {!shaka.util.AbortableOperation.<!shaka.extern.xml.Node>}
*/
static processXlinks(
element, retryParameters,
failGracefully, baseUri, networkingEngine,
linkDepth = 0) {
const MpdUtils = shaka.dash.MpdUtils;
const TXml = shaka.util.TXml;
const NS = MpdUtils.XlinkNamespaceUri_;
// Descend only through containers that can lead to valid XLink locations.
const containers = new Set([
'MPD', 'Period', 'AdaptationSet', 'Representation', 'SegmentList',
]);
if (TXml.getAttributeNS(element, NS, 'href')) {
let handled = MpdUtils.handleXlinkInElement_(
element, retryParameters, failGracefully,
baseUri, networkingEngine, linkDepth);
if (failGracefully) {
// Catch any error and go on.
handled = handled.chain(undefined, (error) => {
// handleXlinkInElement_ strips the xlink properties off of the
// element even if it fails, so calling processXlinks again will
// handle whatever contents the element natively has.
return MpdUtils.processXlinks(
element, retryParameters, failGracefully, baseUri,
networkingEngine, linkDepth);
});
}
return handled;
}
const childOperations = [];
for (const child of shaka.util.TXml.getChildNodes(element)) {
const resolveToZeroString = 'urn:mpeg:dash:resolve-to-zero:2013';
if (TXml.getAttributeNS(child, NS, 'href') == resolveToZeroString) {
// This is a 'resolve to zero' code; it means the element should
// be removed, as specified by the mpeg-dash rules for xlink.
element.children = element.children.filter(
(elem) => elem !== child);
} else if (containers.has(child.tagName)) {
// Recurse only into relevant containers.
childOperations.push(shaka.dash.MpdUtils.processXlinks(
/** @type {!shaka.extern.xml.Node} */ (child),
retryParameters, failGracefully,
baseUri, networkingEngine, linkDepth));
}
}
return shaka.util.AbortableOperation.all(childOperations).chain(() => {
return element;
});
}
/**
* Extracts and normalizes the EssentialProperty descriptors that are direct
* children of the given element.
*
* @param {!shaka.extern.xml.Node} node
* @return {!Array<shaka.dash.MpdUtils.Descriptor>}
* @see ISO/IEC 23009-1:2022 clause 5.8.4.8
*/
static parseEssentialProperties(node) {
return shaka.dash.MpdUtils.parseDescriptorsByTag_(
node, 'EssentialProperty');
}
/**
* Extracts and normalizes the SupplementalProperty descriptors that are
* direct children of the given element.
*
* @param {!shaka.extern.xml.Node} node
* @return {!Array<shaka.dash.MpdUtils.Descriptor>}
* @see ISO/IEC 23009-1:2022 clause 5.8.4.9
*/
static parseSupplementalProperties(node) {
return shaka.dash.MpdUtils.parseDescriptorsByTag_(
node, 'SupplementalProperty');
}
/**
* Extracts and normalizes the descriptor elements with the given tag name
* that are direct children of the given element.
*
* @param {!shaka.extern.xml.Node} node
* @param {string} tagName
* @return {!Array<shaka.dash.MpdUtils.Descriptor>}
* @private
*/
static parseDescriptorsByTag_(node, tagName) {
return shaka.util.TXml.findChildren(node, tagName)
.map(shaka.dash.MpdUtils.normalizeDescriptor_);
}
/**
* Returns the first descriptor in the list matching the given scheme (and,
* when provided, value), or null if there is none.
*
* @param {!Array<shaka.dash.MpdUtils.Descriptor>} descriptors
* @param {string} schemeIdUri
* @param {string=} value
* @return {?shaka.dash.MpdUtils.Descriptor}
*/
static getDescriptor(descriptors, schemeIdUri, value) {
return descriptors.find((d) => d.schemeIdUri == schemeIdUri &&
(value == undefined || d.value == value)) || null;
}
/**
* Returns true if the list contains a descriptor matching the given scheme
* (and, when provided, value).
*
* @param {!Array<shaka.dash.MpdUtils.Descriptor>} descriptors
* @param {string} schemeIdUri
* @param {string=} value
* @return {boolean}
*/
static hasDescriptor(descriptors, schemeIdUri, value) {
return shaka.dash.MpdUtils.getDescriptor(
descriptors, schemeIdUri, value) != null;
}
/**
* Normalizes a single EssentialProperty/SupplementalProperty element into a
* Descriptor, keeping a reference to the underlying XML element for access to
* scheme-specific attributes.
*
* @param {!shaka.extern.xml.Node} element
* @return {shaka.dash.MpdUtils.Descriptor}
* @private
*/
static normalizeDescriptor_(element) {
return {
schemeIdUri: element.attributes['schemeIdUri'],
value: element.attributes['value'],
id: element.attributes['id'],
element,
};
}
};
/**
* @typedef {{
* timescale: number,
* unscaledSegmentDuration: ?number,
* segmentDuration: ?number,
* startNumber: number,
* scaledPresentationTimeOffset: number,
* unscaledPresentationTimeOffset: number,
* timeline: Array<shaka.media.PresentationTimeline.TimeRange>,
* }}
*
* @description
* Contains common information between SegmentList and SegmentTemplate items.
*
* @property {number} timescale
* The time-scale of the representation.
* @property {?number} unscaledSegmentDuration
* The duration of the segments in timescale units, if given.
* @property {?number} segmentDuration
* The duration of the segments in seconds, if given.
* @property {number} startNumber
* The start number of the segments; 1 or greater.
* @property {number} scaledPresentationTimeOffset
* The presentation time offset of the representation, in seconds.
* @property {number} unscaledPresentationTimeOffset
* The presentation time offset of the representation, in timescale units.
* @property {Array<shaka.media.PresentationTimeline.TimeRange>} timeline
* The timeline of the representation, if given. Times in seconds.
*/
shaka.dash.MpdUtils.SegmentInfo;
/**
* @typedef {{
* schemeIdUri: string,
* value: string,
* id: string,
* element: !shaka.extern.xml.Node,
* }}
*
* @description
* A normalized DASH property descriptor, as carried by EssentialProperty and
* SupplementalProperty elements.
*
* @property {string} schemeIdUri
* The @schemeIdUri attribute identifying the descriptor scheme.
* @property {string} value
* The @value attribute, or undefined if not present.
* @property {string} id
* The @id attribute, or undefined if not present.
* @property {!shaka.extern.xml.Node} element
* The underlying XML element, for access to scheme-specific attributes.
*/
shaka.dash.MpdUtils.Descriptor;
/**
* @const {string}
* @private
*/
shaka.dash.MpdUtils.XlinkNamespaceUri_ = 'http://www.w3.org/1999/xlink';