shaka-player
Version:
DASH/EME video player library
1,517 lines (1,320 loc) • 95.7 kB
JavaScript
/*! @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.debug.RunningInLab');
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.drm.DrmUtils.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, /* isLive= */ false);
}
/**
* 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
* @param {boolean=} isLive
* @return {!Promise}
*/
initForPlayback(variants, offlineSessionIds, isLive = true) {
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, isLive);
}
/**
* 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.
* @param {boolean} isLive
* @return {!Promise} Resolved if/when a key system has been chosen.
* @private
*/
async init_(variants, isLive) {
goog.asserts.assert(this.config_,
'DrmEngine configure() must be called before init()!');
shaka.drm.DrmEngine.configureClearKey(this.config_.clearKeys, variants);
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 && isLive) {
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;
/**
* Expand robustness into multiple drm infos if multiple video robustness
* levels were provided.
*
* robustness can be either a single item as a string or multiple items as
* an array of strings.
*
* @param {!Array<shaka.extern.DrmInfo>} drmInfos
* @param {string} robustnessType
* @return {!Array<shaka.extern.DrmInfo>}
*/
const expandRobustness = (drmInfos, robustnessType) => {
const newDrmInfos = [];
for (const drmInfo of drmInfos) {
let items = drmInfo[robustnessType] ||
(this.config_.advanced &&
this.config_.advanced[drmInfo.keySystem] &&
this.config_.advanced[drmInfo.keySystem][robustnessType]) || '';
if (items == '' &&
shaka.drm.DrmUtils.isWidevineKeySystem(drmInfo.keySystem)) {
if (robustnessType == 'audioRobustness') {
items = [this.config_.defaultAudioRobustnessForWidevine];
} else if (robustnessType == 'videoRobustness') {
items = [this.config_.defaultVideoRobustnessForWidevine];
}
}
if (typeof items === 'string') {
// if drmInfo's robustness has already been expanded,
// use the drmInfo directly.
newDrmInfos.push(drmInfo);
} else if (Array.isArray(items)) {
if (items.length === 0) {
items = [''];
}
for (const item of items) {
newDrmInfos.push(
Object.assign({}, drmInfo, {[robustnessType]: item}),
);
}
}
}
return newDrmInfos;
};
for (const variant of variants) {
if (variant.video) {
variant.video.drmInfos =
expandRobustness(variant.video.drmInfos,
'videoRobustness');
variant.video.drmInfos =
expandRobustness(variant.video.drmInfos,
'audioRobustness');
}
if (variant.audio) {
variant.audio.drmInfos =
expandRobustness(variant.audio.drmInfos,
'videoRobustness');
variant.audio.drmInfos =
expandRobustness(variant.audio.drmInfos,
'audioRobustness');
}
}
// 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.video_ === video) {
return;
}
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.
let preferredKeySystems = this.config_.preferredKeySystems;
if (!preferredKeySystems.length) {
// If there is no preference set and we only have one license server, we
// use this as preference. This is used to override manifests on those
// that have the embedded license and the browser supports multiple DRMs.
const servers = shaka.util.MapUtils.asMap(this.config_.servers);
if (servers.size == 1) {
preferredKeySystems = Array.from(servers.keys());
}
}
for (const preferredKeySystem of 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;
if (preferredKeySystems.includes(originalKeySystem)) {
continue;
}
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;
}
/**
* 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.isClearKeySystem(
this.currentDrmInfo_.key