Source: lib/util/cmcd_manager.js

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

goog.provide('shaka.util.CmcdManager');

goog.require('goog.asserts');
goog.require('cml.cmcd.CMCD_EVENT_KEYS');
goog.require('cml.cmcd.CMCD_HEADERS');
goog.require('cml.cmcd.CMCD_QUERY');
goog.require('cml.cmcd.CMCD_REQUEST_KEYS');
goog.require('cml.cmcd.CMCD_REQUEST_MODE');
goog.require('cml.cmcd.CMCD_RESPONSE_KEYS');
goog.require('cml.cmcd.CMCD_V1');
goog.require('cml.cmcd.CMCD_V1_KEYS');
goog.require('cml.cmcd.CMCD_V2');
goog.require('cml.cmcd.CmcdEventType');
goog.require('cml.cmcd.CmcdObjectType');
goog.require('cml.cmcd.CmcdPlayerState');
goog.require('cml.cmcd.CmcdReporter');
goog.require('cml.cmcd.CmcdStreamType');
goog.require('cml.cmcd.CmcdStreamingFormat');
goog.require('cml.cmcd.appendCmcdQuery');

goog.require('shaka.log');
goog.require('shaka.net.NetworkingEngine');
goog.require('shaka.util.ArrayUtils');
goog.require('shaka.util.EventManager');
goog.require('shaka.util.StringUtils');

goog.requireType('cml.cmcd.Cmcd');
goog.requireType('cml.cmcd.CmcdEncodeOptions');
goog.requireType('cml.cmcd.CmcdReporterConfig');
goog.requireType('shaka.media.SegmentReference');
goog.requireType('shaka.Player');


/**
 * @summary
 * Thin shaka adapter around `cml.cmcd.CmcdReporter`. The vendored
 * reporter owns CMCD state, encoding, key filtering, sequence numbers,
 * and event-mode dispatch. The adapter translates shaka's player /
 * `<video>` events into reporter calls, and shaka's request/response
 * shapes into CML's HttpRequest/HttpResponse shapes.
 *
 * Adapter responsibilities:
 *   1. Lifecycle: construct/start/stop CmcdReporter from configure() / reset()
 *   2. Player wiring: <video> + Player events → reporter.update.
 *      State-change events (PLAY_STATE/BITRATE_CHANGE/BACKGROUNDED_MODE)
 *      auto-fire from update() since v2.4.0; adapter only invokes
 *      recordEvent() for non-state-change events (MUTE/UNMUTE/ERROR/etc.).
 *   3. Request mode: applyRequestData → reporter.createRequestReport
 *   4. Response mode: applyResponseData → reporter.recordResponseReceived
 *   5. Event mode: requester callback → NetworkingEngine.request
 *   6. Config translation: shaka.extern.CmcdConfiguration → CmcdReporterConfig
 *   7. Public-API back-compat: re-exports for StreamingFormat / EventType /
 *      PlayerState
 */
shaka.util.CmcdManager = class {
  /**
   * @param {shaka.Player} player
   * @param {shaka.extern.CmcdConfiguration} config
   */
  constructor(player, config) {
    /** @private {?shaka.Player} */
    this.player_ = player;

    /** @private {?shaka.extern.CmcdConfiguration} */
    this.config_ = config;

    /** @private {HTMLMediaElement} */
    this.video_ = null;

    /** @private {?cml.cmcd.CmcdReporter} */
    this.reporter_ = null;

    /** @private {?string} */
    this.lastPlayerState_ = null;

    /**
     * Streaming format set once at manifest-load time. CTA-5004 / CTA-5004-B
     * define `sf` as a stable manifest-type indicator (`'d'`/`'h'`/`'s'`/
     * `'o'`); the value does not reflect the low-latency state.
     *
     * @private {(cml.cmcd.CmcdStreamingFormat|undefined)}
     */
    this.sf_ = undefined;

    /**
     * Per-request `ts` values for TTFB/TTLB derivation in
     * `applyResponseData`. CmcdReporter cannot observe request-send time;
     * the adapter measures it.
     *
     * @private {!Map<!shaka.extern.Request, number>}
     */
    this.requestTimestampMap_ = new Map();

    /** @private {shaka.util.EventManager} */
    this.eventManager_ = new shaka.util.EventManager();

    /** @private {number} */
    this.startTimeOfLoad_ = 0;
  }

  /**
   * Set the media element and start the reporter (if enabled).
   *
   * @param {HTMLMediaElement} mediaElement The video element
   */
  setMediaElement(mediaElement) {
    this.video_ = mediaElement;
    this.maybeStartReporter_();
    if (this.reporter_) {
      this.setupEventListeners_();
    }
  }

  /**
   * Update the manager's configuration. If `enabled` flips or any
   * material field changes, the reporter is torn down and recreated.
   *
   * @param {shaka.extern.CmcdConfiguration} config
   */
  configure(config) {
    const oldConfig = this.config_;
    this.config_ = config;

    if (this.reporter_) {
      const enabledOff = oldConfig && oldConfig.enabled && !config.enabled;
      const materialChange = oldConfig && config.enabled && (
        oldConfig.sessionId !== config.sessionId ||
        oldConfig.contentId !== config.contentId ||
        oldConfig.version !== config.version ||
        oldConfig.useHeaders !== config.useHeaders ||
        oldConfig.eventTargets !== config.eventTargets);
      if (enabledOff || materialChange) {
        this.reporter_.stop(true);
        this.reporter_ = null;
        this.lastPlayerState_ = null;
      }
    }

    if (!this.reporter_ && this.video_) {
      this.maybeStartReporter_();
      if (this.reporter_) {
        this.setupEventListeners_();
      }
    }
  }

  /**
   * Reset the manager. Stops the reporter and clears session-scoped
   * state. The video element reference is preserved — shaka's lifecycle
   * keeps it attached across `unload()`/`load()` cycles, and only
   * `detach()` releases it.
   */
  reset() {
    if (this.reporter_) {
      this.reporter_.stop(true);
      this.reporter_ = null;
    }
    this.lastPlayerState_ = null;
    this.sf_ = undefined;
    this.requestTimestampMap_.clear();
    this.startTimeOfLoad_ = 0;
    this.eventManager_.removeAll();
  }

  /**
   * Forwarded from Player buffering observer; translates to a
   * REBUFFERING / PLAYING player-state transition.
   *
   * @param {boolean} buffering
   */
  setBuffering(buffering) {
    this.setPlayerState_(buffering ?
        cml.cmcd.CmcdPlayerState.REBUFFERING :
        cml.cmcd.CmcdPlayerState.PLAYING);
  }

  /**
   * No-op shim retained for Player call-site back-compat. Phase 1
   * dropped the non-spec `'ld'`/`'lh'` StreamingFormat values; CTA-5004
   * does not define LL-specific values, so low-latency content emits
   * `sf=d` (DASH) or `sf=h` (HLS).
   *
   * @param {boolean} lowLatency
   */
  setLowLatency(lowLatency) {}

  /**
   * Set start time of load; trigger autoplay-driven start if applicable.
   *
   * @param {number} startTimeOfLoad
   */
  setStartTimeOfLoad(startTimeOfLoad) {
    if (!this.reporter_) {
      return;
    }
    this.startTimeOfLoad_ = startTimeOfLoad;
    if (this.video_ && this.video_.autoplay) {
      const playResult = this.video_.play();
      if (playResult) {
        playResult.catch(() => {
          this.startTimeOfLoad_ = 0;
        });
      }
    }
  }

  /**
   * Apply CMCD data to a request via the reporter.
   *
   * @param {!shaka.net.NetworkingEngine.RequestType} type
   * @param {!shaka.extern.Request} request
   * @param {shaka.extern.RequestContext=} context
   */
  applyRequestData(type, request, context = {}) {
    if (!this.reporter_) {
      return;
    }

    const RequestType = shaka.net.NetworkingEngine.RequestType;
    const ObjectType = cml.cmcd.CmcdObjectType;

    if (request.method === 'HEAD') {
      this.applyToRequest_(request, {});
      return;
    }

    switch (type) {
      case RequestType.MANIFEST: {
        if (context.type) {
          const sf = this.getStreamFormat_(context.type);
          if (sf && sf !== this.sf_) {
            this.sf_ = sf;
            this.reporter_.update(/** @type {!cml.cmcd.Cmcd} */ (
              {sf: sf, st: this.getStreamType_()}));
          }
        }
        this.applyToRequest_(request, /** @type {!cml.cmcd.Cmcd} */ ({
          ot: ObjectType.MANIFEST,
          su: this.lastPlayerState_ === null,
        }));
        break;
      }

      case RequestType.SEGMENT: {
        const data = this.getDataForSegment_(context, request.uris[0]);
        // Track request initiation time for TTFB/TTLB derivation when
        // the response comes back.
        if (this.requestTimestampMap_.has(request)) {
          this.requestTimestampMap_.delete(request);
        }
        this.requestTimestampMap_.set(request, Date.now());
        this.applyToRequest_(request,
            /** @type {!cml.cmcd.Cmcd} */ (data));
        break;
      }

      case RequestType.LICENSE:
      case RequestType.SERVER_CERTIFICATE:
      case RequestType.KEY:
        this.applyToRequest_(request, /** @type {!cml.cmcd.Cmcd} */ (
          {ot: ObjectType.KEY}));
        break;

      case RequestType.TIMING:
        this.applyToRequest_(request, /** @type {!cml.cmcd.Cmcd} */ (
          {ot: ObjectType.OTHER}));
        break;
    }
  }

  /**
   * Apply CMCD data to a response.
   *
   * @param {!shaka.net.NetworkingEngine.RequestType} type
   * @param {!shaka.extern.Response} response
   * @param {shaka.extern.RequestContext=} context
   */
  applyResponseData(type, response, context = {}) {
    if (!this.reporter_) {
      return;
    }

    const RequestType = shaka.net.NetworkingEngine.RequestType;
    if (type !== RequestType.SEGMENT) {
      return;
    }

    // Always clean up the per-request timestamp entry to prevent unbounded
    // map growth when response-mode reporting is disabled (the default when
    // eventTargets is empty). Mirrors the upstream fix in shaka-project#9193.
    const startTs = this.requestTimestampMap_.get(response.originalRequest);
    this.requestTimestampMap_.delete(response.originalRequest);

    if (!this.responseModeEnabled_()) {
      return;
    }

    try {
      const url = this.removeCmcdQueryFromUri_(
          response.originalUri || response.uri);

      /** @type {!cml.cmcd.Cmcd} */
      const data = /** @type {!cml.cmcd.Cmcd} */ ({url: url});
      if (response.status != null) {
        data.rc = response.status;
      }
      if (response.originalRequest &&
          response.originalRequest.timeToFirstByte != null) {
        data.ttfb = response.originalRequest.timeToFirstByte;
      }
      if (response.timeMs != null) {
        data.ttlb = response.timeMs;
      }
      if (startTs != null) {
        data.ts = startTs;
      }
      if (response.headers && response.headers['CMSD-Static']) {
        data.cmsds = btoa(response.headers['CMSD-Static']);
      }
      if (response.headers && response.headers['CMSD-Dynamic']) {
        data.cmsdd = btoa(response.headers['CMSD-Dynamic']);
      }

      // Synthesize a CML-shaped HttpResponse: CmcdReporter reads
      // `request.url` and `status`. We pass `data` as the override so
      // CML's auto-derivation from `resourceTiming` is bypassed
      // (shaka measures these via `requestTimestampMap_`).
      this.reporter_.recordResponseReceived({
        request: {url: url},
        status: response.status || 0,
      }, data);
    } catch (error) {
      shaka.log.warnOnce(
          'CMCD_RESPONSE_ERROR',
          'Could not apply CMCD data to response.', error);
    }
  }

  /**
   * Apply CMCD data to a sidecar text request.
   *
   * @param {!shaka.extern.Request} request
   */
  applyTextData(request) {
    if (!this.reporter_) {
      return;
    }
    this.applyToRequest_(request, /** @type {!cml.cmcd.Cmcd} */ ({
      ot: cml.cmcd.CmcdObjectType.CAPTION,
      su: true,
    }));
  }

  /**
   * Apply CMCD data to streams loaded via `<video src=...>`. Forces
   * query-mode encoding regardless of configured `useHeaders`, since
   * direct media loads cannot carry custom request headers.
   *
   * @param {string} uri
   * @param {string} mimeType
   * @return {string}
   */
  appendSrcData(uri, mimeType) {
    if (!this.reporter_) {
      return uri;
    }
    try {
      const data = this.buildExternalUriData_();
      data.ot = this.getObjectTypeFromMimeType_(mimeType);
      data.su = true;
      return cml.cmcd.appendCmcdQuery(
          uri, data, this.encodeOptionsForUri_(uri));
    } catch (error) {
      shaka.log.warnOnce('CMCD_SRC_ERROR',
          'Could not generate src CMCD data.', error);
      return uri;
    }
  }

  /**
   * Apply CMCD data to a sidecar text track URI.
   *
   * @param {string} uri
   * @return {string}
   */
  appendTextTrackData(uri) {
    if (!this.reporter_) {
      return uri;
    }
    try {
      const data = this.buildExternalUriData_();
      data.ot = cml.cmcd.CmcdObjectType.CAPTION;
      data.su = true;
      return cml.cmcd.appendCmcdQuery(
          uri, data, this.encodeOptionsForUri_(uri));
    } catch (error) {
      shaka.log.warnOnce('CMCD_TEXT_TRACK_ERROR',
          'Could not generate text track CMCD data.', error);
      return uri;
    }
  }

  /**
   * Construct the reporter if `enabled` is set and a video element is
   * available. No-ops if either precondition fails.
   *
   * @private
   */
  maybeStartReporter_() {
    if (this.reporter_ ||
        !this.config_ ||
        !this.config_.enabled ||
        !this.video_) {
      return;
    }
    const reporterConfig = this.toReporterConfig_(this.config_);
    this.reporter_ = new cml.cmcd.CmcdReporter(
        reporterConfig, this.makeRequester_());
    this.reporter_.start();
  }

  /**
   * Translate `shaka.extern.CmcdConfiguration` into
   * `cml.cmcd.CmcdReporterConfig`. Field renames:
   *   - `useHeaders` → `transmissionMode` enum
   *   - `version: 1|2` → `CMCD_V1` / `CMCD_V2` constants
   *   - top-level `targets` → `eventTargets` (Phase 3 experimental rename)
   *   - per-target `timeInterval` → `interval`
   *   - per-target `includeKeys` → `enabledKeys`
   *
   * Empty `includeKeys` is expanded to all valid keys for the version,
   * since CML's reporter early-returns when `enabledKeys` is empty.
   *
   * @param {!shaka.extern.CmcdConfiguration} cfg
   * @return {!cml.cmcd.CmcdReporterConfig}
   * @private
   */
  toReporterConfig_(cfg) {
    if (!cfg.sessionId) {
      cfg.sessionId = window.crypto.randomUUID();
    }
    const includeKeys = (cfg.includeKeys && cfg.includeKeys.length) ?
        cfg.includeKeys : this.allKeysForVersion_(cfg.version);

    /** @type {!cml.cmcd.CmcdReporterConfig} */
    const reporterConfig = /** @type {!cml.cmcd.CmcdReporterConfig} */ ({
      sid: cfg.sessionId,
      cid: cfg.contentId,
      transmissionMode: cfg.useHeaders ?
          cml.cmcd.CMCD_HEADERS : cml.cmcd.CMCD_QUERY,
      enabledKeys: includeKeys,
      version: cfg.version === 2 ? cml.cmcd.CMCD_V2 : cml.cmcd.CMCD_V1,
    });

    const targets = cfg.eventTargets;
    if (targets && targets.length) {
      reporterConfig.eventTargets = targets
          .filter((t) => t.enabled !== false && t.url &&
              t.events && t.events.length)
          .map((t) => {
            const tIncludeKeys = (t.includeKeys && t.includeKeys.length) ?
                t.includeKeys :
                this.allKeysForVersion_(t.version || cfg.version);
            return {
              url: t.url,
              events: t.events,
              interval: t.interval,
              batchSize: t.batchSize,
              enabledKeys: tIncludeKeys,
              version: cml.cmcd.CMCD_V2,
            };
          });
    }
    return reporterConfig;
  }

  /**
   * @param {(number|undefined)} version
   * @return {!Array<string>}
   * @private
   */
  allKeysForVersion_(version) {
    if (version === 2 || version === cml.cmcd.CMCD_V2) {
      return Array.from(new Set([].concat(
          cml.cmcd.CMCD_REQUEST_KEYS,
          cml.cmcd.CMCD_RESPONSE_KEYS,
          cml.cmcd.CMCD_EVENT_KEYS)));
    }
    return [].concat(cml.cmcd.CMCD_V1_KEYS);
  }

  /**
   * Build the `requester` callback CmcdReporter uses for event-mode
   * dispatch. Routes through NetworkingEngine so the request inherits
   * auth / retry / filters.
   *
   * @return {function(!Object): !Promise<{status: number}>}
   * @private
   */
  makeRequester_() {
    return async (cmcdReq) => {
      const RequestType = shaka.net.NetworkingEngine.RequestType;
      const retryParams =
          shaka.net.NetworkingEngine.defaultRetryParameters();
      const url = /** @type {string} */ (cmcdReq['url']);
      const shakaReq =
          shaka.net.NetworkingEngine.makeRequest([url], retryParams);
      shakaReq.method =
        /** @type {string} */ (cmcdReq['method']) || 'POST';
      shakaReq.headers = /** @type {!Object<string, string>} */ (
        cmcdReq['headers']) || {};
      shakaReq.body = shaka.util.StringUtils.toUTF8(
          /** @type {string} */ (cmcdReq['body']));

      try {
        const networkingEngine = this.player_.getNetworkingEngine();
        goog.asserts.assert(networkingEngine, 'Must have net engine');

        await networkingEngine.request(
            RequestType.CMCD, shakaReq).promise;
        return {status: 200};
      } catch (err) {
        return {
          status: /** @type {number} */ (
              (err && err['status']) ? err['status'] : 0),
        };
      }
    };
  }

  /**
   * Player-state deduplication: skip the update + recordEvent pair if
   * the new state is identical to the last-emitted one. Without this,
   * `<video>` events can fire repeatedly on stable states (e.g.,
   * `playing` after every minor stall) and spam the reporter.
   *
   * @param {!cml.cmcd.CmcdPlayerState} state
   * @private
   */
  setPlayerState_(state) {
    if (!this.reporter_ || this.lastPlayerState_ === state) {
      return;
    }
    this.lastPlayerState_ = state;
    // CmcdReporter.update() auto-fires PLAY_STATE since v2.4.0
    // when sta changes vs. lastEmitted.
    this.reporter_.update(/** @type {!cml.cmcd.Cmcd} */ ({sta: state}));
  }

  /**
   * Push a CMCD-applied request through `createRequestReport`. Mutates
   * `request` in place to preserve shaka's existing applyRequestData
   * contract.
   *
   * @param {!shaka.extern.Request} request
   * @param {!cml.cmcd.Cmcd} data
   * @private
   */
  applyToRequest_(request, data) {
    try {
      /** @type {!Object<string, *>} */
      const cmlReq = {
        url: request.uris[0],
        headers: Object.assign({}, request.headers || {}),
        customData: {},
      };
      const report = /** @type {!Object<string, *>} */ (
        this.reporter_.createRequestReport(cmlReq, data));

      // Headers mode: copy CMCD-* headers back onto the request.
      const reportHeaders = /** @type {!Object<string, string>} */ (
        report['headers'] || {});
      const inputHeaderCount =
          Object.keys(cmlReq.headers).length;
      const newHeaderCount = Object.keys(reportHeaders).length;
      if (newHeaderCount > inputHeaderCount) {
        if (!request.headers) {
          request.headers = {};
        }
        for (const key of Object.keys(reportHeaders)) {
          if (!(key in cmlReq.headers)) {
            request.headers[key] = reportHeaders[key];
          }
        }
      }

      // Query mode: report.url has CMCD appended. Apply to all URIs in
      // the retry list (alternates carry identical CMCD).
      const reportUrl = /** @type {string} */ (report['url']);
      if (reportUrl && reportUrl !== cmlReq.url) {
        if (request.uris.length === 1) {
          request.uris = [reportUrl];
        } else {
          let cmcdParam = null;
          try {
            const u = new URL(reportUrl);
            cmcdParam = u.searchParams.get('CMCD');
          } catch (e) {
            // First URI not parsable; leave list untouched.
          }
          if (cmcdParam != null) {
            const param = cmcdParam;
            request.uris = request.uris.map((uri) => {
              if (uri.includes('offline:')) {
                return uri;
              }
              try {
                const uu = new URL(uri);
                uu.searchParams.set('CMCD', param);
                return uu.toString();
              } catch (e) {
                return uri;
              }
            });
          }
        }
      }
    } catch (error) {
      shaka.log.warnOnce('CMCD_REQUEST_ERROR',
          'Could not apply CMCD data to request.', error);
    }
  }

  /**
   * Whether any configured `eventTarget` subscribes to the `'rr'`
   * (response-received) event. Saves recording response-received calls
   * into the reporter when no target consumes them.
   *
   * @return {boolean}
   * @private
   */
  responseModeEnabled_() {
    const targets = this.config_.eventTargets;
    if (!targets) {
      return false;
    }
    return targets.some((target) =>
      target.enabled !== false &&
      target.events &&
      target.events.includes(cml.cmcd.CmcdEventType.RESPONSE_RECEIVED));
  }

  /**
   * Persistent CMCD baseline used by `appendSrcData` /
   * `appendTextTrackData`. These bypass the reporter (browsers can't
   * observe headers for direct media loads, so we always force query
   * mode regardless of configured `useHeaders`); `sn` is omitted since
   * the reporter's per-target counters don't apply.
   *
   * @return {!cml.cmcd.Cmcd}
   * @private
   */
  buildExternalUriData_() {
    return /** @type {!cml.cmcd.Cmcd} */ ({
      v: this.config_.version,
      sid: this.config_.sessionId,
      cid: this.config_.contentId,
      sf: this.sf_,
      mtp: this.player_.getBandwidthEstimate() / 1000,
    });
  }

  /**
   * Encode options for direct `cml.cmcd.appendCmcdQuery` calls
   * (`appendSrcData`, `appendTextTrackData`). `baseUrl` lets CML
   * relativize `nor` URLs root-relative; `version` filters keys per
   * spec; `reportingMode = REQUEST` pins the request-mode key filter.
   *
   * @param {string} uri
   * @return {!cml.cmcd.CmcdEncodeOptions}
   * @private
   */
  encodeOptionsForUri_(uri) {
    /** @type {!cml.cmcd.CmcdEncodeOptions} */
    const options = /** @type {!cml.cmcd.CmcdEncodeOptions} */ ({});
    if (uri && !uri.includes('offline:')) {
      try {
        options.baseUrl = new URL(uri).origin;
      } catch (e) {
        // Unparsable URI; CML's `nor` formatter passes values through
        // unchanged when baseUrl is absent.
      }
    }
    if (this.config_.version != null) {
      options.version = this.config_.version;
    }
    options.reportingMode = cml.cmcd.CMCD_REQUEST_MODE;
    return options;
  }

  /**
   * Wire `<video>` and Player events to reporter calls per spec
   * § "Player state ↔ reporter state mapping".
   *
   * @private
   */
  setupEventListeners_() {
    const PlayerState = cml.cmcd.CmcdPlayerState;
    const EventType = cml.cmcd.CmcdEventType;

    this.eventManager_.listen(this.video_, 'pause', () => {
      this.setPlayerState_(PlayerState.PAUSED);
    });

    this.eventManager_.listen(this.video_, 'playing', () => {
      this.setPlayerState_(PlayerState.PLAYING);
      // Compute and emit Measured Start Delay (msd) on the first 'playing'
      // event after setStartTimeOfLoad() has been called. CmcdReporter
      // sends msd once per target on the first request; zeroing
      // startTimeOfLoad_ here prevents re-emission on subsequent 'playing'
      // events within the same session.
      if (this.reporter_ && this.startTimeOfLoad_) {
        this.reporter_.update(/** @type {!cml.cmcd.Cmcd} */ (
          {msd: Date.now() - this.startTimeOfLoad_}));
        this.startTimeOfLoad_ = 0;
      }
    });

    this.eventManager_.listen(this.video_, 'seeking', () => {
      this.setPlayerState_(PlayerState.SEEKING);
    });

    this.eventManager_.listen(this.video_, 'ended', () => {
      this.setPlayerState_(PlayerState.ENDED);
    });

    this.eventManager_.listen(this.video_, 'volumechange', () => {
      if (!this.reporter_ || !this.video_) {
        return;
      }
      const muted = this.video_.muted;
      // Mute state isn't a persistent CMCD wire key (CTA-5004-B uses
      // m/um events, not a `muted` data field). Emit the event only.
      this.reporter_.recordEvent(muted ? EventType.MUTE : EventType.UNMUTE);
    });

    this.eventManager_.listen(this.player_, 'buffering', (e) => {
      const buffering = /** @type {!Object} */ (e)['buffering'];
      this.setPlayerState_(buffering ?
          PlayerState.REBUFFERING : PlayerState.PLAYING);
    });

    this.eventManager_.listen(this.player_, 'adaptation', () => {
      if (!this.reporter_ || !this.player_) {
        return;
      }
      const variants = this.player_.getVariantTracks();
      const active = variants.find((t) => t.active);
      if (active && active.bandwidth) {
        // CmcdReporter.update() auto-fires BITRATE_CHANGE since v2.4.0
        // when br changes vs. lastEmitted.
        this.reporter_.update(/** @type {!cml.cmcd.Cmcd} */ (
          {br: [active.bandwidth / 1000]}));
      }
    });

    this.eventManager_.listen(this.player_, 'error', () => {
      this.setPlayerState_(PlayerState.FATAL_ERROR);
      if (this.reporter_) {
        this.reporter_.recordEvent(EventType.ERROR);
      }
    });

    this.eventManager_.listen(document, 'fullscreenchange', () => {
      if (!this.reporter_) {
        return;
      }
      const isFullscreen = !!document.fullscreenElement;
      this.reporter_.recordEvent(isFullscreen ?
          EventType.PLAYER_EXPAND : EventType.PLAYER_COLLAPSE);
    });

    const video = /** @type {HTMLVideoElement} */ (this.video_);
    if (video.webkitPresentationMode || video.webkitSupportsFullscreen) {
      this.eventManager_.listen(video, 'webkitpresentationmodechanged', () => {
        if (!this.reporter_) {
          return;
        }
        if (video.webkitPresentationMode) {
          this.reporter_.recordEvent(
              video.webkitPresentationMode !== 'inline' ?
                  EventType.PLAYER_EXPAND : EventType.PLAYER_COLLAPSE);
        } else if (video.webkitSupportsFullscreen) {
          this.reporter_.recordEvent(
              video.webkitDisplayingFullscreen ?
                  EventType.PLAYER_EXPAND : EventType.PLAYER_COLLAPSE);
        }
      });
    }

    this.eventManager_.listen(this.video_, 'enterpictureinpicture', () => {
      if (this.reporter_) {
        this.reporter_.recordEvent(EventType.PLAYER_EXPAND);
      }
    });

    this.eventManager_.listen(this.video_, 'leavepictureinpicture', () => {
      if (this.reporter_) {
        this.reporter_.recordEvent(EventType.PLAYER_COLLAPSE);
      }
    });

    if ('documentPictureInPicture' in window) {
      this.eventManager_.listen(window.documentPictureInPicture, 'enter',
          (e) => {
            if (!this.reporter_) {
              return;
            }
            this.reporter_.recordEvent(EventType.PLAYER_EXPAND);
            const event = /** @type {DocumentPictureInPictureEvent} */ (e);
            const pipWindow = event.window;
            this.eventManager_.listenOnce(pipWindow, 'pagehide', () => {
              if (this.reporter_) {
                this.reporter_.recordEvent(EventType.PLAYER_COLLAPSE);
              }
            });
          });
    }

    this.eventManager_.listen(document, 'visibilitychange', () => {
      if (!this.reporter_) {
        return;
      }
      const backgrounded = document.hidden;
      // CmcdReporter.update() auto-fires BACKGROUNDED_MODE since v2.4.0
      // when bg changes vs. lastEmitted.
      this.reporter_.update(/** @type {!cml.cmcd.Cmcd} */ (
        {bg: backgrounded}));
    });

    this.eventManager_.listen(this.player_, 'complete', () => {
      this.setPlayerState_(PlayerState.ENDED);
    });
  }

  /**
   * Strip the CMCD query parameter from a URL.
   *
   * @param {string} uri
   * @return {string}
   * @private
   */
  removeCmcdQueryFromUri_(uri) {
    if (!uri.includes('CMCD=')) {
      return uri;
    }
    try {
      const url = new URL(uri);
      url.searchParams.delete('CMCD');
      return url.toString();
    } catch (error) {
      shaka.log.error('Failed to parse URI for CMCD removal:', uri, error);
      return uri;
    }
  }

  /**
   * Map a shaka request context to a CMCD object type.
   *
   * @param {shaka.extern.RequestContext} context
   * @return {(cml.cmcd.CmcdObjectType|undefined)}
   * @private
   */
  getObjectType_(context) {
    if (context.type ===
        shaka.net.NetworkingEngine.AdvancedRequestType.INIT_SEGMENT) {
      return cml.cmcd.CmcdObjectType.INIT;
    }
    const stream = context.stream;
    if (!stream) {
      return undefined;
    }
    const type = stream.type;
    if (type == 'video') {
      if (stream.codecs && stream.codecs.includes(',')) {
        return cml.cmcd.CmcdObjectType.MUXED;
      }
      return cml.cmcd.CmcdObjectType.VIDEO;
    }
    if (type == 'audio') {
      return cml.cmcd.CmcdObjectType.AUDIO;
    }
    if (type == 'text') {
      if (stream.mimeType === 'application/mp4') {
        return cml.cmcd.CmcdObjectType.TIMED_TEXT;
      }
      return cml.cmcd.CmcdObjectType.CAPTION;
    }
    return undefined;
  }

  /**
   * Map a mimeType to a CMCD object type. Used by `appendSrcData`.
   *
   * @param {string} mimeType
   * @return {(cml.cmcd.CmcdObjectType|undefined)}
   * @private
   */
  getObjectTypeFromMimeType_(mimeType) {
    switch (mimeType.toLowerCase()) {
      case 'audio/mp4':
      case 'audio/webm':
      case 'audio/ogg':
      case 'audio/mpeg':
      case 'audio/aac':
      case 'audio/flac':
      case 'audio/wav':
        return cml.cmcd.CmcdObjectType.AUDIO;

      case 'video/webm':
      case 'video/mp4':
      case 'video/mpeg':
      case 'video/mp2t':
        return cml.cmcd.CmcdObjectType.MUXED;

      case 'application/x-mpegurl':
      case 'application/vnd.apple.mpegurl':
      case 'audio/x-mpegurl':
      case 'application/dash+xml':
      case 'video/vnd.mpeg.dash.mpd':
      case 'application/vnd.ms-sstr+xml':
        return cml.cmcd.CmcdObjectType.MANIFEST;

      default:
        return undefined;
    }
  }

  /**
   * Buffer length in milliseconds for a media type.
   *
   * @param {string} type
   * @return {number}
   * @private
   */
  getBufferLength_(type) {
    const ranges = this.player_.getBufferedInfo()[type];
    if (!ranges.length) {
      return NaN;
    }
    const start = this.getCurrentTime_();
    const range = ranges.find((r) => r.start <= start && r.end >= start);
    if (!range) {
      return NaN;
    }
    return (range.end - start) * 1000;
  }

  /**
   * Remaining buffer length in milliseconds.
   *
   * @param {string} type
   * @return {number}
   * @private
   */
  getRemainingBufferLength_(type) {
    const ranges = this.player_.getBufferedInfo()[type];
    if (!ranges.length) {
      return 0;
    }
    const start = this.getCurrentTime_();
    const range = ranges.find((r) => r.start <= start && r.end >= start);
    if (!range) {
      return 0;
    }
    return (range.end - start) * 1000;
  }

  /**
   * Computed `rtp` (requested maximum throughput) for a segment.
   *
   * @param {shaka.extern.Stream} stream
   * @param {shaka.media.SegmentReference} segment
   * @return {number}
   * @private
   */
  calculateRtp_(stream, segment) {
    const playbackRate = this.player_.getPlaybackRate() || 1;
    const currentBufferLevel =
        this.getRemainingBufferLength_(stream.type) || 500;
    const bandwidth = stream.bandwidth;
    if (!bandwidth) {
      return NaN;
    }
    const segmentDuration = segment.endTime - segment.startTime;
    const segmentSize = bandwidth * segmentDuration / 1000;
    const timeToLoad = (currentBufferLevel / playbackRate) / 1000;
    const minBandwidth = segmentSize / timeToLoad;
    return minBandwidth * this.config_.rtpSafetyFactor;
  }

  /**
   * Resolve `sf` from the manifest-parser advanced-request type.
   *
   * @param {shaka.net.NetworkingEngine.AdvancedRequestType} type
   * @return {(cml.cmcd.CmcdStreamingFormat|undefined)}
   * @private
   */
  getStreamFormat_(type) {
    const AdvancedRequestType = shaka.net.NetworkingEngine.AdvancedRequestType;
    switch (type) {
      case AdvancedRequestType.MPD:
        return cml.cmcd.CmcdStreamingFormat.DASH;
      case AdvancedRequestType.MASTER_PLAYLIST:
      case AdvancedRequestType.MEDIA_PLAYLIST:
        return cml.cmcd.CmcdStreamingFormat.HLS;
    }
    return undefined;
  }

  /**
   * @return {cml.cmcd.CmcdStreamType}
   * @private
   */
  getStreamType_() {
    return this.player_.isLive() ?
        cml.cmcd.CmcdStreamType.LIVE :
        cml.cmcd.CmcdStreamType.VOD;
  }

  /**
   * Top-bandwidth value across variants for a given object type.
   *
   * @param {(cml.cmcd.CmcdObjectType|undefined)} type
   * @return {number}
   * @private
   */
  getTopBandwidth_(type) {
    const variants = this.player_.getVariantTracks();
    if (!variants.length) {
      return NaN;
    }
    let top = variants[0];
    for (const variant of variants) {
      if (variant.type === 'variant' && variant.bandwidth > top.bandwidth) {
        top = variant;
      }
    }
    const ObjectType = cml.cmcd.CmcdObjectType;
    switch (type) {
      case ObjectType.VIDEO:
        return top.videoBandwidth || NaN;
      case ObjectType.AUDIO:
        return top.audioBandwidth || NaN;
      default:
        return top.bandwidth;
    }
  }

  /**
   * Build the per-segment CMCD payload from shaka's request context.
   *
   * @param {shaka.extern.RequestContext} context
   * @param {?string} requestUri
   * @return {!Object<string, *>}
   * @private
   */
  getDataForSegment_(context, requestUri) {
    const segment = context.segment;
    let duration = 0;
    if (segment) {
      duration = segment.endTime - segment.startTime;
    }

    /** @type {!Object<string, *>} */
    const data = {
      d: duration * 1000,
      st: this.getStreamType_(),
    };
    data['ot'] = this.getObjectType_(context);

    const ObjectType = cml.cmcd.CmcdObjectType;
    const isMedia = data['ot'] === ObjectType.VIDEO ||
        data['ot'] === ObjectType.AUDIO ||
        data['ot'] === ObjectType.MUXED ||
        data['ot'] === ObjectType.TIMED_TEXT;

    const stream = context.stream;
    if (stream) {
      const playbackRate = this.player_.getPlaybackRate();
      if (isMedia) {
        const bl = this.getBufferLength_(stream.type);
        if (Number.isFinite(bl)) {
          data['bl'] = bl;
        }
        if (data['ot'] !== ObjectType.TIMED_TEXT) {
          const remainingBufferLength =
              this.getRemainingBufferLength_(stream.type);
          if (playbackRate) {
            data['dl'] = remainingBufferLength / Math.abs(playbackRate);
          } else {
            data['dl'] = remainingBufferLength;
          }
        }
      }

      if (stream.bandwidth) {
        data['br'] = stream.bandwidth / 1000;
      }

      if (stream.segmentIndex && segment) {
        const reverse = playbackRate < 0;
        const iterator = stream.segmentIndex.getIteratorForTime(
            segment.endTime, /* allowNonIndependent= */ true, reverse);
        if (iterator) {
          const nextSegment = iterator.next().value;
          if (nextSegment && nextSegment != segment) {
            if (requestUri && !shaka.util.ArrayUtils.equal(
                segment.getUris(), nextSegment.getUris())) {
              data['nor'] = nextSegment.getUris()[0];
            }
            if ((nextSegment.startByte || nextSegment.endByte) &&
                (segment.startByte != nextSegment.startByte ||
                segment.endByte != nextSegment.endByte)) {
              let range = nextSegment.startByte + '-';
              if (nextSegment.endByte) {
                range += nextSegment.endByte;
              }
              data['nrr'] = range;
            }
          }
        }
        const rtp = this.calculateRtp_(stream, segment);
        if (Number.isFinite(rtp)) {
          data['rtp'] = rtp;
        }
      }
    }

    if (isMedia && data['ot'] !== ObjectType.TIMED_TEXT) {
      const tb = this.getTopBandwidth_(
          /** @type {(cml.cmcd.CmcdObjectType|undefined)} */ (
            data['ot'])) / 1000;
      if (Number.isFinite(tb)) {
        data['tb'] = tb;
      }
    }

    if (this.player_.isLive()) {
      const liveLatency = this.player_.getLiveLatency();
      if (liveLatency) {
        data['ltc'] = liveLatency;
      }
    }

    // Set the startup flag: true when the player has not yet transitioned
    // to a playing state (lastPlayerState_ is null = initial buffering) or
    // is actively rebuffering. Mirrors pre-refactor: `data.su = buffering_`.
    data['su'] = this.lastPlayerState_ === null ||
        this.lastPlayerState_ === cml.cmcd.CmcdPlayerState.REBUFFERING;

    return data;
  }

  /**
   * @return {number}
   * @private
   */
  getCurrentTime_() {
    return this.video_ ? this.video_.currentTime : 0;
  }
};

/**
 * Public re-export of CMCD streaming-format values. The literal object
 * form is required for Closure tooling: clutz (TypeScript-defs gen)
 * and `generateExterns.js` reject `@export`ed `@enum`s whose RHS is a
 * MemberExpression / alias. The 4 values match
 * `cml.cmcd.CmcdStreamingFormat` exactly; a unit test asserts value
 * identity.
 *
 * @enum {string}
 * @export
 */
shaka.util.CmcdManager.StreamingFormat = {
  DASH: 'd',
  HLS: 'h',
  SMOOTH: 's',
  OTHER: 'o',
};

/**
 * Public re-export of CMCD v2 event types. Literal form per the
 * Closure-tooling constraint above. Values match `cml.cmcd.CmcdEventType`
 * exactly; unit-test asserts identity.
 *
 * @enum {string}
 * @export
 */
shaka.util.CmcdManager.EventType = {
  BITRATE_CHANGE: 'bc',
  PLAY_STATE: 'ps',
  PLAYBACK_RATE: 'pr',
  ERROR: 'e',
  TIME_INTERVAL: 't',
  CONTENT_ID: 'c',
  BACKGROUNDED_MODE: 'b',
  MUTE: 'm',
  UNMUTE: 'um',
  PLAYER_EXPAND: 'pe',
  PLAYER_COLLAPSE: 'pc',
  RESPONSE_RECEIVED: 'rr',
  AD_START: 'as',
  AD_END: 'ae',
  AD_BREAK_START: 'abs',
  AD_BREAK_END: 'abe',
  SKIP: 'sk',
  CUSTOM_EVENT: 'ce',
};

/**
 * Public re-export of CMCD v2 player states. Literal form per the
 * Closure-tooling constraint above. Values match `cml.cmcd.CmcdPlayerState`
 * exactly; unit-test asserts identity.
 *
 * @enum {string}
 * @export
 */
shaka.util.CmcdManager.PlayerState = {
  STARTING: 's',
  PLAYING: 'p',
  SEEKING: 'k',
  REBUFFERING: 'r',
  PAUSED: 'a',
  WAITING: 'w',
  ENDED: 'e',
  FATAL_ERROR: 'f',
  QUIT: 'q',
  PRELOADING: 'd',
};