Source: lib/dash/service_description_parser.js

/*! @license
 * Shaka Player
 * Copyright 2016 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

goog.provide('shaka.dash.ServiceDescriptionParser');

goog.require('shaka.log');
goog.require('shaka.util.TXml');


/**
 * @summary
 * Parses MPD-level ServiceDescription elements (ISO/IEC 23009-1:2026
 * Annex K) into a shaka.extern.ServiceDescription.
 *
 * Selection rules:
 *   1. Descriptions are visited in document order.
 *   2. A description that carries Scope children is ignored unless one of
 *      them names a scheme this player recognizes, per K.4.2.1: clients
 *      that recognize none of the scope descriptors must ignore the
 *      description. The DVB DASH low-latency scope is recognized; the
 *      event-activation scope (K.6) is not, which keeps event-activated
 *      descriptions from being applied at load time.
 *   3. Latency and PlaybackRate come from the first remaining description
 *      that has them.
 *   4. ClientDataReporting comes from the first remaining description that
 *      has one with a supported scheme.
 */
shaka.dash.ServiceDescriptionParser = class {
  /**
   * @param {!shaka.extern.xml.Node} mpd
   * @param {!Array<shaka.extern.ServiceLocationBaseUri>} baseUris The
   *   MPD-level BaseURL and Location elements that carry a serviceLocation
   *   attribute, resolved to absolute URIs by the caller the same way it
   *   resolves request URIs. They are attached to the parsed
   *   ClientDataReporting so the CMCD service-location filter can match
   *   requests by URI prefix.
   * @return {?shaka.extern.ServiceDescription}
   */
  static parse(mpd, baseUris) {
    const TXml = shaka.util.TXml;
    const Parser = shaka.dash.ServiceDescriptionParser;

    /** @type {?shaka.extern.ServiceDescription} */
    let description = null;
    let latencyParsed = false;
    let reportingParsed = false;

    for (const elem of TXml.findChildren(mpd, 'ServiceDescription')) {
      const scopes = TXml.findChildren(elem, 'Scope');
      const recognized = scopes.some((scope) =>
        Parser.RECOGNIZED_SCOPES_.includes(scope.attributes['schemeIdUri']));
      if (scopes.length && !recognized) {
        shaka.log.debug('Ignoring ServiceDescription with unrecognized scopes',
            elem.attributes['id']);
        continue;
      }

      if (!latencyParsed) {
        const latency = Parser.parseLatencyAndPlaybackRate_(elem);
        if (latency) {
          description = description || Parser.createEmpty_();
          Object.assign(description, latency);
          latencyParsed = true;
        }
      }

      if (!reportingParsed) {
        const reporting = Parser.parseClientDataReporting_(elem, baseUris);
        if (reporting) {
          description = description || Parser.createEmpty_();
          description.clientDataReporting = reporting;
          reportingParsed = true;
        }
      }

      if (latencyParsed && reportingParsed) {
        break;
      }
    }

    return description;
  }

  /**
   * A description with no latency targets and no client data reporting.
   * Latency fields are deliberately left undefined so existing consumers
   * that test for `undefined` keep working.
   *
   * @return {!shaka.extern.ServiceDescription}
   * @private
   */
  static createEmpty_() {
    return /** @type {!shaka.extern.ServiceDescription} */ ({
      clientDataReporting: null,
    });
  }

  /**
   * Reads the Latency and PlaybackRate children of a ServiceDescription.
   *
   * @param {!shaka.extern.xml.Node} elem
   * @return {?Object} Partial description with only the attributes that are
   *   present and parse, or null when neither child exists.
   * @private
   */
  static parseLatencyAndPlaybackRate_(elem) {
    const TXml = shaka.util.TXml;
    const latencyNode = TXml.findChild(elem, 'Latency');
    const playbackRateNode = TXml.findChild(elem, 'PlaybackRate');

    if (!latencyNode && !playbackRateNode) {
      return null;
    }

    const result = {};

    if (latencyNode) {
      const target = TXml.parseAttr(latencyNode, 'target', TXml.parseInt);
      if (target != null) {
        result.targetLatency = target / 1000;
      }
      const max = TXml.parseAttr(latencyNode, 'max', TXml.parseInt);
      if (max != null) {
        result.maxLatency = max / 1000;
      }
      const min = TXml.parseAttr(latencyNode, 'min', TXml.parseInt);
      if (min != null) {
        result.minLatency = min / 1000;
      }
    }

    if (playbackRateNode) {
      const max = TXml.parseAttr(playbackRateNode, 'max', TXml.parseFloat);
      if (max != null) {
        result.maxPlaybackRate = max;
      }
      const min = TXml.parseAttr(playbackRateNode, 'min', TXml.parseFloat);
      if (min != null) {
        result.minPlaybackRate = min;
      }
    }

    return result;
  }

  /**
   * Reads the first ClientDataReporting child with a supported CMCD scheme.
   * The scheme is taken from ClientDataReporting@schemeIdUri and, for
   * lenience with MPDs written against dash.js samples, falls back to
   * CMCDParameters@schemeIdUri.
   *
   * @param {!shaka.extern.xml.Node} elem The ServiceDescription element.
   * @param {!Array<shaka.extern.ServiceLocationBaseUri>} baseUris
   * @return {?shaka.extern.ClientDataReporting}
   * @private
   */
  static parseClientDataReporting_(elem, baseUris) {
    const TXml = shaka.util.TXml;
    const Parser = shaka.dash.ServiceDescriptionParser;

    for (const reporting of TXml.findChildren(elem, 'ClientDataReporting')) {
      // Table K.16 and the schema allow 0..N CMCDParameters children
      // (maxOccurs="unbounded") without saying what several mean. Use the
      // first rather than TXml.findChild, which would treat two as none and
      // drop the whole reporting element.
      const paramsList = TXml.findChildren(reporting, 'CMCDParameters');
      const params = paramsList[0] || null;
      const schemeIdUri = reporting.attributes['schemeIdUri'] ||
          (params ? params.attributes['schemeIdUri'] : null) || '';
      if (schemeIdUri != Parser.CMCD_SCHEME_) {
        shaka.log.info(
            'Ignoring ClientDataReporting with unsupported scheme',
            schemeIdUri);
        continue;
      }
      if (paramsList.length > 1) {
        shaka.log.warning('ClientDataReporting has several CMCDParameters ' +
            'children; using the first one.');
      }
      return {
        schemeIdUri: schemeIdUri,
        serviceLocations: TXml.parseAttr(
            reporting, 'serviceLocations', TXml.parseStringList),
        adaptationSets: TXml.parseAttr(
            reporting, 'adaptationSets', TXml.parseStringList),
        serviceLocationBaseUris: baseUris,
        cmcdParameters: params ? Parser.parseCmcdParameters_(params) : null,
      };
    }
    return null;
  }

  /**
   * Reads a CMCDParameters element (Table K.8 / K.17). As with every other
   * attribute parsed through TXml.parseAttr, an absent, empty or invalid
   * value takes the spec default; parsing never throws.
   *
   * @param {!shaka.extern.xml.Node} node
   * @return {!shaka.extern.CmcdParameters}
   * @private
   */
  static parseCmcdParameters_(node) {
    const TXml = shaka.util.TXml;
    const Parser = shaka.dash.ServiceDescriptionParser;

    // @version is the highest version the reporting server accepts, so
    // anything up to it is acceptable; clamp to what shaka supports.
    const version = Math.min(
        /** @type {number} */ (TXml.parseAttr(
            node, 'version', TXml.parsePositiveInt, 1)),
        Parser.MAX_CMCD_VERSION_);

    const mode = /** @type {string} */ (TXml.parseAttr(node, 'mode',
        (value) => (value == 'query' || value == 'header') ? value : null,
        'query'));

    const includeInRequests = /** @type {!Array<string>} */ (TXml.parseAttr(
        node, 'includeInRequests', Parser.parseRequestTypes_, ['segment']));

    let keys = TXml.parseAttr(node, 'keys', TXml.parseStringList);
    if (!keys || !keys.length) {
      // @keys is mandatory, and an attribute that lists nothing is not a
      // selection of zero keys; both fall back to the player configuration.
      shaka.log.warning('CMCDParameters@keys is mandatory; falling back to ' +
          'the player configuration keys.');
      keys = null;
    }

    return {
      version: version,
      mode: mode,
      includeInRequests: includeInRequests,
      keys: keys,
      contentId: TXml.parseAttr(node, 'contentID', Parser.parseId_),
      sessionId: TXml.parseAttr(node, 'sessionID', Parser.parseId_),
    };
  }

  /**
   * Parses CMCDParameters@includeInRequests, keeping the request type tokens
   * this player understands.
   *
   * @param {string} value
   * @return {!Array<string>} Possibly empty, in which case CMCD is attached
   *   to no request at all.
   * @private
   */
  static parseRequestTypes_(value) {
    const Parser = shaka.dash.ServiceDescriptionParser;
    const tokens = [];
    for (const token of shaka.util.TXml.parseStringList(value)) {
      if (token.includes(':')) {
        // URN / tag URI request types are dropped when unknown (I.3.6).
        continue;
      }
      if (Parser.REQUEST_TYPES_.includes(token)) {
        tokens.push(token);
      } else {
        shaka.log.warning(
            'Ignoring unknown CMCDParameters@includeInRequests token', token);
      }
    }
    if (!tokens.length) {
      shaka.log.warning('CMCDParameters@includeInRequests has no usable ' +
          'request types; CMCD will not be attached to any request.');
    }
    return tokens;
  }

  /**
   * Parses a contentID / sessionID attribute, enforcing the 1..64 character
   * limit from Table K.8.
   *
   * @param {string} value
   * @return {?string}
   * @private
   */
  static parseId_(value) {
    const id = value.trim();
    if (!id.length || id.length > 64) {
      shaka.log.warning(
          'Ignoring a CMCDParameters id outside 1 to 64 characters:', value);
      return null;
    }
    return id;
  }
};

/**
 * The descriptor scheme that identifies CMCD client data reporting
 * (ISO/IEC 23009-1:2026 K.4.2.7.2). dash.js also accepts a DASH-IF alias,
 * urn:dashif:cta-5004:2025, which has no registry entry; it is deliberately
 * not accepted here.
 *
 * @const {string}
 * @private
 */
shaka.dash.ServiceDescriptionParser.CMCD_SCHEME_ =
    'urn:mpeg:dash:cta-5004:2023';

/**
 * Scope schemes this player recognizes (ISO/IEC 23009-1:2026 Table K.10).
 * DVB-DASH low-latency MPDs (ETSI TS 103 285 clause 10.20.3, DASH-IF
 * CR-Low-Latency-Live-r8 clause 9) scope their low-latency
 * ServiceDescription, and shaka has always honored its Latency and
 * PlaybackRate targets.
 *
 * @const {!Array<string>}
 * @private
 */
shaka.dash.ServiceDescriptionParser.RECOGNIZED_SCOPES_ = [
  'urn:dvb:dash:lowlatency:scope:2019',
];

/**
 * Request type tokens from ISO/IEC 23009-1:2026 Table I.4, plus '*'.
 *
 * @const {!Array<string>}
 * @private
 */
shaka.dash.ServiceDescriptionParser.REQUEST_TYPES_ = [
  'segment', 'init', 'xlink', 'mpd', 'callback', 'chaining', 'fallback',
  'sbd', 'steering', 'mpdpatch', 'altmpd', 'mpdlink', '*',
];

/**
 * Highest CMCD version this player can emit.
 *
 * @const {number}
 * @private
 */
shaka.dash.ServiceDescriptionParser.MAX_CMCD_VERSION_ = 2;