/*! @license * Shaka Player * Copyright 2016 Google LLC * SPDX-License-Identifier: Apache-2.0 */goog.provide('shaka.media.DrmEngine');goog.require('goog.asserts');goog.require('shaka.log');goog.require('shaka.media.Transmuxer');goog.require('shaka.net.NetworkingEngine');goog.require('shaka.util.BufferUtils');goog.require('shaka.util.Destroyer');goog.require('shaka.util.Error');goog.require('shaka.util.EventManager');goog.require('shaka.util.FairPlayUtils');goog.require('shaka.util.FakeEvent');goog.require('shaka.util.IDestroyable');goog.require('shaka.util.Iterables');goog.require('shaka.util.Lazy');goog.require('shaka.util.MapUtils');goog.require('shaka.util.MimeUtils');goog.require('shaka.util.Platform');goog.require('shaka.util.PublicPromise');goog.require('shaka.util.StreamUtils');goog.require('shaka.util.StringUtils');goog.require('shaka.util.Timer');goog.require('shaka.util.Uint8ArrayUtils');/** @implements {shaka.util.IDestroyable} */shaka.media.DrmEngine = class { /** * @param {shaka.media.DrmEngine.PlayerInterface} playerInterface * @param {number=} updateExpirationTime */ constructor(playerInterface, updateExpirationTime = 1) { /** @private {?shaka.media.DrmEngine.PlayerInterface} */ this.playerInterface_ = playerInterface; /** @private {!Set.<string>} */ this.supportedTypes_ = new Set(); /** @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.media.DrmEngine.SessionMetaData>} */ this.activeSessions_ = new Map(); /** @private {!Array.<string>} */ this.offlineSessionIds_ = []; /** @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) => { 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_(); }).tickEvery(/* seconds= */ updateExpirationTime); // 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; } /** @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_) { goog.asserts.assert(!this.video_.src, '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. } this.video_ = null; } // Break references to everything else we hold internally. this.currentDrmInfo_ = null; this.supportedTypes_.clear(); this.mediaKeys_ = null; this.offlineSessionIds_ = []; this.config_ = null; this.onError_ = () => {}; this.playerInterface_ = null; this.srcEquals_ = false; } /** * 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 */ configure(config) { this.config_ = config; } /** * @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.offlineSessionIds_ = []; // 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.offlineSessionIds_ = offlineSessionIds; this.usePersistentLicenses_ = offlineSessionIds.length > 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 * |offlineSessionIds_| 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.media.DrmEngine.replaceDrmInfo_(variants, servers); } // Make sure all the drm infos are valid and filled in correctly. for (const variant of variants) { const drmInfos = this.getVariantDrmInfos_(variant); for (const info of drmInfos) { shaka.media.DrmEngine.fillInDrmInfoDefaults_( info, shaka.util.MapUtils.asMap(this.config_.servers), shaka.util.MapUtils.asMap(this.config_.advanced || {})); } } /** @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_); 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 and 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 ('webkitCurrentPlaybackTargetIsWireless' in this.video_) { this.eventManager_.listen(this.video_, 'webkitcurrentplaybacktargetiswirelesschanged', () => this.closeOpenSessions_()); } let setMediaKeys = this.video_.setMediaKeys(this.mediaKeys_); setMediaKeys = setMediaKeys.catch((exception) => { goog.asserts.assert(exception instanceof Error, 'Wrong error type!'); return Promise.reject(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)); }); await setMediaKeys; this.destroyer_.ensureNotDestroyed(); this.createOrLoad(); if (!this.currentDrmInfo_.initData.length && !this.offlineSessionIds_.length) { // Explicit init data for any one stream or an offline session is // sufficient to suppress 'encrypted' events for all streams. const cb = (e) => this.newInitData( e.initDataType, shaka.util.BufferUtils.toUint8(e.initData)); this.eventManager_.listen(this.video_, 'encrypted', cb); } } /** * 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); 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); // 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} */ createOrLoad() { // Create temp sessions. const initDatas = (this.currentDrmInfo_ ? this.currentDrmInfo_.initData : []) || []; for (const initDataOverride of initDatas) { this.newInitData( initDataOverride.initDataType, initDataOverride.initData); } // Load each session. for (const sessionId of this.offlineSessionIds_) { this.loadOfflineSession_(sessionId); } // If we have no sessions, we need to resolve the promise right now or else // it will never get resolved. if (!initDatas.length && !this.offlineSessionIds_.length) { 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) { // Suppress duplicate init data. // Note that some init data are extremely large and can't portably be used // as keys in a dictionary. const metadatas = this.activeSessions_.values(); for (const metadata of metadatas) { // Tizen 2015 and 2016 models will send multiple webkitneedkey events // with the same init data. If the duplicates are supressed, playback // will stall without errors. if (shaka.util.BufferUtils.equal(initData, metadata.initData) && !shaka.util.Platform.isTizen2()) { shaka.log.debug('Ignoring duplicate init data.'); return; } } this.createSession(initDataType, initData, this.currentDrmInfo_.sessionType); } /** @return {boolean} */ initialized() { return this.initialized_; } /** * @param {?shaka.extern.DrmInfo} drmInfo * @return {string} */ static keySystem(drmInfo) { return drmInfo ? drmInfo.keySystem : ''; } /** * @param {?string} keySystem * @return {boolean} */ static isPlayReadyKeySystem(keySystem) { if (keySystem) { return !!keySystem.match(/^com\.(microsoft|chromecast)\.playready/); } return false; } /** * @param {?string} keySystem * @return {boolean} */ static isFairPlayKeySystem(keySystem) { if (keySystem) { return !!keySystem.match(/^com\.apple\.fps/); } return false; } /** * Check if DrmEngine (as initialized) will likely be able to support the * given content type. * * @param {string} contentType * @return {boolean} */ willSupport(contentType) { // Edge 14 does not report correct capabilities. It will only report the // first MIME type even if the others are supported. To work around this, // we say that Edge supports everything. // // See https://github.com/google/shaka-player/issues/1495 for details. if (shaka.util.Platform.isLegacyEdge()) { return true; } contentType = contentType.toLowerCase(); if (shaka.util.Platform.isTizen() && contentType.includes('codecs="ac-3"')) { // Some Tizen devices seem to misreport AC-3 support. This works around // the issue, by falling back to EC-3, which seems to be supported on the // same devices and be correctly reported in all cases we have observed. // See https://github.com/google/shaka-player/issues/2989 for details. const fallback = contentType.replace('ac-3', 'ec-3'); return this.supportedTypes_.has(contentType) || this.supportedTypes_.has(fallback); } return this.supportedTypes_.has(contentType); } /** * 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 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 {shaka.extern.Stream} stream * @param {string=} codecOverride * @return {string} * @private */ static computeMimeType_(stream, codecOverride) { const realMimeType = shaka.util.MimeUtils.getFullType(stream.mimeType, codecOverride || stream.codecs); if (shaka.media.Transmuxer.isSupported(realMimeType)) { // This will be handled by the Transmuxer, so use the MIME type that the // Transmuxer will produce. return shaka.media.Transmuxer.convertTsCodecs(stream.type, realMimeType); } return realMimeType; } /** * @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) { 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 { // Get the set of supported content types from the audio and video // capabilities. Avoid duplicates so that it is easier to read what is // supported. this.supportedTypes_.clear(); // Store the capabilities of the key system. const realConfig = mediaKeySystemAccess.getConfiguration(); shaka.log.v2( 'Got MediaKeySystemAccess with configuration', realConfig); const audioCaps = realConfig.audioCapabilities || []; const videoCaps = realConfig.videoCapabilities || []; for (const cap of audioCaps) { this.supportedTypes_.add(cap.contentType.toLowerCase()); } for (const cap of videoCaps) { this.supportedTypes_.add(cap.contentType.toLowerCase()); } goog.asserts.assert(this.supportedTypes_.size, 'We should get at least one supported MIME type'); if (variants.length) { this.currentDrmInfo_ = this.createDrmInfoByInfos_( mediaKeySystemAccess.keySystem, drmInfosByKeySystem.get(mediaKeySystemAccess.keySystem)); } else { this.currentDrmInfo_ = shaka.media.DrmEngine.createDrmInfoByConfigs_( mediaKeySystemAccess.keySystem, configsByKeySystem.get(mediaKeySystemAccess.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; 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; this.supportedTypes_.clear(); 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 drmInfos = drmInfosByKeySystem.get(decodingInfo.keySystemAccess.keySystem); 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 StringUtils = shaka.util.StringUtils; const Uint8ArrayUtils = shaka.util.Uint8ArrayUtils; const keys = []; const keyIds = []; clearKeys.forEach((keyHex, keyIdHex) => { const keyId = Uint8ArrayUtils.fromHex(keyIdHex); const key = Uint8ArrayUtils.fromHex(keyHex); const keyObj = { kty: 'oct', kid: Uint8ArrayUtils.toBase64(keyId, false), k: Uint8ArrayUtils.toBase64(key, false), }; keys.push(keyObj); keyIds.push(keyObj.kid); }); const jwkSet = {keys: keys}; const license = JSON.stringify(jwkSet); // Use the keyids init data since is suggested by EME. // Suggestion: https://bit.ly/2JYcNTu // Format: https://www.w3.org/TR/eme-initdata-keyids/ const initDataStr = JSON.stringify({'kids': keyIds}); const initData = shaka.util.BufferUtils.toUint8(StringUtils.toUTF8(initDataStr)); const initDatas = [{initData: initData, initDataType: 'keyids'}]; return { keySystem: 'org.w3.clearkey', licenseServerUri: 'data:application/json;base64,' + window.btoa(license), distinctiveIdentifierRequired: false, persistentStateRequired: false, audioRobustness: '', videoRobustness: '', serverCertificate: null, serverCertificateUri: '', sessionType: '', initData: initDatas, keyIds: new Set(keyIds), }; } /** * @param {string} sessionId * @return {!Promise.<MediaKeySession>} * @private */ async loadOfflineSession_(sessionId) { 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: null, 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); this.onError_(new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.DRM, shaka.util.Error.Code.OFFLINE_SESSION_REMOVED)); return Promise.resolve(); } // TODO: We should get a key status change event. Remove once Chrome CDM // is fixed. metadata.loaded = true; if (this.areAllSessionsLoaded_()) { this.allSessionsLoaded_.resolve(); } return session; } catch (error) { this.destroyer_.ensureNotDestroyed(error); this.activeSessions_.delete(session); this.onError_(new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.DRM, shaka.util.Error.Code.FAILED_TO_CREATE_SESSION, error.message)); } 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, 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 {!Uint8Array} initData * @param {string} initDataType * @param {?shaka.extern.DrmInfo} drmInfo * @return {!Uint8Array} */ static defaultInitDataTransform(initData, initDataType, drmInfo) { if (initDataType == 'skd') { const cert = drmInfo.serverCertificate; const contentId = shaka.util.FairPlayUtils.defaultGetContentId(initData); initData = shaka.util.FairPlayUtils.initDataTransform( initData, contentId, cert); } return initData; } /** * @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_); } /** * 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; // NOTE: allowCrossSiteCredentials can be set in a request filter. if (shaka.media.DrmEngine.isPlayReadyKeySystem( this.currentDrmInfo_.keySystem)) { this.unpackPlayReadyRequest_(request); } const startTimeRequest = Date.now(); let response; try { const req = this.playerInterface_.netEngine.request(requestType, request); response = await req.promise; } catch (error) { // 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); this.onError_(shakaErr); if (metadata && metadata.updatePromise) { metadata.updatePromise.reject(shakaErr); } 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; } const updateEvent = new shaka.util.FakeEvent('drmsessionupdate'); this.playerInterface_.onEvent(updateEvent); if (metadata) { if (metadata.updatePromise) { metadata.updatePromise.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. const timer = new shaka.util.Timer(() => { metadata.loaded = true; if (this.areAllSessionsLoaded_()) { this.allSessionsLoaded_.resolve(); } }); timer.tickAfter( /* seconds= */ shaka.media.DrmEngine.SESSION_LOAD_TIMEOUT_); } } /** * 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 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 = new DOMParser().parseFromString(xml, 'application/xml'); // Set request headers. const headers = dom.getElementsByTagName('HttpHeader'); for (const header of headers) { const name = header.getElementsByTagName('name')[0]; const value = header.getElementsByTagName('value')[0]; goog.asserts.assert(name && value, 'Malformed PlayReady headers!'); request.headers[name.textContent] = value.textContent; } // Unpack the base64-encoded challenge. const challenge = dom.getElementsByTagName('Challenge')[0]; goog.asserts.assert(challenge, 'Malformed PlayReady challenge!'); goog.asserts.assert(challenge.getAttribute('encoding') == 'base64encoded', 'Unexpected PlayReady challenge encoding!'); request.body = shaka.util.Uint8ArrayUtils.fromBase64(challenge.textContent); } /** * @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.media.DrmEngine.isPlayReadyKeySystem( this.currentDrmInfo_.keySystem) && keyId.byteLength == 16 && shaka.util.Platform.isEdge()) { // 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); 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.media.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 true if the browser has recent EME APIs. * * @return {boolean} */ static isBrowserSupported() { const basic = !!window.MediaKeys && !!window.navigator && !!window.navigator.requestMediaKeySystemAccess && !!window.MediaKeySystemAccess && // eslint-disable-next-line no-restricted-syntax !!window.MediaKeySystemAccess.prototype.getConfiguration; return basic; } /** * Returns a Promise to a map of EME support for well-known key systems. * * @return {!Promise.<!Object.<string, ?shaka.extern.DrmSupportType>>} */ static async probeSupport() { goog.asserts.assert(shaka.media.DrmEngine.isBrowserSupported(), 'Must have basic EME support'); const testKeySystems = [ 'org.w3.clearkey', 'com.widevine.alpha', 'com.microsoft.playready', 'com.microsoft.playready.recommendation', 'com.apple.fps.3_0', 'com.apple.fps.2_0', 'com.apple.fps.1_0', 'com.apple.fps', 'com.adobe.primetime', ]; const basicVideoCapabilities = [ {contentType: 'video/mp4; codecs="avc1.42E01E"'}, {contentType: 'video/webm; codecs="vp8"'}, ]; const basicConfig = { initDataTypes: ['cenc'], videoCapabilities: basicVideoCapabilities, }; const offlineConfig = { videoCapabilities: basicVideoCapabilities, persistentState: 'required', sessionTypes: ['persistent-license'], }; // Try the offline config first, then fall back to the basic config. const configs = [offlineConfig, basicConfig]; /** @type {!Map.<string, ?shaka.extern.DrmSupportType>} */ const support = new Map(); const testSystem = async (keySystem) => { try { const access = await navigator.requestMediaKeySystemAccess( keySystem, configs); // Edge doesn't return supported session types, but current versions // do not support persistent-license. If sessionTypes is missing, // assume no support for persistent-license. // TODO: Polyfill Edge to return known supported session types. // Edge bug: https://bit.ly/2IeKzho 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; } support.set(keySystem, {persistentState: persistentState}); await access.createMediaKeys(); } catch (e) { // Either the request failed or createMediaKeys failed. // Either way, write null to the support object. support.set(keySystem, null); } }; // Test each key system. const tests = testKeySystems.map((keySystem) => testSystem(keySystem)); 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) { const DrmEngine = shaka.media.DrmEngine; const timeout = new Promise((resolve, reject) => { const timer = new shaka.util.Timer(reject); timer.tickAfter(DrmEngine.CLOSE_TIMEOUT_); }); try { await Promise.race([ Promise.all([session.close(), session.closed]), timeout, ]); } 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.offlineSessionIds_.includes(session.sessionId) && metadata.type === 'persistent-license') { 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); } })); } /** * Check if a variant is likely to be supported by DrmEngine. This will err on * the side of being too accepting and may not reject a variant that it will * later fail to play. * * @param {!shaka.extern.Variant} variant * @return {boolean} */ supportsVariant(variant) { /** @type {?shaka.extern.Stream} */ const audio = variant.audio; /** @type {?shaka.extern.Stream} */ const video = variant.video; if (audio && audio.encrypted) { const audioContentType = shaka.media.DrmEngine.computeMimeType_(audio); if (!this.willSupport(audioContentType)) { return false; } } if (video && video.encrypted) { const videoContentType = shaka.media.DrmEngine.computeMimeType_(video); if (!this.willSupport(videoContentType)) { return false; } } const keySystem = shaka.media.DrmEngine.keySystem(this.currentDrmInfo_); const drmInfos = this.getVariantDrmInfos_(variant); return drmInfos.length == 0 || drmInfos.some((drmInfo) => drmInfo.keySystem == keySystem); } /** * Checks if two DrmInfos can be decrypted using the same key system. * Clear content is considered compatible with every key system. * * @param {!Array.<!shaka.extern.DrmInfo>} drms1 * @param {!Array.<!shaka.extern.DrmInfo>} drms2 * @return {boolean} */ static areDrmCompatible(drms1, drms2) { if (!drms1.length || !drms2.length) { return true; } return shaka.media.DrmEngine.getCommonDrmInfos( drms1, drms2).length > 0; } /** * Returns an array of drm infos that are present in both input arrays. * If one of the arrays is empty, returns the other one since clear * content is considered compatible with every drm info. * * @param {!Array.<!shaka.extern.DrmInfo>} drms1 * @param {!Array.<!shaka.extern.DrmInfo>} drms2 * @return {!Array.<!shaka.extern.DrmInfo>} */ static getCommonDrmInfos(drms1, drms2) { if (!drms1.length) { return drms2; } if (!drms2.length) { return drms1; } const commonDrms = []; for (const drm1 of drms1) { for (const drm2 of drms2) { // This method is only called to compare drmInfos of a video and an // audio adaptations, so we shouldn't have to worry about checking // robustness. if (drm1.keySystem == drm2.keySystem) { /** @type {Array<shaka.extern.InitDataOverride>} */ let initData = []; initData = initData.concat(drm1.initData || []); initData = initData.concat(drm2.initData || []); initData = initData.filter((d, i) => { return d.keyId === undefined || i === initData.findIndex((d2) => { return d2.keyId === d.keyId; }); }); const keyIds = drm1.keyIds && drm2.keyIds ? new Set([...drm1.keyIds, ...drm2.keyIds]) : drm1.keyIds || drm2.keyIds; const mergedDrm = { keySystem: drm1.keySystem, licenseServerUri: drm1.licenseServerUri || drm2.licenseServerUri, distinctiveIdentifierRequired: drm1.distinctiveIdentifierRequired || drm2.distinctiveIdentifierRequired, persistentStateRequired: drm1.persistentStateRequired || drm2.persistentStateRequired, videoRobustness: drm1.videoRobustness || drm2.videoRobustness, audioRobustness: drm1.audioRobustness || drm2.audioRobustness, serverCertificate: drm1.serverCertificate || drm2.serverCertificate, serverCertificateUri: drm1.serverCertificateUri || drm2.serverCertificateUri, initData, keyIds, }; commonDrms.push(mergedDrm); break; } } } return commonDrms; } /** * 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); } /** * 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 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(); shaka.media.DrmEngine.processDrmInfos_( drmInfos, licenseServers, serverCerts, serverCertificateUris, initDatas, keyIds); 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, 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, }; 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 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.media.DrmEngine.processDrmInfos_( config['drmInfos'], licenseServers, serverCerts, serverCertificateUris, initDatas, keyIds); 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, 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.<!Uint8Array>} serverCerts * @param {!Array.<string>} serverCertificateUris * @param {!Array.<!shaka.extern.InitDataOverride>} initDatas * @param {!Set.<string>} keyIds * @private */ static processDrmInfos_( drmInfos, licenseServers, serverCerts, serverCertificateUris, initDatas, keyIds) { /** @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); }; for (const drmInfo of drmInfos) { // Build an array of unique license servers. 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); } } } } /** * 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. * * @param {shaka.extern.DrmInfo} drmInfo * @param {!Map.<string, string>} servers * @param {!Map.<string, shaka.extern.AdvancedDrmConfiguration>} * advancedConfigs * @private */ static fillInDrmInfoDefaults_(drmInfo, servers, advancedConfigs) { if (!drmInfo.keySystem) { // 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 (drmInfo.keySystem == '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(drmInfo.keySystem) || ''; 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(drmInfo.keySystem); 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; } } // 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 (drmInfo.keySystem == 'com.microsoft.playready') { drmInfo.keySystem = 'com.chromecast.playready'; } } }};/** * @typedef {{ * loaded: boolean, * initData: Uint8Array, * 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 {!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.media.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.media.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.media.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.media.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.media.DrmEngine.KEY_STATUS_BATCH_TIME = 0.5;/** * Contains the suggested "default" key ID used by EME polyfills that do not * have a per-key key status. See w3c/encrypted-media#32. * @type {!shaka.util.Lazy.<!ArrayBuffer>} */shaka.media.DrmEngine.DUMMY_KEY_ID = new shaka.util.Lazy( () => shaka.util.BufferUtils.toArrayBuffer(new Uint8Array([0])));