UNPKG

shaka-player

Version:
1,517 lines (1,320 loc) 95.7 kB
/*! @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