Source: lib/drm/drm_engine.js

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

goog.provide('shaka.drm.DrmEngine');

goog.require('goog.asserts');
goog.require('shaka.log');
goog.require('shaka.drm.DrmUtils');
goog.require('shaka.net.NetworkingEngine');
goog.require('shaka.util.ArrayUtils');
goog.require('shaka.util.BufferUtils');
goog.require('shaka.util.Destroyer');
goog.require('shaka.util.Error');
goog.require('shaka.util.EventManager');
goog.require('shaka.util.FakeEvent');
goog.require('shaka.util.Functional');
goog.require('shaka.util.IDestroyable');
goog.require('shaka.util.Iterables');
goog.require('shaka.util.ManifestParserUtils');
goog.require('shaka.util.MapUtils');
goog.require('shaka.util.ObjectUtils');
goog.require('shaka.util.Platform');
goog.require('shaka.util.Pssh');
goog.require('shaka.util.PublicPromise');
goog.require('shaka.util.StreamUtils');
goog.require('shaka.util.StringUtils');
goog.require('shaka.util.Timer');
goog.require('shaka.util.TXml');
goog.require('shaka.util.Uint8ArrayUtils');


/** @implements {shaka.util.IDestroyable} */
shaka.drm.DrmEngine = class {
  /**
   * @param {shaka.drm.DrmEngine.PlayerInterface} playerInterface
   */
  constructor(playerInterface) {
    /** @private {?shaka.drm.DrmEngine.PlayerInterface} */
    this.playerInterface_ = playerInterface;

    /** @private {MediaKeys} */
    this.mediaKeys_ = null;

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

    /** @private {boolean} */
    this.initialized_ = false;

    /** @private {boolean} */
    this.initializedForStorage_ = false;

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

    /** @private {?shaka.extern.DrmInfo} */
    this.currentDrmInfo_ = null;

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

    /**
     * @private {!Map.<MediaKeySession,
     *           shaka.drm.DrmEngine.SessionMetaData>}
     */
    this.activeSessions_ = new Map();

    /** @private {!Array.<!shaka.net.NetworkingEngine.PendingRequest>} */
    this.activeRequests_ = [];

    /**
     * @private {!Map<string,
     *           {initData: ?Uint8Array, initDataType: ?string}>}
     */
    this.storedPersistentSessions_ = new Map();

    /** @private {boolean} */
    this.hasInitData_ = false;

    /** @private {!shaka.util.PublicPromise} */
    this.allSessionsLoaded_ = new shaka.util.PublicPromise();

    /** @private {?shaka.extern.DrmConfiguration} */
    this.config_ = null;

    /** @private {function(!shaka.util.Error)} */
    this.onError_ = (err) => {
      if (err.severity == shaka.util.Error.Severity.CRITICAL) {
        this.allSessionsLoaded_.reject(err);
      }

      playerInterface.onError(err);
    };

    /**
     * The most recent key status information we have.
     * We may not have announced this information to the outside world yet,
     * which we delay to batch up changes and avoid spurious "missing key"
     * errors.
     * @private {!Map.<string, string>}
     */
    this.keyStatusByKeyId_ = new Map();

    /**
     * The key statuses most recently announced to other classes.
     * We may have more up-to-date information being collected in
     * this.keyStatusByKeyId_, which has not been batched up and released yet.
     * @private {!Map.<string, string>}
     */
    this.announcedKeyStatusByKeyId_ = new Map();

    /** @private {shaka.util.Timer} */
    this.keyStatusTimer_ =
        new shaka.util.Timer(() => this.processKeyStatusChanges_());

    /** @private {boolean} */
    this.usePersistentLicenses_ = false;

    /** @private {!Array.<!MediaKeyMessageEvent>} */
    this.mediaKeyMessageEvents_ = [];

    /** @private {boolean} */
    this.initialRequestsSent_ = false;

    /** @private {?shaka.util.Timer} */
    this.expirationTimer_ = new shaka.util.Timer(() => {
      this.pollExpiration_();
    });

    // Add a catch to the Promise to avoid console logs about uncaught errors.
    const noop = () => {};
    this.allSessionsLoaded_.catch(noop);

    /** @const {!shaka.util.Destroyer} */
    this.destroyer_ = new shaka.util.Destroyer(() => this.destroyNow_());

    /** @private {boolean} */
    this.srcEquals_ = false;

    /** @private {Promise} */
    this.mediaKeysAttached_ = null;

    /** @private {?shaka.extern.InitDataOverride} */
    this.manifestInitData_ = null;

    /** @private {function():boolean} */
    this.isPreload_ = () => false;
  }

  /** @override */
  destroy() {
    return this.destroyer_.destroy();
  }

  /**
   * Destroy this instance of DrmEngine. This assumes that all other checks
   * about "if it should" have passed.
   *
   * @private
   */
  async destroyNow_() {
    // |eventManager_| should only be |null| after we call |destroy|. Destroy it
    // first so that we will stop responding to events.
    this.eventManager_.release();
    this.eventManager_ = null;

    // Since we are destroying ourselves, we don't want to react to the "all
    // sessions loaded" event.
    this.allSessionsLoaded_.reject();

    // Stop all timers. This will ensure that they do not start any new work
    // while we are destroying ourselves.
    this.expirationTimer_.stop();
    this.expirationTimer_ = null;

    this.keyStatusTimer_.stop();
    this.keyStatusTimer_ = null;

    // Close all open sessions.
    await this.closeOpenSessions_();

    // |video_| will be |null| if we never attached to a video element.
    if (this.video_) {
      // Webkit EME implementation requires the src to be defined to clear
      // the MediaKeys.
      if (!shaka.util.Platform.isMediaKeysPolyfilled('webkit')) {
        goog.asserts.assert(
            !this.video_.src &&
            !this.video_.getElementsByTagName('source').length,
            'video src must be removed first!');
      }

      try {
        await this.video_.setMediaKeys(null);
      } catch (error) {
        // Ignore any failures while removing media keys from the video element.
        shaka.log.debug(`DrmEngine.destroyNow_ exception`, error);
      }

      this.video_ = null;
    }

    // Break references to everything else we hold internally.
    this.currentDrmInfo_ = null;
    this.mediaKeys_ = null;
    this.storedPersistentSessions_ = new Map();
    this.config_ = null;
    this.onError_ = () => {};
    this.playerInterface_ = null;
    this.srcEquals_ = false;
    this.mediaKeysAttached_ = null;
  }

  /**
   * Called by the Player to provide an updated configuration any time it
   * changes.
   * Must be called at least once before init().
   *
   * @param {shaka.extern.DrmConfiguration} config
   * @param {(function():boolean)=} isPreload
   */
  configure(config, isPreload) {
    this.config_ = config;
    if (isPreload) {
      this.isPreload_ = isPreload;
    }
    if (this.expirationTimer_) {
      this.expirationTimer_.tickEvery(
          /* seconds= */ this.config_.updateExpirationTime);
    }
  }

  /**
   * @param {!boolean} value
   */
  setSrcEquals(value) {
    this.srcEquals_ = value;
  }

  /**
   * Initialize the drm engine for storing and deleting stored content.
   *
   * @param {!Array.<shaka.extern.Variant>} variants
   *    The variants that are going to be stored.
   * @param {boolean} usePersistentLicenses
   *    Whether or not persistent licenses should be requested and stored for
   *    |manifest|.
   * @return {!Promise}
   */
  initForStorage(variants, usePersistentLicenses) {
    this.initializedForStorage_ = true;
    // There are two cases for this call:
    //  1. We are about to store a manifest - in that case, there are no offline
    //     sessions and therefore no offline session ids.
    //  2. We are about to remove the offline sessions for this manifest - in
    //     that case, we don't need to know about them right now either as
    //     we will be told which ones to remove later.
    this.storedPersistentSessions_ = new Map();

    // What we really need to know is whether or not they are expecting to use
    // persistent licenses.
    this.usePersistentLicenses_ = usePersistentLicenses;

    return this.init_(variants);
  }

  /**
   * Initialize the drm engine for playback operations.
   *
   * @param {!Array.<shaka.extern.Variant>} variants
   *    The variants that we want to support playing.
   * @param {!Array.<string>} offlineSessionIds
   * @return {!Promise}
   */
  initForPlayback(variants, offlineSessionIds) {
    this.storedPersistentSessions_ = new Map();

    for (const sessionId of offlineSessionIds) {
      this.storedPersistentSessions_.set(
          sessionId, {initData: null, initDataType: null});
    }

    for (const metadata of this.config_.persistentSessionsMetadata) {
      this.storedPersistentSessions_.set(
          metadata.sessionId,
          {initData: metadata.initData, initDataType: metadata.initDataType});
    }

    this.usePersistentLicenses_ = this.storedPersistentSessions_.size > 0;

    return this.init_(variants);
  }

  /**
   * Initializes the drm engine for removing persistent sessions.  Only the
   * removeSession(s) methods will work correctly, creating new sessions may not
   * work as desired.
   *
   * @param {string} keySystem
   * @param {string} licenseServerUri
   * @param {Uint8Array} serverCertificate
   * @param {!Array.<MediaKeySystemMediaCapability>} audioCapabilities
   * @param {!Array.<MediaKeySystemMediaCapability>} videoCapabilities
   * @return {!Promise}
   */
  initForRemoval(keySystem, licenseServerUri, serverCertificate,
      audioCapabilities, videoCapabilities) {
    /** @type {!Map.<string, MediaKeySystemConfiguration>} */
    const configsByKeySystem = new Map();

    /** @type {MediaKeySystemConfiguration} */
    const config = {
      audioCapabilities: audioCapabilities,
      videoCapabilities: videoCapabilities,
      distinctiveIdentifier: 'optional',
      persistentState: 'required',
      sessionTypes: ['persistent-license'],
      label: keySystem,  // Tracked by us, ignored by EME.
    };

    // TODO: refactor, don't stick drmInfos onto MediaKeySystemConfiguration
    config['drmInfos'] = [{  // Non-standard attribute, ignored by EME.
      keySystem: keySystem,
      licenseServerUri: licenseServerUri,
      distinctiveIdentifierRequired: false,
      persistentStateRequired: true,
      audioRobustness: '',  // Not required by queryMediaKeys_
      videoRobustness: '',  // Same
      serverCertificate: serverCertificate,
      serverCertificateUri: '',
      initData: null,
      keyIds: null,
    }];

    configsByKeySystem.set(keySystem, config);
    return this.queryMediaKeys_(configsByKeySystem,
        /* variants= */ []);
  }

  /**
   * Negotiate for a key system and set up MediaKeys.
   * This will assume that both |usePersistentLicences_| and
   * |storedPersistentSessions_| have been properly set.
   *
   * @param {!Array.<shaka.extern.Variant>} variants
   *    The variants that we expect to operate with during the drm engine's
   *    lifespan of the drm engine.
   * @return {!Promise} Resolved if/when a key system has been chosen.
   * @private
   */
  async init_(variants) {
    goog.asserts.assert(this.config_,
        'DrmEngine configure() must be called before init()!');

    // ClearKey config overrides the manifest DrmInfo if present. The variants
    // are modified so that filtering in Player still works.
    // This comes before hadDrmInfo because it influences the value of that.
    /** @type {?shaka.extern.DrmInfo} */
    const clearKeyDrmInfo = this.configureClearKey_();
    if (clearKeyDrmInfo) {
      for (const variant of variants) {
        if (variant.video) {
          variant.video.drmInfos = [clearKeyDrmInfo];
        }
        if (variant.audio) {
          variant.audio.drmInfos = [clearKeyDrmInfo];
        }
      }
    }

    const hadDrmInfo = variants.some((variant) => {
      if (variant.video && variant.video.drmInfos.length) {
        return true;
      }
      if (variant.audio && variant.audio.drmInfos.length) {
        return true;
      }
      return false;
    });

    // When preparing to play live streams, it is possible that we won't know
    // about some upcoming encrypted content. If we initialize the drm engine
    // with no key systems, we won't be able to play when the encrypted content
    // comes.
    //
    // To avoid this, we will set the drm engine up to work with as many key
    // systems as possible so that we will be ready.
    if (!hadDrmInfo) {
      const servers = shaka.util.MapUtils.asMap(this.config_.servers);
      shaka.drm.DrmEngine.replaceDrmInfo_(variants, servers);
    }

    /** @type {!Set<shaka.extern.DrmInfo>} */
    const drmInfos = new Set();
    for (const variant of variants) {
      const variantDrmInfos = this.getVariantDrmInfos_(variant);
      for (const info of variantDrmInfos) {
        drmInfos.add(info);
      }
    }

    for (const info of drmInfos) {
      shaka.drm.DrmEngine.fillInDrmInfoDefaults_(
          info,
          shaka.util.MapUtils.asMap(this.config_.servers),
          shaka.util.MapUtils.asMap(this.config_.advanced || {}),
          this.config_.keySystemsMapping);
    }

    /** @type {!Map.<string, MediaKeySystemConfiguration>} */
    let configsByKeySystem;

    // We should get the decodingInfo results for the variants after we filling
    // in the drm infos, and before queryMediaKeys_().
    await shaka.util.StreamUtils.getDecodingInfosForVariants(variants,
        this.usePersistentLicenses_, this.srcEquals_,
        this.config_.preferredKeySystems);
    this.destroyer_.ensureNotDestroyed();

    const hasDrmInfo = hadDrmInfo || Object.keys(this.config_.servers).length;
    // An unencrypted content is initialized.
    if (!hasDrmInfo) {
      this.initialized_ = true;
      return Promise.resolve();
    }

    const p = this.queryMediaKeys_(configsByKeySystem, variants);

    // TODO(vaage): Look into the assertion below. If we do not have any drm
    // info, we create drm info so that content can play if it has drm info
    // later.
    // However it is okay if we fail to initialize? If we fail to initialize,
    // it means we won't be able to play the later-encrypted content, which is
    // not okay.

    // If the content did not originally have any drm info, then it doesn't
    // matter if we fail to initialize the drm engine, because we won't need it
    // anyway.
    return hadDrmInfo ? p : p.catch(() => {});
  }

  /**
   * Attach MediaKeys to the video element
   * @return {Promise}
   * @private
   */
  async attachMediaKeys_() {
    if (this.video_.mediaKeys) {
      return;
    }

    // An attach process has already started, let's wait it out
    if (this.mediaKeysAttached_) {
      await this.mediaKeysAttached_;

      this.destroyer_.ensureNotDestroyed();
      return;
    }

    try {
      this.mediaKeysAttached_ = this.video_.setMediaKeys(this.mediaKeys_);

      await this.mediaKeysAttached_;
    } catch (exception) {
      goog.asserts.assert(exception instanceof Error, 'Wrong error type!');

      this.onError_(new shaka.util.Error(
          shaka.util.Error.Severity.CRITICAL,
          shaka.util.Error.Category.DRM,
          shaka.util.Error.Code.FAILED_TO_ATTACH_TO_VIDEO,
          exception.message));
    }

    this.destroyer_.ensureNotDestroyed();
  }

  /**
   * Processes encrypted event and start licence challenging
   * @return {!Promise}
   * @private
   */
  async onEncryptedEvent_(event) {
    /**
     * MediaKeys should be added when receiving an encrypted event. Setting
     * mediaKeys before could result into encrypted event not being fired on
     * some browsers
     */
    await this.attachMediaKeys_();

    this.newInitData(
        event.initDataType,
        shaka.util.BufferUtils.toUint8(event.initData));
  }

  /**
   * Start processing events.
   * @param {HTMLMediaElement} video
   * @return {!Promise}
   */
  async attach(video) {
    if (!this.mediaKeys_) {
      // Unencrypted, or so we think.  We listen for encrypted events in order
      // to warn when the stream is encrypted, even though the manifest does
      // not know it.
      // Don't complain about this twice, so just listenOnce().
      // FIXME: This is ineffective when a prefixed event is translated by our
      // polyfills, since those events are only caught and translated by a
      // MediaKeys instance.  With clear content and no polyfilled MediaKeys
      // instance attached, you'll never see the 'encrypted' event on those
      // platforms (Safari).
      this.eventManager_.listenOnce(video, 'encrypted', (event) => {
        this.onError_(new shaka.util.Error(
            shaka.util.Error.Severity.CRITICAL,
            shaka.util.Error.Category.DRM,
            shaka.util.Error.Code.ENCRYPTED_CONTENT_WITHOUT_DRM_INFO));
      });
      return;
    }

    this.video_ = video;

    this.eventManager_.listenOnce(this.video_, 'play', () => this.onPlay_());
    if (this.video_.remote) {
      this.eventManager_.listen(this.video_.remote, 'connect',
          () => this.closeOpenSessions_());
      this.eventManager_.listen(this.video_.remote, 'connecting',
          () => this.closeOpenSessions_());
      this.eventManager_.listen(this.video_.remote, 'disconnect',
          () => this.closeOpenSessions_());
    } else if ('webkitCurrentPlaybackTargetIsWireless' in this.video_) {
      this.eventManager_.listen(this.video_,
          'webkitcurrentplaybacktargetiswirelesschanged',
          () => this.closeOpenSessions_());
    }

    this.manifestInitData_ = this.currentDrmInfo_ ?
      (this.currentDrmInfo_.initData.find(
          (initDataOverride) => initDataOverride.initData.length > 0,
      ) || null) : null;

    /**
     * We can attach media keys before the playback actually begins when:
     *  - If we are not using FairPlay Modern EME
     *  - Some initData already has been generated (through the manifest)
     *  - In case of an offline session
     */
    if (this.manifestInitData_ ||
        this.currentDrmInfo_.keySystem !== 'com.apple.fps' ||
        this.storedPersistentSessions_.size) {
      await this.attachMediaKeys_();
    }

    this.createOrLoad().catch(() => {
      // Silence errors
      // createOrLoad will run async, errors are triggered through onError_
    });

    // Explicit init data for any one stream or an offline session is
    // sufficient to suppress 'encrypted' events for all streams.
    // Also suppress 'encrypted' events when parsing in-band pssh
    // from media segments because that serves the same purpose as the
    // 'encrypted' events.
    if (!this.manifestInitData_ && !this.storedPersistentSessions_.size &&
        !this.config_.parseInbandPsshEnabled) {
      this.eventManager_.listen(
          this.video_, 'encrypted', (e) => this.onEncryptedEvent_(e));
    }
  }

  /**
   * Returns true if the manifest has init data.
   *
   * @return {boolean}
   */
  hasManifestInitData() {
    return !!this.manifestInitData_;
  }

  /**
   * Sets the server certificate based on the current DrmInfo.
   *
   * @return {!Promise}
   */
  async setServerCertificate() {
    goog.asserts.assert(this.initialized_,
        'Must call init() before setServerCertificate');

    if (!this.mediaKeys_ || !this.currentDrmInfo_) {
      return;
    }

    if (this.currentDrmInfo_.serverCertificateUri &&
       (!this.currentDrmInfo_.serverCertificate ||
       !this.currentDrmInfo_.serverCertificate.length)) {
      const request = shaka.net.NetworkingEngine.makeRequest(
          [this.currentDrmInfo_.serverCertificateUri],
          this.config_.retryParameters);

      try {
        const operation = this.playerInterface_.netEngine.request(
            shaka.net.NetworkingEngine.RequestType.SERVER_CERTIFICATE,
            request, {isPreload: this.isPreload_()});
        const response = await operation.promise;

        this.currentDrmInfo_.serverCertificate =
          shaka.util.BufferUtils.toUint8(response.data);
      } catch (error) {
        // Request failed!
        goog.asserts.assert(error instanceof shaka.util.Error,
            'Wrong NetworkingEngine error type!');

        throw new shaka.util.Error(
            shaka.util.Error.Severity.CRITICAL,
            shaka.util.Error.Category.DRM,
            shaka.util.Error.Code.SERVER_CERTIFICATE_REQUEST_FAILED,
            error);
      }

      if (this.destroyer_.destroyed()) {
        return;
      }
    }

    if (!this.currentDrmInfo_.serverCertificate ||
        !this.currentDrmInfo_.serverCertificate.length) {
      return;
    }

    try {
      const supported = await this.mediaKeys_.setServerCertificate(
          this.currentDrmInfo_.serverCertificate);

      if (!supported) {
        shaka.log.warning('Server certificates are not supported by the ' +
                          'key system.  The server certificate has been ' +
                          'ignored.');
      }
    } catch (exception) {
      throw new shaka.util.Error(
          shaka.util.Error.Severity.CRITICAL,
          shaka.util.Error.Category.DRM,
          shaka.util.Error.Code.INVALID_SERVER_CERTIFICATE,
          exception.message);
    }
  }

  /**
   * Remove an offline session and delete it's data. This can only be called
   * after a successful call to |init|. This will wait until the
   * 'license-release' message is handled. The returned Promise will be rejected
   * if there is an error releasing the license.
   *
   * @param {string} sessionId
   * @return {!Promise}
   */
  async removeSession(sessionId) {
    goog.asserts.assert(this.mediaKeys_,
        'Must call init() before removeSession');

    const session = await this.loadOfflineSession_(
        sessionId, {initData: null, initDataType: null});

    // This will be null on error, such as session not found.
    if (!session) {
      shaka.log.v2('Ignoring attempt to remove missing session', sessionId);
      return;
    }

    // TODO: Consider adding a timeout to get the 'message' event.
    // Note that the 'message' event will get raised after the remove()
    // promise resolves.
    const tasks = [];

    const found = this.activeSessions_.get(session);
    if (found) {
      // This will force us to wait until the 'license-release' message has been
      // handled.
      found.updatePromise = new shaka.util.PublicPromise();
      tasks.push(found.updatePromise);
    }

    shaka.log.v2('Attempting to remove session', sessionId);
    tasks.push(session.remove());

    await Promise.all(tasks);
    this.activeSessions_.delete(session);
  }

  /**
   * Creates the sessions for the init data and waits for them to become ready.
   *
   * @return {!Promise}
   */
  async createOrLoad() {
    if (this.storedPersistentSessions_.size) {
      this.storedPersistentSessions_.forEach((metadata, sessionId) => {
        this.loadOfflineSession_(sessionId, metadata);
      });

      await this.allSessionsLoaded_;

      const keyIds = (this.currentDrmInfo_ && this.currentDrmInfo_.keyIds) ||
          new Set([]);

      // All the needed keys are already loaded, we don't need another license
      // Therefore we prevent starting a new session
      if (keyIds.size > 0 && this.areAllKeysUsable_()) {
        return this.allSessionsLoaded_;
      }

      // Reset the promise for the next sessions to come if key needs aren't
      // satisfied with persistent sessions
      this.hasInitData_ = false;
      this.allSessionsLoaded_ = new shaka.util.PublicPromise();
      this.allSessionsLoaded_.catch(() => {});
    }

    // Create sessions.
    const initDatas =
        (this.currentDrmInfo_ ? this.currentDrmInfo_.initData : []) || [];
    for (const initDataOverride of initDatas) {
      this.newInitData(
          initDataOverride.initDataType, initDataOverride.initData);
    }

    // If there were no sessions to load, we need to resolve the promise right
    // now or else it will never get resolved.
    // We determine this by checking areAllSessionsLoaded_, rather than checking
    // the number of initDatas, since the newInitData method can reject init
    // datas in some circumstances.
    if (this.areAllSessionsLoaded_()) {
      this.allSessionsLoaded_.resolve();
    }

    return this.allSessionsLoaded_;
  }

  /**
   * Called when new initialization data is encountered.  If this data hasn't
   * been seen yet, this will create a new session for it.
   *
   * @param {string} initDataType
   * @param {!Uint8Array} initData
   */
  newInitData(initDataType, initData) {
    if (!initData.length) {
      return;
    }

    // Suppress duplicate init data.
    // Note that some init data are extremely large and can't portably be used
    // as keys in a dictionary.

    if (this.config_.ignoreDuplicateInitData) {
      const metadatas = this.activeSessions_.values();
      for (const metadata of metadatas) {
        if (shaka.util.BufferUtils.equal(initData, metadata.initData)) {
          shaka.log.debug('Ignoring duplicate init data.');
          return;
        }
      }
      let duplicate = false;
      this.storedPersistentSessions_.forEach((metadata, sessionId) => {
        if (!duplicate &&
            shaka.util.BufferUtils.equal(initData, metadata.initData)) {
          duplicate = true;
        }
      });
      if (duplicate) {
        shaka.log.debug('Ignoring duplicate init data.');
        return;
      }
    }

    // Mark that there is init data, so that the preloader will know to wait
    // for sessions to be loaded.
    this.hasInitData_ = true;

    // If there are pre-existing sessions that have all been loaded
    // then reset the allSessionsLoaded_ promise, which can now be
    // used to wait for new sessions to be loaded
    if (this.activeSessions_.size > 0 && this.areAllSessionsLoaded_()) {
      this.allSessionsLoaded_.resolve();
      this.hasInitData_ = false;
      this.allSessionsLoaded_ = new shaka.util.PublicPromise();
      this.allSessionsLoaded_.catch(() => {});
    }
    this.createSession(initDataType, initData,
        this.currentDrmInfo_.sessionType);
  }

  /** @return {boolean} */
  initialized() {
    return this.initialized_;
  }

  /**
   * Returns the ID of the sessions currently active.
   *
   * @return {!Array.<string>}
   */
  getSessionIds() {
    const sessions = this.activeSessions_.keys();
    const ids = shaka.util.Iterables.map(sessions, (s) => s.sessionId);

    // TODO: Make |getSessionIds| return |Iterable| instead of |Array|.
    return Array.from(ids);
  }

  /**
   * Returns the active sessions metadata
   *
   * @return {!Array.<shaka.extern.DrmSessionMetadata>}
   */
  getActiveSessionsMetadata() {
    const sessions = this.activeSessions_.keys();

    const metadata = shaka.util.Iterables.map(sessions, (session) => {
      const metadata = this.activeSessions_.get(session);

      return {
        sessionId: session.sessionId,
        sessionType: metadata.type,
        initData: metadata.initData,
        initDataType: metadata.initDataType,
      };
    });

    return Array.from(metadata);
  }

  /**
   * Returns the next expiration time, or Infinity.
   * @return {number}
   */
  getExpiration() {
    // This will equal Infinity if there are no entries.
    let min = Infinity;

    const sessions = this.activeSessions_.keys();
    for (const session of sessions) {
      if (!isNaN(session.expiration)) {
        min = Math.min(min, session.expiration);
      }
    }

    return min;
  }

  /**
   * Returns the time spent on license requests during this session, or NaN.
   *
   * @return {number}
   */
  getLicenseTime() {
    if (this.licenseTimeSeconds_) {
      return this.licenseTimeSeconds_;
    }
    return NaN;
  }

  /**
   * Returns the DrmInfo that was used to initialize the current key system.
   *
   * @return {?shaka.extern.DrmInfo}
   */
  getDrmInfo() {
    return this.currentDrmInfo_;
  }

  /**
   * Return the media keys created from the current mediaKeySystemAccess.
   * @return {MediaKeys}
   */
  getMediaKeys() {
    return this.mediaKeys_;
  }

  /**
   * Returns the current key statuses.
   *
   * @return {!Object.<string, string>}
   */
  getKeyStatuses() {
    return shaka.util.MapUtils.asObject(this.announcedKeyStatusByKeyId_);
  }

  /**
   * Returns the current media key sessions.
   *
   * @return {!Array.<MediaKeySession>}
   */
  getMediaKeySessions() {
    return Array.from(this.activeSessions_.keys());
  }

  /**
   * @param {!Map.<string, MediaKeySystemConfiguration>} configsByKeySystem
   *   A dictionary of configs, indexed by key system, with an iteration order
   *   (insertion order) that reflects the preference for the application.
   * @param {!Array.<shaka.extern.Variant>} variants
   * @return {!Promise} Resolved if/when a key system has been chosen.
   * @private
   */
  async queryMediaKeys_(configsByKeySystem, variants) {
    const drmInfosByKeySystem = new Map();

    const mediaKeySystemAccess = variants.length ?
        this.getKeySystemAccessFromVariants_(variants, drmInfosByKeySystem) :
        await this.getKeySystemAccessByConfigs_(configsByKeySystem);

    if (!mediaKeySystemAccess) {
      if (!navigator.requestMediaKeySystemAccess) {
        throw new shaka.util.Error(
            shaka.util.Error.Severity.CRITICAL,
            shaka.util.Error.Category.DRM,
            shaka.util.Error.Code.MISSING_EME_SUPPORT);
      }
      throw new shaka.util.Error(
          shaka.util.Error.Severity.CRITICAL,
          shaka.util.Error.Category.DRM,
          shaka.util.Error.Code.REQUESTED_KEY_SYSTEM_CONFIG_UNAVAILABLE);
    }
    this.destroyer_.ensureNotDestroyed();

    try {
      // Store the capabilities of the key system.
      const realConfig = mediaKeySystemAccess.getConfiguration();

      shaka.log.v2(
          'Got MediaKeySystemAccess with configuration',
          realConfig);

      const keySystem =
          this.config_.keySystemsMapping[mediaKeySystemAccess.keySystem] ||
          mediaKeySystemAccess.keySystem;

      if (variants.length) {
        this.currentDrmInfo_ = this.createDrmInfoByInfos_(
            keySystem, drmInfosByKeySystem.get(keySystem));
      } else {
        this.currentDrmInfo_ = shaka.drm.DrmEngine.createDrmInfoByConfigs_(
            keySystem, configsByKeySystem.get(keySystem));
      }
      if (!this.currentDrmInfo_.licenseServerUri) {
        throw new shaka.util.Error(
            shaka.util.Error.Severity.CRITICAL,
            shaka.util.Error.Category.DRM,
            shaka.util.Error.Code.NO_LICENSE_SERVER_GIVEN,
            this.currentDrmInfo_.keySystem);
      }

      const mediaKeys = await mediaKeySystemAccess.createMediaKeys();
      this.destroyer_.ensureNotDestroyed();
      shaka.log.info('Created MediaKeys object for key system',
          this.currentDrmInfo_.keySystem);

      this.mediaKeys_ = mediaKeys;
      if (this.config_.minHdcpVersion != '' &&
          'getStatusForPolicy' in this.mediaKeys_) {
        try {
          const status = await this.mediaKeys_.getStatusForPolicy({
            minHdcpVersion: this.config_.minHdcpVersion,
          });
          if (status != 'usable') {
            throw new shaka.util.Error(
                shaka.util.Error.Severity.CRITICAL,
                shaka.util.Error.Category.DRM,
                shaka.util.Error.Code.MIN_HDCP_VERSION_NOT_MATCH);
          }
          this.destroyer_.ensureNotDestroyed();
        } catch (e) {
          if (e instanceof shaka.util.Error) {
            throw e;
          }
          throw new shaka.util.Error(
              shaka.util.Error.Severity.CRITICAL,
              shaka.util.Error.Category.DRM,
              shaka.util.Error.Code.ERROR_CHECKING_HDCP_VERSION,
              e.message);
        }
      }
      this.initialized_ = true;

      await this.setServerCertificate();
      this.destroyer_.ensureNotDestroyed();
    } catch (exception) {
      this.destroyer_.ensureNotDestroyed(exception);

      // Don't rewrap a shaka.util.Error from earlier in the chain:
      this.currentDrmInfo_ = null;
      if (exception instanceof shaka.util.Error) {
        throw exception;
      }

      // We failed to create MediaKeys.  This generally shouldn't happen.
      throw new shaka.util.Error(
          shaka.util.Error.Severity.CRITICAL,
          shaka.util.Error.Category.DRM,
          shaka.util.Error.Code.FAILED_TO_CREATE_CDM,
          exception.message);
    }
  }

  /**
   * Get the MediaKeySystemAccess from the decodingInfos of the variants.
   * @param {!Array.<shaka.extern.Variant>} variants
   * @param {!Map.<string, !Array.<shaka.extern.DrmInfo>>} drmInfosByKeySystem
   *   A dictionary of drmInfos, indexed by key system.
   * @return {MediaKeySystemAccess}
   * @private
   */
  getKeySystemAccessFromVariants_(variants, drmInfosByKeySystem) {
    for (const variant of variants) {
      // Get all the key systems in the variant that shouldHaveLicenseServer.
      const drmInfos = this.getVariantDrmInfos_(variant);
      for (const info of drmInfos) {
        if (!drmInfosByKeySystem.has(info.keySystem)) {
          drmInfosByKeySystem.set(info.keySystem, []);
        }
        drmInfosByKeySystem.get(info.keySystem).push(info);
      }
    }

    if (drmInfosByKeySystem.size == 1 && drmInfosByKeySystem.has('')) {
      throw new shaka.util.Error(
          shaka.util.Error.Severity.CRITICAL,
          shaka.util.Error.Category.DRM,
          shaka.util.Error.Code.NO_RECOGNIZED_KEY_SYSTEMS);
    }

    // If we have configured preferredKeySystems, choose a preferred keySystem
    // if available.
    for (const preferredKeySystem of this.config_.preferredKeySystems) {
      for (const variant of variants) {
        const decodingInfo = variant.decodingInfos.find((decodingInfo) => {
          return decodingInfo.supported &&
              decodingInfo.keySystemAccess != null &&
              decodingInfo.keySystemAccess.keySystem == preferredKeySystem;
        });
        if (decodingInfo) {
          return decodingInfo.keySystemAccess;
        }
      }
    }

    // Try key systems with configured license servers first.  We only have to
    // try key systems without configured license servers for diagnostic
    // reasons, so that we can differentiate between "none of these key
    // systems are available" and "some are available, but you did not
    // configure them properly."  The former takes precedence.
    for (const shouldHaveLicenseServer of [true, false]) {
      for (const variant of variants) {
        for (const decodingInfo of variant.decodingInfos) {
          if (!decodingInfo.supported || !decodingInfo.keySystemAccess) {
            continue;
          }
          const originalKeySystem = decodingInfo.keySystemAccess.keySystem;
          let drmInfos = drmInfosByKeySystem.get(originalKeySystem);
          if (!drmInfos && this.config_.keySystemsMapping[originalKeySystem]) {
            drmInfos = drmInfosByKeySystem.get(
                this.config_.keySystemsMapping[originalKeySystem]);
          }
          for (const info of drmInfos) {
            if (!!info.licenseServerUri == shouldHaveLicenseServer) {
              return decodingInfo.keySystemAccess;
            }
          }
        }
      }
    }
    return null;
  }

  /**
   * Get the MediaKeySystemAccess by querying requestMediaKeySystemAccess.
   * @param {!Map.<string, MediaKeySystemConfiguration>} configsByKeySystem
   *   A dictionary of configs, indexed by key system, with an iteration order
   *   (insertion order) that reflects the preference for the application.
   * @return {!Promise.<MediaKeySystemAccess>} Resolved if/when a
   *   mediaKeySystemAccess has been chosen.
   * @private
   */
  async getKeySystemAccessByConfigs_(configsByKeySystem) {
    /** @type {MediaKeySystemAccess} */
    let mediaKeySystemAccess;

    if (configsByKeySystem.size == 1 && configsByKeySystem.has('')) {
      throw new shaka.util.Error(
          shaka.util.Error.Severity.CRITICAL,
          shaka.util.Error.Category.DRM,
          shaka.util.Error.Code.NO_RECOGNIZED_KEY_SYSTEMS);
    }

    // If there are no tracks of a type, these should be not present.
    // Otherwise the query will fail.
    for (const config of configsByKeySystem.values()) {
      if (config.audioCapabilities.length == 0) {
        delete config.audioCapabilities;
      }
      if (config.videoCapabilities.length == 0) {
        delete config.videoCapabilities;
      }
    }

    // If we have configured preferredKeySystems, choose the preferred one if
    // available.
    for (const keySystem of this.config_.preferredKeySystems) {
      if (configsByKeySystem.has(keySystem)) {
        const config = configsByKeySystem.get(keySystem);
        try {
          mediaKeySystemAccess =  // eslint-disable-next-line no-await-in-loop
              await navigator.requestMediaKeySystemAccess(keySystem, [config]);
          return mediaKeySystemAccess;
        } catch (error) {
          // Suppress errors.
          shaka.log.v2(
              'Requesting', keySystem, 'failed with config', config, error);
        }
        this.destroyer_.ensureNotDestroyed();
      }
    }

    // Try key systems with configured license servers first.  We only have to
    // try key systems without configured license servers for diagnostic
    // reasons, so that we can differentiate between "none of these key
    // systems are available" and "some are available, but you did not
    // configure them properly."  The former takes precedence.
    // TODO: once MediaCap implementation is complete, this part can be
    // simplified or removed.
    for (const shouldHaveLicenseServer of [true, false]) {
      for (const keySystem of configsByKeySystem.keys()) {
        const config = configsByKeySystem.get(keySystem);
        // TODO: refactor, don't stick drmInfos onto
        // MediaKeySystemConfiguration
        const hasLicenseServer = config['drmInfos'].some((info) => {
          return !!info.licenseServerUri;
        });
        if (hasLicenseServer != shouldHaveLicenseServer) {
          continue;
        }

        try {
          mediaKeySystemAccess =  // eslint-disable-next-line no-await-in-loop
              await navigator.requestMediaKeySystemAccess(keySystem, [config]);
          return mediaKeySystemAccess;
        } catch (error) {
          // Suppress errors.
          shaka.log.v2(
              'Requesting', keySystem, 'failed with config', config, error);
        }
        this.destroyer_.ensureNotDestroyed();
      }
    }
    return mediaKeySystemAccess;
  }

  /**
   * Create a DrmInfo using configured clear keys.
   * The server URI will be a data URI which decodes to a clearkey license.
   * @return {?shaka.extern.DrmInfo} or null if clear keys are not configured.
   * @private
   * @see https://bit.ly/2K8gOnv for the spec on the clearkey license format.
   */
  configureClearKey_() {
    const clearKeys = shaka.util.MapUtils.asMap(this.config_.clearKeys);
    if (clearKeys.size == 0) {
      return null;
    }

    const ManifestParserUtils = shaka.util.ManifestParserUtils;
    return ManifestParserUtils.createDrmInfoFromClearKeys(clearKeys);
  }

  /**
   * Resolves the allSessionsLoaded_ promise when all the sessions are loaded
   *
   * @private
   */
  checkSessionsLoaded_() {
    if (this.areAllSessionsLoaded_()) {
      this.allSessionsLoaded_.resolve();
    }
  }

  /**
   * In case there are no key statuses, consider this session loaded
   * after a reasonable timeout.  It should definitely not take 5
   * seconds to process a license.
   * @param {!shaka.drm.DrmEngine.SessionMetaData} metadata
   * @private
   */
  setLoadSessionTimeoutTimer_(metadata) {
    const timer = new shaka.util.Timer(() => {
      metadata.loaded = true;
      this.checkSessionsLoaded_();
    });

    timer.tickAfter(
        /* seconds= */ shaka.drm.DrmEngine.SESSION_LOAD_TIMEOUT_);
  }

  /**
   * @param {string} sessionId
   * @param {{initData: ?Uint8Array, initDataType: ?string}} sessionMetadata
   * @return {!Promise.<MediaKeySession>}
   * @private
   */
  async loadOfflineSession_(sessionId, sessionMetadata) {
    let session;

    const sessionType = 'persistent-license';

    try {
      shaka.log.v1('Attempting to load an offline session', sessionId);
      session = this.mediaKeys_.createSession(sessionType);
    } catch (exception) {
      const error = new shaka.util.Error(
          shaka.util.Error.Severity.CRITICAL,
          shaka.util.Error.Category.DRM,
          shaka.util.Error.Code.FAILED_TO_CREATE_SESSION,
          exception.message);
      this.onError_(error);
      return Promise.reject(error);
    }

    this.eventManager_.listen(session, 'message',
        /** @type {shaka.util.EventManager.ListenerType} */(
          (event) => this.onSessionMessage_(event)));
    this.eventManager_.listen(session, 'keystatuseschange',
        (event) => this.onKeyStatusesChange_(event));

    const metadata = {
      initData: sessionMetadata.initData,
      initDataType: sessionMetadata.initDataType,
      loaded: false,
      oldExpiration: Infinity,
      updatePromise: null,
      type: sessionType,
    };
    this.activeSessions_.set(session, metadata);

    try {
      const present = await session.load(sessionId);
      this.destroyer_.ensureNotDestroyed();
      shaka.log.v2('Loaded offline session', sessionId, present);

      if (!present) {
        this.activeSessions_.delete(session);

        const severity = this.config_.persistentSessionOnlinePlayback ?
            shaka.util.Error.Severity.RECOVERABLE :
            shaka.util.Error.Severity.CRITICAL;

        this.onError_(new shaka.util.Error(
            severity,
            shaka.util.Error.Category.DRM,
            shaka.util.Error.Code.OFFLINE_SESSION_REMOVED));

        metadata.loaded = true;
      }

      this.setLoadSessionTimeoutTimer_(metadata);
      this.checkSessionsLoaded_();

      return session;
    } catch (error) {
      this.destroyer_.ensureNotDestroyed(error);

      this.activeSessions_.delete(session);

      const severity = this.config_.persistentSessionOnlinePlayback ?
          shaka.util.Error.Severity.RECOVERABLE :
          shaka.util.Error.Severity.CRITICAL;

      this.onError_(new shaka.util.Error(
          severity,
          shaka.util.Error.Category.DRM,
          shaka.util.Error.Code.FAILED_TO_CREATE_SESSION,
          error.message));

      metadata.loaded = true;

      this.checkSessionsLoaded_();
    }

    return Promise.resolve();
  }

  /**
   * @param {string} initDataType
   * @param {!Uint8Array} initData
   * @param {string} sessionType
   */
  createSession(initDataType, initData, sessionType) {
    goog.asserts.assert(this.mediaKeys_,
        'mediaKeys_ should be valid when creating temporary session.');

    let session;

    try {
      shaka.log.info('Creating new', sessionType, 'session');

      session = this.mediaKeys_.createSession(sessionType);
    } catch (exception) {
      this.onError_(new shaka.util.Error(
          shaka.util.Error.Severity.CRITICAL,
          shaka.util.Error.Category.DRM,
          shaka.util.Error.Code.FAILED_TO_CREATE_SESSION,
          exception.message));
      return;
    }

    this.eventManager_.listen(session, 'message',
    /** @type {shaka.util.EventManager.ListenerType} */(
          (event) => this.onSessionMessage_(event)));
    this.eventManager_.listen(session, 'keystatuseschange',
        (event) => this.onKeyStatusesChange_(event));

    const metadata = {
      initData: initData,
      initDataType: initDataType,
      loaded: false,
      oldExpiration: Infinity,
      updatePromise: null,
      type: sessionType,
    };
    this.activeSessions_.set(session, metadata);

    try {
      initData = this.config_.initDataTransform(
          initData, initDataType, this.currentDrmInfo_);
    } catch (error) {
      let shakaError = error;
      if (!(error instanceof shaka.util.Error)) {
        shakaError = new shaka.util.Error(
            shaka.util.Error.Severity.CRITICAL,
            shaka.util.Error.Category.DRM,
            shaka.util.Error.Code.INIT_DATA_TRANSFORM_ERROR,
            error);
      }
      this.onError_(shakaError);
      return;
    }

    if (this.config_.logLicenseExchange) {
      const str = shaka.util.Uint8ArrayUtils.toBase64(initData);
      shaka.log.info('EME init data: type=', initDataType, 'data=', str);
    }

    session.generateRequest(initDataType, initData).catch((error) => {
      if (this.destroyer_.destroyed()) {
        return;
      }
      goog.asserts.assert(error instanceof Error, 'Wrong error type!');

      this.activeSessions_.delete(session);

      // This may be supplied by some polyfills.
      /** @type {MediaKeyError} */
      const errorCode = error['errorCode'];

      let extended;
      if (errorCode && errorCode.systemCode) {
        extended = errorCode.systemCode;
        if (extended < 0) {
          extended += Math.pow(2, 32);
        }
        extended = '0x' + extended.toString(16);
      }

      this.onError_(new shaka.util.Error(
          shaka.util.Error.Severity.CRITICAL,
          shaka.util.Error.Category.DRM,
          shaka.util.Error.Code.FAILED_TO_GENERATE_LICENSE_REQUEST,
          error.message, error, extended));
    });
  }

  /**
   * @param {!MediaKeyMessageEvent} event
   * @private
   */
  onSessionMessage_(event) {
    if (this.delayLicenseRequest_()) {
      this.mediaKeyMessageEvents_.push(event);
    } else {
      this.sendLicenseRequest_(event);
    }
  }

  /**
   * @return {boolean}
   * @private
   */
  delayLicenseRequest_() {
    if (!this.video_) {
      // If there's no video, don't delay the license request; i.e., in the case
      // of offline storage.
      return false;
    }
    return (this.config_.delayLicenseRequestUntilPlayed &&
            this.video_.paused && !this.initialRequestsSent_);
  }

  /** @return {!Promise} */
  async waitForActiveRequests() {
    if (this.hasInitData_) {
      await this.allSessionsLoaded_;
      await Promise.all(this.activeRequests_.map((req) => req.promise));
    }
  }

  /**
   * Sends a license request.
   * @param {!MediaKeyMessageEvent} event
   * @private
   */
  async sendLicenseRequest_(event) {
    /** @type {!MediaKeySession} */
    const session = event.target;
    shaka.log.v1(
        'Sending license request for session', session.sessionId, 'of type',
        event.messageType);
    if (this.config_.logLicenseExchange) {
      const str = shaka.util.Uint8ArrayUtils.toBase64(event.message);
      shaka.log.info('EME license request', str);
    }

    const metadata = this.activeSessions_.get(session);

    let url = this.currentDrmInfo_.licenseServerUri;
    const advancedConfig =
        this.config_.advanced[this.currentDrmInfo_.keySystem];

    if (event.messageType == 'individualization-request' && advancedConfig &&
        advancedConfig.individualizationServer) {
      url = advancedConfig.individualizationServer;
    }

    const requestType = shaka.net.NetworkingEngine.RequestType.LICENSE;
    const request = shaka.net.NetworkingEngine.makeRequest(
        [url], this.config_.retryParameters);
    request.body = event.message;
    request.method = 'POST';
    request.licenseRequestType = event.messageType;
    request.sessionId = session.sessionId;
    request.drmInfo = this.currentDrmInfo_;
    if (metadata) {
      request.initData = metadata.initData;
      request.initDataType = metadata.initDataType;
    }
    if (advancedConfig && advancedConfig.headers) {
      // Add these to the existing headers.  Do not clobber them!
      // For PlayReady, there will already be headers in the request.
      for (const header in advancedConfig.headers) {
        request.headers[header] = advancedConfig.headers[header];
      }
    }
    // NOTE: allowCrossSiteCredentials can be set in a request filter.

    if (shaka.drm.DrmUtils.isPlayReadyKeySystem(
        this.currentDrmInfo_.keySystem)) {
      this.unpackPlayReadyRequest_(request);
    }

    const startTimeRequest = Date.now();

    let response;
    try {
      const req = this.playerInterface_.netEngine.request(
          requestType, request, {isPreload: this.isPreload_()});
      this.activeRequests_.push(req);
      response = await req.promise;
      shaka.util.ArrayUtils.remove(this.activeRequests_, req);
    } catch (error) {
      if (this.destroyer_.destroyed()) {
        return;
      }
      // Request failed!
      goog.asserts.assert(error instanceof shaka.util.Error,
          'Wrong NetworkingEngine error type!');
      const shakaErr = new shaka.util.Error(
          shaka.util.Error.Severity.CRITICAL,
          shaka.util.Error.Category.DRM,
          shaka.util.Error.Code.LICENSE_REQUEST_FAILED,
          error);
      if (this.activeSessions_.size == 1) {
        this.onError_(shakaErr);
        if (metadata && metadata.updatePromise) {
          metadata.updatePromise.reject(shakaErr);
        }
      } else {
        if (metadata && metadata.updatePromise) {
          metadata.updatePromise.reject(shakaErr);
        }
        this.activeSessions_.delete(session);
        if (this.areAllSessionsLoaded_()) {
          this.allSessionsLoaded_.resolve();
          this.keyStatusTimer_.tickAfter(/* seconds= */ 0.1);
        }
      }
      return;
    }
    if (this.destroyer_.destroyed()) {
      return;
    }

    this.licenseTimeSeconds_ += (Date.now() - startTimeRequest) / 1000;

    if (this.config_.logLicenseExchange) {
      const str = shaka.util.Uint8ArrayUtils.toBase64(response.data);
      shaka.log.info('EME license response', str);
    }

    // Request succeeded, now pass the response to the CDM.
    try {
      shaka.log.v1('Updating session', session.sessionId);
      await session.update(response.data);
    } catch (error) {
      // Session update failed!
      const shakaErr = new shaka.util.Error(
          shaka.util.Error.Severity.CRITICAL,
          shaka.util.Error.Category.DRM,
          shaka.util.Error.Code.LICENSE_RESPONSE_REJECTED,
          error.message);
      this.onError_(shakaErr);
      if (metadata && metadata.updatePromise) {
        metadata.updatePromise.reject(shakaErr);
      }
      return;
    }
    if (this.destroyer_.destroyed()) {
      return;
    }

    const updateEvent = new shaka.util.FakeEvent('drmsessionupdate');
    this.playerInterface_.onEvent(updateEvent);

    if (metadata) {
      if (metadata.updatePromise) {
        metadata.updatePromise.resolve();
      }

      this.setLoadSessionTimeoutTimer_(metadata);
    }
  }

  /**
   * Unpacks PlayReady license requests.  Modifies the request object.
   * @param {shaka.extern.Request} request
   * @private
   */
  unpackPlayReadyRequest_(request) {
    // On Edge, the raw license message is UTF-16-encoded XML.  We need
    // to unpack the Challenge element (base64-encoded string containing the
    // actual license request) and any HttpHeader elements (sent as request
    // headers).

    // Example XML:

    // <PlayReadyKeyMessage type="LicenseAcquisition">
    //   <LicenseAcquisition Version="1">
    //     <Challenge encoding="base64encoded">{Base64Data}</Challenge>
    //     <HttpHeaders>
    //       <HttpHeader>
    //         <name>Content-Type</name>
    //         <value>text/xml; charset=utf-8</value>
    //       </HttpHeader>
    //       <HttpHeader>
    //         <name>SOAPAction</name>
    //         <value>http://schemas.microsoft.com/DRM/etc/etc</value>
    //       </HttpHeader>
    //     </HttpHeaders>
    //   </LicenseAcquisition>
    // </PlayReadyKeyMessage>
    const TXml = shaka.util.TXml;

    const xml = shaka.util.StringUtils.fromUTF16(
        request.body, /* littleEndian= */ true, /* noThrow= */ true);
    if (!xml.includes('PlayReadyKeyMessage')) {
      // This does not appear to be a wrapped message as on Edge.  Some
      // clients do not need this unwrapping, so we will assume this is one of
      // them.  Note that "xml" at this point probably looks like random
      // garbage, since we interpreted UTF-8 as UTF-16.
      shaka.log.debug('PlayReady request is already unwrapped.');
      request.headers['Content-Type'] = 'text/xml; charset=utf-8';
      return;
    }
    shaka.log.debug('Unwrapping PlayReady request.');
    const dom = TXml.parseXmlString(xml, 'PlayReadyKeyMessage');
    goog.asserts.assert(dom, 'Failed to parse PlayReady XML!');

    // Set request headers.
    const headers = TXml.getElementsByTagName(dom, 'HttpHeader');
    for (const header of headers) {
      const name = TXml.getElementsByTagName(header, 'name')[0];
      const value = TXml.getElementsByTagName(header, 'value')[0];
      goog.asserts.assert(name && value, 'Malformed PlayReady headers!');
      request.headers[
          /** @type {string} */(shaka.util.TXml.getTextContents(name))] =
        /** @type {string} */(shaka.util.TXml.getTextContents(value));
    }

    // Unpack the base64-encoded challenge.
    const challenge = TXml.getElementsByTagName(dom, 'Challenge')[0];
    goog.asserts.assert(challenge,
        'Malformed PlayReady challenge!');
    goog.asserts.assert(challenge.attributes['encoding'] == 'base64encoded',
        'Unexpected PlayReady challenge encoding!');
    request.body = shaka.util.Uint8ArrayUtils.fromBase64(
        /** @type{string} */(shaka.util.TXml.getTextContents(challenge)));
  }

  /**
   * @param {!Event} event
   * @private
   * @suppress {invalidCasts} to swap keyId and status
   */
  onKeyStatusesChange_(event) {
    const session = /** @type {!MediaKeySession} */(event.target);
    shaka.log.v2('Key status changed for session', session.sessionId);

    const found = this.activeSessions_.get(session);
    const keyStatusMap = session.keyStatuses;
    let hasExpiredKeys = false;

    keyStatusMap.forEach((status, keyId) => {
      // The spec has changed a few times on the exact order of arguments here.
      // As of 2016-06-30, Edge has the order reversed compared to the current
      // EME spec.  Given the back and forth in the spec, it may not be the only
      // one.  Try to detect this and compensate:
      if (typeof keyId == 'string') {
        const tmp = keyId;
        keyId = /** @type {!ArrayBuffer} */(status);
        status = /** @type {string} */(tmp);
      }

      // Microsoft's implementation in Edge seems to present key IDs as
      // little-endian UUIDs, rather than big-endian or just plain array of
      // bytes.
      // standard: 6e 5a 1d 26 - 27 57 - 47 d7 - 80 46 ea a5 d1 d3 4b 5a
      // on Edge:  26 1d 5a 6e - 57 27 - d7 47 - 80 46 ea a5 d1 d3 4b 5a
      // Bug filed: https://bit.ly/2thuzXu

      // NOTE that we skip this if byteLength != 16.  This is used for Edge
      // which uses single-byte dummy key IDs.
      // However, unlike Edge and Chromecast, Tizen doesn't have this problem.
      if (shaka.drm.DrmUtils.isPlayReadyKeySystem(
          this.currentDrmInfo_.keySystem) &&
          keyId.byteLength == 16 &&
          (shaka.util.Platform.isEdge() || shaka.util.Platform.isPS4())) {
        // Read out some fields in little-endian:
        const dataView = shaka.util.BufferUtils.toDataView(keyId);
        const part0 = dataView.getUint32(0, /* LE= */ true);
        const part1 = dataView.getUint16(4, /* LE= */ true);
        const part2 = dataView.getUint16(6, /* LE= */ true);
        // Write it back in big-endian:
        dataView.setUint32(0, part0, /* BE= */ false);
        dataView.setUint16(4, part1, /* BE= */ false);
        dataView.setUint16(6, part2, /* BE= */ false);
      }

      if (status != 'status-pending') {
        found.loaded = true;
      }

      if (!found) {
        // We can get a key status changed for a closed session after it has
        // been removed from |activeSessions_|.  If it is closed, none of its
        // keys should be usable.
        goog.asserts.assert(
            status != 'usable', 'Usable keys found in closed session');
      }

      if (status == 'expired') {
        hasExpiredKeys = true;
      }

      const keyIdHex = shaka.util.Uint8ArrayUtils.toHex(keyId).slice(0, 32);

      this.keyStatusByKeyId_.set(keyIdHex, status);
    });

    // If the session has expired, close it.
    // Some CDMs do not have sub-second time resolution, so the key status may
    // fire with hundreds of milliseconds left until the stated expiration time.
    const msUntilExpiration = session.expiration - Date.now();
    if (msUntilExpiration < 0 || (hasExpiredKeys && msUntilExpiration < 1000)) {
      // If this is part of a remove(), we don't want to close the session until
      // the update is complete.  Otherwise, we will orphan the session.
      if (found && !found.updatePromise) {
        shaka.log.debug('Session has expired', session.sessionId);
        this.activeSessions_.delete(session);
        session.close().catch(() => {});  // Silence uncaught rejection errors
      }
    }

    if (!this.areAllSessionsLoaded_()) {
      // Don't announce key statuses or resolve the "all loaded" promise until
      // everything is loaded.
      return;
    }

    this.allSessionsLoaded_.resolve();

    // Batch up key status changes before checking them or notifying Player.
    // This handles cases where the statuses of multiple sessions are set
    // simultaneously by the browser before dispatching key status changes for
    // each of them.  By batching these up, we only send one status change event
    // and at most one EXPIRED error on expiration.
    this.keyStatusTimer_.tickAfter(
        /* seconds= */ shaka.drm.DrmEngine.KEY_STATUS_BATCH_TIME);
  }

  /** @private */
  processKeyStatusChanges_() {
    const privateMap = this.keyStatusByKeyId_;
    const publicMap = this.announcedKeyStatusByKeyId_;

    // Copy the latest key statuses into the publicly-accessible map.
    publicMap.clear();
    privateMap.forEach((status, keyId) => publicMap.set(keyId, status));

    // If all keys are expired, fire an error. |every| is always true for an
    // empty array but we shouldn't fire an error for a lack of key status info.
    const statuses = Array.from(publicMap.values());
    const allExpired = statuses.length &&
                       statuses.every((status) => status == 'expired');

    if (allExpired) {
      this.onError_(new shaka.util.Error(
          shaka.util.Error.Severity.CRITICAL,
          shaka.util.Error.Category.DRM,
          shaka.util.Error.Code.EXPIRED));
    }

    this.playerInterface_.onKeyStatus(shaka.util.MapUtils.asObject(publicMap));
  }

  /**
   * Returns a Promise to a map of EME support for well-known key systems.
   *
   * @return {!Promise.<!Object.<string, ?shaka.extern.DrmSupportType>>}
   */
  static async probeSupport() {
    const testKeySystems = [
      'org.w3.clearkey',
      'com.widevine.alpha',
      'com.widevine.alpha.experiment', // Widevine L1 in Windows
      'com.microsoft.playready',
      'com.microsoft.playready.hardware',
      'com.microsoft.playready.recommendation',
      'com.chromecast.playready',
      'com.apple.fps.1_0',
      'com.apple.fps',
    ];

    if (!shaka.drm.DrmUtils.isBrowserSupported()) {
      const result = {};
      for (const keySystem of testKeySystems) {
        result[keySystem] = null;
      }
      return result;
    }

    const widevineRobustness = [
      'SW_SECURE_CRYPTO',
      'SW_SECURE_DECODE',
      'HW_SECURE_CRYPTO',
      'HW_SECURE_DECODE',
      'HW_SECURE_ALL',
    ];

    const playreadyRobustness = [
      '150',
      '2000',
      '3000',
    ];

    const testRobustness = {
      'com.widevine.alpha': widevineRobustness,
      'com.widevine.alpha.experiment': widevineRobustness,
      'com.microsoft.playready.recommendation': playreadyRobustness,
    };

    const basicVideoCapabilities = [
      {contentType: 'video/mp4; codecs="avc1.42E01E"'},
      {contentType: 'video/webm; codecs="vp8"'},
    ];

    const basicAudioCapabilities = [
      {contentType: 'audio/mp4; codecs="mp4a.40.2"'},
      {contentType: 'audio/webm; codecs="opus"'},
    ];

    const basicConfigTemplate = {
      videoCapabilities: basicVideoCapabilities,
      audioCapabilities: basicAudioCapabilities,
      initDataTypes: ['cenc', 'sinf', 'skd', 'keyids'],
    };

    const testEncryptionSchemes = [
      null,
      'cenc',
      'cbcs',
      'cbcs-1-9',
    ];

    /** @type {!Map.<string, ?shaka.extern.DrmSupportType>} */
    const support = new Map();

    /**
     * @param {string} keySystem
     * @param {MediaKeySystemAccess} access
     * @return {!Promise}
     */
    const processMediaKeySystemAccess = async (keySystem, access) => {
      try {
        await access.createMediaKeys();
      } catch (error) {
        // In some cases, we can get a successful access object but fail to
        // create a MediaKeys instance.  When this happens, don't update the
        // support structure.  If a previous test succeeded, we won't overwrite
        // any of the results.
        return;
      }

      // If sessionTypes is missing, assume no support for persistent-license.
      const sessionTypes = access.getConfiguration().sessionTypes;
      let persistentState = sessionTypes ?
          sessionTypes.includes('persistent-license') : false;

      // Tizen 3.0 doesn't support persistent licenses, but reports that it
      // does.  It doesn't fail until you call update() with a license
      // response, which is way too late.
      // This is a work-around for #894.
      if (shaka.util.Platform.isTizen3()) {
        persistentState = false;
      }

      const videoCapabilities = access.getConfiguration().videoCapabilities;
      const audioCapabilities = access.getConfiguration().audioCapabilities;

      let supportValue = {
        persistentState,
        encryptionSchemes: [],
        videoRobustnessLevels: [],
        audioRobustnessLevels: [],
      };
      if (support.has(keySystem) && support.get(keySystem)) {
        // Update the existing non-null value.
        supportValue = support.get(keySystem);
      } else {
        // Set a new one.
        support.set(keySystem, supportValue);
      }

      // If the returned config doesn't mention encryptionScheme, the field
      // is not supported.  If installed, our polyfills should make sure this
      // doesn't happen.
      const returnedScheme = videoCapabilities[0].encryptionScheme;
      if (returnedScheme &&
          !supportValue.encryptionSchemes.includes(returnedScheme)) {
        supportValue.encryptionSchemes.push(returnedScheme);
      }

      const videoRobustness = videoCapabilities[0].robustness;
      if (videoRobustness &&
          !supportValue.videoRobustnessLevels.includes(videoRobustness)) {
        supportValue.videoRobustnessLevels.push(videoRobustness);
      }

      const audioRobustness = audioCapabilities[0].robustness;
      if (audioRobustness &&
          !supportValue.audioRobustnessLevels.includes(audioRobustness)) {
        supportValue.audioRobustnessLevels.push(audioRobustness);
      }
    };

    const testSystemEme = async (keySystem, encryptionScheme,
        videoRobustness, audioRobustness) => {
      try {
        const basicConfig =
            shaka.util.ObjectUtils.cloneObject(basicConfigTemplate);
        for (const cap of basicConfig.videoCapabilities) {
          cap.encryptionScheme = encryptionScheme;
          cap.robustness = videoRobustness;
        }
        for (const cap of basicConfig.audioCapabilities) {
          cap.encryptionScheme = encryptionScheme;
          cap.robustness = audioRobustness;
        }

        const offlineConfig = shaka.util.ObjectUtils.cloneObject(basicConfig);
        offlineConfig.persistentState = 'required';
        offlineConfig.sessionTypes = ['persistent-license'];

        const configs = [offlineConfig, basicConfig];
        // On some (Android) WebView environments,
        // requestMediaKeySystemAccess will
        // not resolve or reject, at least if RESOURCE_PROTECTED_MEDIA_ID
        // is not set.  This is a workaround for that issue.
        const TIMEOUT_FOR_CHECK_ACCESS_IN_SECONDS = 1;
        const access =
          await shaka.util.Functional.promiseWithTimeout(
              TIMEOUT_FOR_CHECK_ACCESS_IN_SECONDS,
              navigator.requestMediaKeySystemAccess(keySystem, configs),
          );
        await processMediaKeySystemAccess(keySystem, access);
      } catch (error) {}  // Ignore errors.
    };

    const testSystemMcap = async (keySystem, encryptionScheme,
        videoRobustness, audioRobustness) => {
      try {
        const decodingConfig = {
          type: 'media-source',
          video: {
            contentType: basicVideoCapabilities[0].contentType,
            width: 640,
            height: 480,
            bitrate: 1,
            framerate: 1,
          },
          audio: {
            contentType: basicAudioCapabilities[0].contentType,
            channels: 2,
            bitrate: 1,
            samplerate: 1,
          },
          keySystemConfiguration: {
            keySystem,
            video: {
              encryptionScheme,
              robustness: videoRobustness,
            },
            audio: {
              encryptionScheme,
              robustness: audioRobustness,
            },
          },
        };
        // On some (Android) WebView environments, decodingInfo will
        // not resolve or reject, at least if RESOURCE_PROTECTED_MEDIA_ID
        // is not set.  This is a workaround for that issue.
        const TIMEOUT_FOR_DECODING_INFO_IN_SECONDS = 1;
        const decodingInfo =
          await shaka.util.Functional.promiseWithTimeout(
              TIMEOUT_FOR_DECODING_INFO_IN_SECONDS,
              navigator.mediaCapabilities.decodingInfo(decodingConfig),
          );

        const access = decodingInfo.keySystemAccess;
        await processMediaKeySystemAccess(keySystem, access);
      } catch (error) {
        // Ignore errors.
        shaka.log.v2('Failed to probe support for', keySystem, error);
      }
    };

    // Initialize the support structure for each key system.
    for (const keySystem of testKeySystems) {
      support.set(keySystem, null);
    }

    // Test each key system and encryption scheme.
    const tests = [];
    for (const encryptionScheme of testEncryptionSchemes) {
      for (const keySystem of testKeySystems) {
        // Our Polyfill will reject anything apart com.apple.fps key systems.
        // It seems the Safari modern EME API will allow to request a
        // MediaKeySystemAccess for the ClearKey CDM, create and update a key
        // session but playback will never start
        // Safari bug: https://bugs.webkit.org/show_bug.cgi?id=231006
        if (keySystem === 'org.w3.clearkey' &&
            shaka.util.Platform.isSafari()) {
          continue;
        }
        tests.push(testSystemEme(keySystem, encryptionScheme, '', ''));
        tests.push(testSystemMcap(keySystem, encryptionScheme, '', ''));
      }
    }

    for (const keySystem of testKeySystems) {
      for (const robustness of (testRobustness[keySystem] || [])) {
        tests.push(testSystemEme(keySystem, null, robustness, ''));
        tests.push(testSystemEme(keySystem, null, '', robustness));
        tests.push(testSystemMcap(keySystem, null, robustness, ''));
        tests.push(testSystemMcap(keySystem, null, '', robustness));
      }
    }

    await Promise.all(tests);
    return shaka.util.MapUtils.asObject(support);
  }

  /** @private */
  onPlay_() {
    for (const event of this.mediaKeyMessageEvents_) {
      this.sendLicenseRequest_(event);
    }

    this.initialRequestsSent_ = true;
    this.mediaKeyMessageEvents_ = [];
  }

  /**
   * Close a drm session while accounting for a bug in Chrome. Sometimes the
   * Promise returned by close() never resolves.
   *
   * See issue #2741 and http://crbug.com/1108158.
   * @param {!MediaKeySession} session
   * @return {!Promise}
   * @private
   */
  async closeSession_(session) {
    try {
      await shaka.util.Functional.promiseWithTimeout(
          shaka.drm.DrmEngine.CLOSE_TIMEOUT_,
          Promise.all([session.close(), session.closed]));
    } catch (e) {
      shaka.log.warning('Timeout waiting for session close');
    }
  }

  /** @private */
  async closeOpenSessions_() {
    // Close all open sessions.
    const openSessions = Array.from(this.activeSessions_.entries());
    this.activeSessions_.clear();

    // Close all sessions before we remove media keys from the video element.
    await Promise.all(openSessions.map(async ([session, metadata]) => {
      try {
        /**
         * Special case when a persistent-license session has been initiated,
         * without being registered in the offline sessions at start-up.
         * We should remove the session to prevent it from being orphaned after
         * the playback session ends
         */
        if (!this.initializedForStorage_ &&
            !this.storedPersistentSessions_.has(session.sessionId) &&
            metadata.type === 'persistent-license' &&
            !this.config_.persistentSessionOnlinePlayback) {
          shaka.log.v1('Removing session', session.sessionId);

          await session.remove();
        } else {
          shaka.log.v1('Closing session', session.sessionId, metadata);

          await this.closeSession_(session);
        }
      } catch (error) {
        // Ignore errors when closing the sessions. Closing a session that
        // generated no key requests will throw an error.

        shaka.log.error('Failed to close or remove the session', error);
      }
    }));
  }

  /**
   * Concat the audio and video drmInfos in a variant.
   * @param {shaka.extern.Variant} variant
   * @return {!Array.<!shaka.extern.DrmInfo>}
   * @private
   */
  getVariantDrmInfos_(variant) {
    const videoDrmInfos = variant.video ? variant.video.drmInfos : [];
    const audioDrmInfos = variant.audio ? variant.audio.drmInfos : [];
    return videoDrmInfos.concat(audioDrmInfos);
  }

  /**
   * Called in an interval timer to poll the expiration times of the sessions.
   * We don't get an event from EME when the expiration updates, so we poll it
   * so we can fire an event when it happens.
   * @private
   */
  pollExpiration_() {
    this.activeSessions_.forEach((metadata, session) => {
      const oldTime = metadata.oldExpiration;
      let newTime = session.expiration;
      if (isNaN(newTime)) {
        newTime = Infinity;
      }

      if (newTime != oldTime) {
        this.playerInterface_.onExpirationUpdated(session.sessionId, newTime);
        metadata.oldExpiration = newTime;
      }
    });
  }

  /**
   * @return {boolean}
   * @private
   */
  areAllSessionsLoaded_() {
    const metadatas = this.activeSessions_.values();
    return shaka.util.Iterables.every(metadatas, (data) => data.loaded);
  }

  /**
   * @return {boolean}
   * @private
   */
  areAllKeysUsable_() {
    const keyIds = (this.currentDrmInfo_ && this.currentDrmInfo_.keyIds) ||
        new Set([]);

    for (const keyId of keyIds) {
      const status = this.keyStatusByKeyId_.get(keyId);

      if (status !== 'usable') {
        return false;
      }
    }

    return true;
  }

  /**
   * Replace the drm info used in each variant in |variants| to reflect each
   * key service in |keySystems|.
   *
   * @param {!Array.<shaka.extern.Variant>} variants
   * @param {!Map.<string, string>} keySystems
   * @private
   */
  static replaceDrmInfo_(variants, keySystems) {
    const drmInfos = [];

    keySystems.forEach((uri, keySystem) => {
      drmInfos.push({
        keySystem: keySystem,
        licenseServerUri: uri,
        distinctiveIdentifierRequired: false,
        persistentStateRequired: false,
        audioRobustness: '',
        videoRobustness: '',
        serverCertificate: null,
        serverCertificateUri: '',
        initData: [],
        keyIds: new Set(),
      });
    });

    for (const variant of variants) {
      if (variant.video) {
        variant.video.drmInfos = drmInfos;
      }
      if (variant.audio) {
        variant.audio.drmInfos = drmInfos;
      }
    }
  }


  /**
   * Creates a DrmInfo object describing the settings used to initialize the
   * engine.
   *
   * @param {string} keySystem
   * @param {!Array.<shaka.extern.DrmInfo>} drmInfos
   * @return {shaka.extern.DrmInfo}
   *
   * @private
   */
  createDrmInfoByInfos_(keySystem, drmInfos) {
    /** @type {!Array.<string>} */
    const encryptionSchemes = [];

    /** @type {!Array.<string>} */
    const licenseServers = [];

    /** @type {!Array.<string>} */
    const serverCertificateUris = [];

    /** @type {!Array.<!Uint8Array>} */
    const serverCerts = [];

    /** @type {!Array.<!shaka.extern.InitDataOverride>} */
    const initDatas = [];

    /** @type {!Set.<string>} */
    const keyIds = new Set();

    /** @type {!Set.<string>} */
    const keySystemUris = new Set();

    shaka.drm.DrmEngine.processDrmInfos_(
        drmInfos, encryptionSchemes, licenseServers, serverCerts,
        serverCertificateUris, initDatas, keyIds, keySystemUris);

    if (encryptionSchemes.length > 1) {
      shaka.log.warning('Multiple unique encryption schemes found! ' +
                        'Only the first will be used.');
    }

    if (serverCerts.length > 1) {
      shaka.log.warning('Multiple unique server certificates found! ' +
                        'Only the first will be used.');
    }

    if (licenseServers.length > 1) {
      shaka.log.warning('Multiple unique license server URIs found! ' +
                        'Only the first will be used.');
    }

    if (serverCertificateUris.length > 1) {
      shaka.log.warning('Multiple unique server certificate URIs found! ' +
                        'Only the first will be used.');
    }

    const defaultSessionType =
        this.usePersistentLicenses_ ? 'persistent-license' : 'temporary';

    /** @type {shaka.extern.DrmInfo} */
    const res = {
      keySystem,
      encryptionScheme: encryptionSchemes[0],
      licenseServerUri: licenseServers[0],
      distinctiveIdentifierRequired: drmInfos[0].distinctiveIdentifierRequired,
      persistentStateRequired: drmInfos[0].persistentStateRequired,
      sessionType: drmInfos[0].sessionType || defaultSessionType,
      audioRobustness: drmInfos[0].audioRobustness || '',
      videoRobustness: drmInfos[0].videoRobustness || '',
      serverCertificate: serverCerts[0],
      serverCertificateUri: serverCertificateUris[0],
      initData: initDatas,
      keyIds,
    };

    if (keySystemUris.size > 0) {
      res.keySystemUris = keySystemUris;
    }

    for (const info of drmInfos) {
      if (info.distinctiveIdentifierRequired) {
        res.distinctiveIdentifierRequired = info.distinctiveIdentifierRequired;
      }

      if (info.persistentStateRequired) {
        res.persistentStateRequired = info.persistentStateRequired;
      }
    }

    return res;
  }

  /**
   * Creates a DrmInfo object describing the settings used to initialize the
   * engine.
   *
   * @param {string} keySystem
   * @param {MediaKeySystemConfiguration} config
   * @return {shaka.extern.DrmInfo}
   *
   * @private
   */
  static createDrmInfoByConfigs_(keySystem, config) {
    /** @type {!Array.<string>} */
    const encryptionSchemes = [];

    /** @type {!Array.<string>} */
    const licenseServers = [];

    /** @type {!Array.<string>} */
    const serverCertificateUris = [];

    /** @type {!Array.<!Uint8Array>} */
    const serverCerts = [];

    /** @type {!Array.<!shaka.extern.InitDataOverride>} */
    const initDatas = [];

    /** @type {!Set.<string>} */
    const keyIds = new Set();

    // TODO: refactor, don't stick drmInfos onto MediaKeySystemConfiguration
    shaka.drm.DrmEngine.processDrmInfos_(
        config['drmInfos'], encryptionSchemes, licenseServers, serverCerts,
        serverCertificateUris, initDatas, keyIds);

    if (encryptionSchemes.length > 1) {
      shaka.log.warning('Multiple unique encryption schemes found! ' +
                        'Only the first will be used.');
    }

    if (serverCerts.length > 1) {
      shaka.log.warning('Multiple unique server certificates found! ' +
                        'Only the first will be used.');
    }

    if (serverCertificateUris.length > 1) {
      shaka.log.warning('Multiple unique server certificate URIs found! ' +
                        'Only the first will be used.');
    }

    if (licenseServers.length > 1) {
      shaka.log.warning('Multiple unique license server URIs found! ' +
                        'Only the first will be used.');
    }

    // TODO: This only works when all DrmInfo have the same robustness.
    const audioRobustness =
        config.audioCapabilities ? config.audioCapabilities[0].robustness : '';
    const videoRobustness =
        config.videoCapabilities ? config.videoCapabilities[0].robustness : '';

    const distinctiveIdentifier = config.distinctiveIdentifier;
    return {
      keySystem,
      encryptionScheme: encryptionSchemes[0],
      licenseServerUri: licenseServers[0],
      distinctiveIdentifierRequired: (distinctiveIdentifier == 'required'),
      persistentStateRequired: (config.persistentState == 'required'),
      sessionType: config.sessionTypes[0] || 'temporary',
      audioRobustness: audioRobustness || '',
      videoRobustness: videoRobustness || '',
      serverCertificate: serverCerts[0],
      serverCertificateUri: serverCertificateUris[0],
      initData: initDatas,
      keyIds,
    };
  }

  /**
   * Extract license server, server cert, and init data from |drmInfos|, taking
   * care to eliminate duplicates.
   *
   * @param {!Array.<shaka.extern.DrmInfo>} drmInfos
   * @param {!Array.<string>} licenseServers
   * @param {!Array.<string>} encryptionSchemes
   * @param {!Array.<!Uint8Array>} serverCerts
   * @param {!Array.<string>} serverCertificateUris
   * @param {!Array.<!shaka.extern.InitDataOverride>} initDatas
   * @param {!Set.<string>} keyIds
   * @param {!Set.<string>} [keySystemUris]
   * @private
   */
  static processDrmInfos_(
      drmInfos, encryptionSchemes, licenseServers, serverCerts,
      serverCertificateUris, initDatas, keyIds, keySystemUris) {
    /** @type {function(shaka.extern.InitDataOverride,
     *                  shaka.extern.InitDataOverride):boolean} */
    const initDataOverrideEqual = (a, b) => {
      if (a.keyId && a.keyId == b.keyId) {
        // Two initDatas with the same keyId are considered to be the same,
        // unless that "same keyId" is null.
        return true;
      }
      return a.initDataType == b.initDataType &&
         shaka.util.BufferUtils.equal(a.initData, b.initData);
    };

    const clearkeyDataStart = 'data:application/json;base64,';
    const clearKeyLicenseServers = [];

    for (const drmInfo of drmInfos) {
      // Build an array of unique encryption schemes.
      if (!encryptionSchemes.includes(drmInfo.encryptionScheme)) {
        encryptionSchemes.push(drmInfo.encryptionScheme);
      }

      // Build an array of unique license servers.
      if (drmInfo.keySystem == 'org.w3.clearkey' &&
          drmInfo.licenseServerUri.startsWith(clearkeyDataStart)) {
        if (!clearKeyLicenseServers.includes(drmInfo.licenseServerUri)) {
          clearKeyLicenseServers.push(drmInfo.licenseServerUri);
        }
      } else if (!licenseServers.includes(drmInfo.licenseServerUri)) {
        licenseServers.push(drmInfo.licenseServerUri);
      }

      // Build an array of unique license servers.
      if (!serverCertificateUris.includes(drmInfo.serverCertificateUri)) {
        serverCertificateUris.push(drmInfo.serverCertificateUri);
      }

      // Build an array of unique server certs.
      if (drmInfo.serverCertificate) {
        const found = serverCerts.some(
            (cert) => shaka.util.BufferUtils.equal(
                cert, drmInfo.serverCertificate));
        if (!found) {
          serverCerts.push(drmInfo.serverCertificate);
        }
      }

      // Build an array of unique init datas.
      if (drmInfo.initData) {
        for (const initDataOverride of drmInfo.initData) {
          const found = initDatas.some(
              (initData) =>
                initDataOverrideEqual(initData, initDataOverride));
          if (!found) {
            initDatas.push(initDataOverride);
          }
        }
      }

      if (drmInfo.keyIds) {
        for (const keyId of drmInfo.keyIds) {
          keyIds.add(keyId);
        }
      }

      if (drmInfo.keySystemUris && keySystemUris) {
        for (const keySystemUri of drmInfo.keySystemUris) {
          keySystemUris.add(keySystemUri);
        }
      }
    }

    if (clearKeyLicenseServers.length == 1) {
      licenseServers.push(clearKeyLicenseServers[0]);
    } else if (clearKeyLicenseServers.length > 0) {
      const keys = [];
      for (const clearKeyLicenseServer of clearKeyLicenseServers) {
        const license = window.atob(
            clearKeyLicenseServer.split(clearkeyDataStart).pop());
        const jwkSet = /** @type {{keys: !Array}} */(JSON.parse(license));
        keys.push(...jwkSet.keys);
      }
      const newJwkSet = {keys: keys};
      const newLicense = JSON.stringify(newJwkSet);
      licenseServers.push(clearkeyDataStart + window.btoa(newLicense));
    }
  }

  /**
   * Use |servers| and |advancedConfigs| to fill in missing values in drmInfo
   * that the parser left blank. Before working with any drmInfo, it should be
   * passed through here as it is uncommon for drmInfo to be complete when
   * fetched from a manifest because most manifest formats do not have the
   * required information. Also applies the key systems mapping.
   *
   * @param {shaka.extern.DrmInfo} drmInfo
   * @param {!Map.<string, string>} servers
   * @param {!Map.<string, shaka.extern.AdvancedDrmConfiguration>}
   *   advancedConfigs
   * @param {!Object.<string, string>} keySystemsMapping
   * @private
   */
  static fillInDrmInfoDefaults_(drmInfo, servers, advancedConfigs,
      keySystemsMapping) {
    const originalKeySystem = drmInfo.keySystem;

    if (!originalKeySystem) {
      // This is a placeholder from the manifest parser for an unrecognized key
      // system.  Skip this entry, to avoid logging nonsensical errors.
      return;
    }

    // The order of preference for drmInfo:
    // 1. Clear Key config, used for debugging, should override everything else.
    //    (The application can still specify a clearkey license server.)
    // 2. Application-configured servers, if any are present, should override
    //    anything from the manifest.  Nuance: if key system A is in the
    //    manifest and key system B is in the player config, only B will be
    //    used, not A.
    // 3. Manifest-provided license servers are only used if nothing else is
    //    specified.
    // This is important because it allows the application a clear way to
    // indicate which DRM systems should be used on platforms with multiple DRM
    // systems.
    // The only way to get license servers from the manifest is not to specify
    // any in your player config.

    if (originalKeySystem == 'org.w3.clearkey' && drmInfo.licenseServerUri) {
      // Preference 1: Clear Key with pre-configured keys will have a data URI
      // assigned as its license server.  Don't change anything.
      return;
    } else if (servers.size) {
      // Preference 2: If anything is configured at the application level,
      // override whatever was in the manifest.
      const server = servers.get(originalKeySystem) || '';

      drmInfo.licenseServerUri = server;
    } else {
      // Preference 3: Keep whatever we had in drmInfo.licenseServerUri, which
      // comes from the manifest.
    }

    if (!drmInfo.keyIds) {
      drmInfo.keyIds = new Set();
    }

    const advancedConfig = advancedConfigs.get(originalKeySystem);

    if (advancedConfig) {
      if (!drmInfo.distinctiveIdentifierRequired) {
        drmInfo.distinctiveIdentifierRequired =
            advancedConfig.distinctiveIdentifierRequired;
      }

      if (!drmInfo.persistentStateRequired) {
        drmInfo.persistentStateRequired =
            advancedConfig.persistentStateRequired;
      }

      if (!drmInfo.videoRobustness) {
        drmInfo.videoRobustness = advancedConfig.videoRobustness;
      }

      if (!drmInfo.audioRobustness) {
        drmInfo.audioRobustness = advancedConfig.audioRobustness;
      }

      if (!drmInfo.serverCertificate) {
        drmInfo.serverCertificate = advancedConfig.serverCertificate;
      }

      if (advancedConfig.sessionType) {
        drmInfo.sessionType = advancedConfig.sessionType;
      }

      if (!drmInfo.serverCertificateUri) {
        drmInfo.serverCertificateUri = advancedConfig.serverCertificateUri;
      }
    }

    if (keySystemsMapping[originalKeySystem]) {
      drmInfo.keySystem = keySystemsMapping[originalKeySystem];
    }

    // Chromecast has a variant of PlayReady that uses a different key
    // system ID.  Since manifest parsers convert the standard PlayReady
    // UUID to the standard PlayReady key system ID, here we will switch
    // to the Chromecast version if we are running on that platform.
    // Note that this must come after fillInDrmInfoDefaults_, since the
    // player config uses the standard PlayReady ID for license server
    // configuration.
    if (window.cast && window.cast.__platform__) {
      if (originalKeySystem == 'com.microsoft.playready') {
        drmInfo.keySystem = 'com.chromecast.playready';
      }
    }
  }

  /**
   * Parse  pssh from a media segment and announce new initData
   *
   * @param {shaka.util.ManifestParserUtils.ContentType} contentType
   * @param {!BufferSource} mediaSegment
   * @return {!Promise<void>}
   */
  parseInbandPssh(contentType, mediaSegment) {
    if (!this.config_.parseInbandPsshEnabled || this.manifestInitData_) {
      return Promise.resolve();
    }

    const ContentType = shaka.util.ManifestParserUtils.ContentType;
    if (![ContentType.AUDIO, ContentType.VIDEO].includes(contentType)) {
      return Promise.resolve();
    }

    const pssh = new shaka.util.Pssh(
        shaka.util.BufferUtils.toUint8(mediaSegment));

    let totalLength = 0;
    for (const data of pssh.data) {
      totalLength += data.length;
    }
    if (totalLength == 0) {
      return Promise.resolve();
    }
    const combinedData = new Uint8Array(totalLength);
    let pos = 0;
    for (const data of pssh.data) {
      combinedData.set(data, pos);
      pos += data.length;
    }
    this.newInitData('cenc', combinedData);
    return this.allSessionsLoaded_;
  }
};


/**
 * @typedef {{
 *   loaded: boolean,
 *   initData: Uint8Array,
 *   initDataType: ?string,
 *   oldExpiration: number,
 *   type: string,
 *   updatePromise: shaka.util.PublicPromise
 * }}
 *
 * @description A record to track sessions and suppress duplicate init data.
 * @property {boolean} loaded
 *   True once the key status has been updated (to a non-pending state).  This
 *   does not mean the session is 'usable'.
 * @property {Uint8Array} initData
 *   The init data used to create the session.
 * @property {?string} initDataType
 *   The init data type used to create the session.
 * @property {!MediaKeySession} session
 *   The session object.
 * @property {number} oldExpiration
 *   The expiration of the session on the last check.  This is used to fire
 *   an event when it changes.
 * @property {string} type
 *   The session type
 * @property {shaka.util.PublicPromise} updatePromise
 *   An optional Promise that will be resolved/rejected on the next update()
 *   call.  This is used to track the 'license-release' message when calling
 *   remove().
 */
shaka.drm.DrmEngine.SessionMetaData;


/**
 * @typedef {{
 *   netEngine: !shaka.net.NetworkingEngine,
 *   onError: function(!shaka.util.Error),
 *   onKeyStatus: function(!Object.<string,string>),
 *   onExpirationUpdated: function(string,number),
 *   onEvent: function(!Event)
 * }}
 *
 * @property {shaka.net.NetworkingEngine} netEngine
 *   The NetworkingEngine instance to use.  The caller retains ownership.
 * @property {function(!shaka.util.Error)} onError
 *   Called when an error occurs.  If the error is recoverable (see
 *   {@link shaka.util.Error}) then the caller may invoke either
 *   StreamingEngine.switch*() or StreamingEngine.seeked() to attempt recovery.
 * @property {function(!Object.<string,string>)} onKeyStatus
 *   Called when key status changes.  The argument is a map of hex key IDs to
 *   statuses.
 * @property {function(string,number)} onExpirationUpdated
 *   Called when the session expiration value changes.
 * @property {function(!Event)} onEvent
 *   Called when an event occurs that should be sent to the app.
 */
shaka.drm.DrmEngine.PlayerInterface;

/**
 * The amount of time, in seconds, we wait to consider a session closed.
 * This allows us to work around Chrome bug https://crbug.com/1108158.
 * @private {number}
 */
shaka.drm.DrmEngine.CLOSE_TIMEOUT_ = 1;


/**
 * The amount of time, in seconds, we wait to consider session loaded even if no
 * key status information is available.  This allows us to support browsers/CDMs
 * without key statuses.
 * @private {number}
 */
shaka.drm.DrmEngine.SESSION_LOAD_TIMEOUT_ = 5;


/**
 * The amount of time, in seconds, we wait to batch up rapid key status changes.
 * This allows us to avoid multiple expiration events in most cases.
 * @type {number}
 */
shaka.drm.DrmEngine.KEY_STATUS_BATCH_TIME = 0.5;