Source: lib/dash/mpd_utils.js

/*! @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';