UNPKG

shaka-player

Version:
1,514 lines (1,290 loc) 74.6 kB
/** * @license * Copyright 2016 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 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.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.MapUtils'); goog.require('shaka.util.MimeUtils'); goog.require('shaka.util.Platform'); goog.require('shaka.util.PublicPromise'); goog.require('shaka.util.StringUtils'); goog.require('shaka.util.Timer'); goog.require('shaka.util.Uint8ArrayUtils'); /** * @param {shaka.media.DrmEngine.PlayerInterface} playerInterface * @param {number=} updateExpirationTime * * @constructor * @struct * @implements {shaka.util.IDestroyable} */ shaka.media.DrmEngine = function(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 {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_()); /** * A flag to signal when have started destroying ourselves. This will: * 1. Stop later calls to |destroy| from trying to destroy the already * destroyed (or currently destroying) DrmEngine. * 2. Stop in-progress async operations from continuing. * * @private {boolean} */ this.isDestroying_ = false; /** * A promise that will only resolve once we have finished destroying * ourselves, this is used to ensure that subsequent calls to |destroy| don't * resolve before the first call to |destroy|. * * @private {!shaka.util.PublicPromise} */ this.finishedDestroyingPromise_ = new shaka.util.PublicPromise(); /** @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); }; /** * @typedef {{ * loaded: boolean, * initData: Uint8Array, * oldExpiration: number, * 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 {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; /** @override */ shaka.media.DrmEngine.prototype.destroy = async function() { // If we have started destroying ourselves, wait for the common "I am finished // being destroyed" promise to be resolved. if (this.isDestroying_) { await this.finishedDestroyingPromise_; } else { this.isDestroying_ = true; await this.destroyNow_(); this.finishedDestroyingPromise_.resolve(); } }; /** * Destroy this instance of DrmEngine. This assumes that all other checks about * "if it should" have passed. * * @private */ shaka.media.DrmEngine.prototype.destroyNow_ = async function() { // |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_ = null; this.playerInterface_ = 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 */ shaka.media.DrmEngine.prototype.configure = function(config) { this.config_ = config; }; /** * 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} */ shaka.media.DrmEngine.prototype.initForStorage = function( variants, usePersistentLicenses) { // 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} */ shaka.media.DrmEngine.prototype.initForPlayback = function( 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} */ shaka.media.DrmEngine.prototype.initForRemoval = function( keySystem, licenseServerUri, serverCertificate, audioCapabilities, videoCapabilities) { /** @type {!Map.<string, MediaKeySystemConfiguration>} */ const configsByKeySystem = new Map(); configsByKeySystem.set(keySystem, { audioCapabilities: audioCapabilities, videoCapabilities: videoCapabilities, distinctiveIdentifier: 'optional', persistentState: 'required', sessionTypes: ['persistent-license'], label: keySystem, drmInfos: [{ keySystem: keySystem, licenseServerUri: licenseServerUri, distinctiveIdentifierRequired: false, persistentStateRequired: true, audioRobustness: '', // Not required by queryMediaKeys_ videoRobustness: '', // Same serverCertificate: serverCertificate, initData: null, keyIds: null, }], // Tracked by us, ignored by EME. }); return this.queryMediaKeys_(configsByKeySystem); }; /** * 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 */ shaka.media.DrmEngine.prototype.init_ = function(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) { variant.drmInfos = [clearKeyDrmInfo]; } } const hadDrmInfo = variants.some((v) => v.drmInfos.length > 0); // 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) { for (const info of variant.drmInfos) { shaka.media.DrmEngine.fillInDrmInfoDefaults_( info, shaka.util.MapUtils.asMap(this.config_.servers), shaka.util.MapUtils.asMap(this.config_.advanced || {})); } } /** @type {!Map.<string, MediaKeySystemConfiguration>} */ const configsByKeySystem = this.prepareMediaKeyConfigsForVariants_(variants); // TODO(vaage): Find an explanation for the difference between this // "unencrypted" form and the "no drm info unencrypted form" and express // that difference here. if (!configsByKeySystem.size) { // Unencrypted. this.initialized_ = true; return Promise.resolve(); } const p = this.queryMediaKeys_(configsByKeySystem); // 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 no // 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} */ shaka.media.DrmEngine.prototype.attach = function(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 (IE 11 & 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 Promise.resolve(); } 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(function(exception) { 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)); }); let setServerCertificate = this.setServerCertificate(); return Promise.all([setMediaKeys, setServerCertificate]).then(() => { if (this.isDestroying_) { return Promise.reject(); } 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, new Uint8Array(e.initData)); this.eventManager_.listen(this.video_, 'encrypted', cb); } }).catch((error) => { if (this.isDestroying_) { return; } return Promise.reject(error); }); }; /** * Sets the server certificate based on the current DrmInfo. * * @return {!Promise} */ shaka.media.DrmEngine.prototype.setServerCertificate = async function() { goog.asserts.assert(this.initialized_, 'Must call init() before setServerCertificate'); if (this.mediaKeys_ && this.currentDrmInfo_ && this.currentDrmInfo_.serverCertificate && this.currentDrmInfo_.serverCertificate.length) { 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) { return Promise.reject(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} */ shaka.media.DrmEngine.prototype.removeSession = async function(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); }; /** * Creates the sessions for the init data and waits for them to become ready. * * @return {!Promise} */ shaka.media.DrmEngine.prototype.createOrLoad = function() { // Create temp sessions. let initDatas = this.currentDrmInfo_ ? this.currentDrmInfo_.initData : []; initDatas.forEach((initDataOverride) => { return this.newInitData(initDataOverride.initDataType, initDataOverride.initData); }); // Load each session. this.offlineSessionIds_.forEach((sessionId) => { return 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 */ shaka.media.DrmEngine.prototype.newInitData = function(initDataType, initData) { // Aliases: const Uint8ArrayUtils = shaka.util.Uint8ArrayUtils; // 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 (Uint8ArrayUtils.equal(initData, metadata.initData) && !shaka.util.Platform.isTizen2()) { shaka.log.debug('Ignoring duplicate init data.'); return; } } this.createTemporarySession_(initDataType, initData); }; /** @return {boolean} */ shaka.media.DrmEngine.prototype.initialized = function() { return this.initialized_; }; /** * @param {?shaka.extern.DrmInfo} drmInfo * @return {string} */ shaka.media.DrmEngine.keySystem = function(drmInfo) { return drmInfo ? drmInfo.keySystem : ''; }; /** * Check if DrmEngine (as initialized) will likely be able to support the given * content type. * * @param {string} contentType * @return {boolean} */ shaka.media.DrmEngine.prototype.willSupport = function(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>} */ shaka.media.DrmEngine.prototype.getSessionIds = function() { 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} */ shaka.media.DrmEngine.prototype.getExpiration = function() { // 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} */ shaka.media.DrmEngine.prototype.getLicenseTime = function() { if (this.licenseTimeSeconds_) { return this.licenseTimeSeconds_; } return NaN; }; /** * Returns the DrmInfo that was used to initialize the current key system. * * @return {?shaka.extern.DrmInfo} */ shaka.media.DrmEngine.prototype.getDrmInfo = function() { return this.currentDrmInfo_; }; /** * Returns the current key statuses. * * @return {!Object.<string, string>} */ shaka.media.DrmEngine.prototype.getKeyStatuses = function() { return shaka.util.MapUtils.asObject(this.announcedKeyStatusByKeyId_); }; /** * @param {!Array.<shaka.extern.Variant>} variants * @see https://bit.ly/EmeConfig for MediaKeySystemConfiguration spec * @return {!Map.<string, MediaKeySystemConfiguration>} * @private */ shaka.media.DrmEngine.prototype.prepareMediaKeyConfigsForVariants_ = function( variants) { // Get all the drm info so that we can avoid using nested loops when we just // need the drm info. const allDrmInfo = new Set(); for (const variant of variants) { for (const info of variant.drmInfos) { allDrmInfo.add(info); } } // Make sure all the drm infos are valid and filled in correctly. for (const info of allDrmInfo) { shaka.media.DrmEngine.fillInDrmInfoDefaults_( info, shaka.util.MapUtils.asMap(this.config_.servers), shaka.util.MapUtils.asMap(this.config_.advanced || {})); } const persistentState = this.usePersistentLicenses_ ? 'required' : 'optional'; const sessionTypes = this.usePersistentLicenses_ ? ['persistent-license'] : ['temporary']; const configs = new Map(); // Create a config entry for each key system. for (const info of allDrmInfo) { const config = { // Ignore initDataTypes. audioCapabilities: [], videoCapabilities: [], distinctiveIdentifier: 'optional', persistentState: persistentState, sessionTypes: sessionTypes, label: info.keySystem, drmInfos: [], // Tracked by us, ignored by EME. }; // Multiple calls to |set| will still respect the insertion order of the // first call to |set| for a given key. configs.set(info.keySystem, config); } // Connect each key system with each stream using it. for (const variant of variants) { /** @type {?shaka.extern.Stream} */ const audio = variant.audio; /** @type {?shaka.extern.Stream} */ const video = variant.video; /** @type {string} */ const audioMimeType = audio ? shaka.media.DrmEngine.computeMimeType_(audio) : ''; /** @type {string} */ const videoMimeType = video ? shaka.media.DrmEngine.computeMimeType_(video) : ''; // Add the last bit of information to each config; for (const info of variant.drmInfos) { const config = configs.get(info.keySystem); goog.asserts.assert( config, 'Any missing configs should have be filled in before.'); config.drmInfos.push(info); if (info.distinctiveIdentifierRequired) { config.distinctiveIdentifier = 'required'; } if (info.persistentStateRequired) { config.persistentState = 'required'; } if (audio) { /** @type {MediaKeySystemMediaCapability} */ const capability = { robustness: info.audioRobustness || '', contentType: audioMimeType, }; config.audioCapabilities.push(capability); if (audio.codecs.toLowerCase() == 'ac-3' && shaka.util.Platform.isTizen()) { // Some Tizen devices seem to misreport AC-3 support, but correctly // report EC-3 support. So query EC-3 as a fallback for AC-3. // See https://github.com/google/shaka-player/issues/2989 for details. const fallbackMimeType = shaka.util.MimeUtils.getFullType( audio.mimeType, 'ec-3'); /** @type {MediaKeySystemMediaCapability} */ const fallbackCapability = { robustness: info.audioRobustness || '', contentType: fallbackMimeType, }; config.audioCapabilities.push(fallbackCapability); } } if (video) { /** @type {MediaKeySystemMediaCapability} */ const capability = { robustness: info.videoRobustness || '', contentType: videoMimeType, }; config.videoCapabilities.push(capability); } } } return configs; }; /** * @param {shaka.extern.Stream} stream * @param {string=} codecOverride * @return {string} * @private */ shaka.media.DrmEngine.computeMimeType_ = function(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. * @return {!Promise} Resolved if/when a key system has been chosen. * @private */ shaka.media.DrmEngine.prototype.queryMediaKeys_ = function(configsByKeySystem) { if (configsByKeySystem.size == 1 && configsByKeySystem.has('')) { return Promise.reject(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; } } // Wait to reject this initial Promise until we have built the entire chain. let instigator = new shaka.util.PublicPromise(); let p = instigator; // 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. [true, false].forEach(function(shouldHaveLicenseServer) { configsByKeySystem.forEach((config, keySystem) => { let hasLicenseServer = config.drmInfos.some(function(info) { return !!info.licenseServerUri; }); if (hasLicenseServer != shouldHaveLicenseServer) return; p = p.catch(function() { if (this.isDestroying_) { return; } return navigator.requestMediaKeySystemAccess(keySystem, [config]); }.bind(this)); }); }.bind(this)); p = p.catch(() => { return Promise.reject(new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.DRM, shaka.util.Error.Code.REQUESTED_KEY_SYSTEM_CONFIG_UNAVAILABLE)); }); p = p.then(function(mediaKeySystemAccess) { if (this.isDestroying_) { return Promise.reject(); } // 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(); 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'); this.currentDrmInfo_ = shaka.media.DrmEngine.createDrmInfoFor_( mediaKeySystemAccess.keySystem, configsByKeySystem.get(mediaKeySystemAccess.keySystem)); if (!this.currentDrmInfo_.licenseServerUri) { return Promise.reject(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)); } return mediaKeySystemAccess.createMediaKeys(); }.bind(this)).then(function(mediaKeys) { if (this.isDestroying_) { return Promise.reject(); } shaka.log.info('Created MediaKeys object for key system', this.currentDrmInfo_.keySystem); this.mediaKeys_ = mediaKeys; this.initialized_ = true; }.bind(this)).catch(function(exception) { if (this.isDestroying_) { return; } // Don't rewrap a shaka.util.Error from earlier in the chain: this.currentDrmInfo_ = null; this.supportedTypes_.clear(); if (exception instanceof shaka.util.Error) { return Promise.reject(exception); } // We failed to create MediaKeys. This generally shouldn't happen. return Promise.reject(new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.DRM, shaka.util.Error.Code.FAILED_TO_CREATE_CDM, exception.message)); }.bind(this)); instigator.reject(); return p; }; /** * 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. */ shaka.media.DrmEngine.prototype.configureClearKey_ = function() { 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; let keys = []; let keyIds = []; clearKeys.forEach((keyHex, keyIdHex) => { let keyId = Uint8ArrayUtils.fromHex(keyIdHex); let key = Uint8ArrayUtils.fromHex(keyHex); let keyObj = { kty: 'oct', kid: Uint8ArrayUtils.toBase64(keyId, false), k: Uint8ArrayUtils.toBase64(key, false), }; keys.push(keyObj); keyIds.push(keyObj.kid); }); let jwkSet = {keys: keys}; let 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/ let initDataStr = JSON.stringify({'kids': keyIds}); let initData = new Uint8Array(StringUtils.toUTF8(initDataStr)); let 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, initData: initDatas, keyIds: [], }; }; /** * @param {string} sessionId * @return {!Promise.<MediaKeySession>} * @private */ shaka.media.DrmEngine.prototype.loadOfflineSession_ = function(sessionId) { let session; try { shaka.log.v1('Attempting to load an offline session', sessionId); session = this.mediaKeys_.createSession('persistent-license'); } catch (exception) { let 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} */( this.onSessionMessage_.bind(this))); this.eventManager_.listen(session, 'keystatuseschange', this.onKeyStatusesChange_.bind(this)); const metadata = { initData: null, loaded: false, oldExpiration: Infinity, updatePromise: null, }; this.activeSessions_.set(session, metadata); return session.load(sessionId).then(function(present) { if (this.isDestroying_) { return Promise.reject(); } 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; } // 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; }.bind(this), function(error) { if (this.isDestroying_) { return; } 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)); }.bind(this)); }; /** * @param {string} initDataType * @param {!Uint8Array} initData * @private */ shaka.media.DrmEngine.prototype.createTemporarySession_ = function(initDataType, initData) { let session; try { if (this.usePersistentLicenses_) { shaka.log.v1('Creating new persistent session'); session = this.mediaKeys_.createSession('persistent-license'); } else { shaka.log.v1('Creating new temporary session'); session = this.mediaKeys_.createSession(); } } 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} */( this.onSessionMessage_.bind(this))); this.eventManager_.listen(session, 'keystatuseschange', this.onKeyStatusesChange_.bind(this)); const metadata = { initData: initData, loaded: false, oldExpiration: Infinity, updatePromise: null, }; this.activeSessions_.set(session, metadata); try { initData = this.config_.initDataTransform(initData, 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; } session.generateRequest(initDataType, initData).catch((error) => { if (this.isDestroying_) { return; } this.activeSessions_.delete(session); let extended; if (error.errorCode && error.errorCode.systemCode) { extended = error.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 {?shaka.extern.DrmInfo} drmInfo * @return {!Uint8Array} */ shaka.media.DrmEngine.defaultInitDataTransform = function(initData, drmInfo) { if (shaka.media.DrmEngine.keySystem(drmInfo).startsWith('com.apple.fps')) { 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 */ shaka.media.DrmEngine.prototype.onSessionMessage_ = function(event) { if (this.delayLicenseRequest_()) { this.mediaKeyMessageEvents_.push(event); } else { this.sendLicenseRequest_(event); } }; /** * @return {boolean} * @private */ shaka.media.DrmEngine.prototype.delayLicenseRequest_ = function() { 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 */ shaka.media.DrmEngine.prototype.sendLicenseRequest_ = function(event) { /** @type {!MediaKeySession} */ const session = event.target; shaka.log.v1( 'Sending license request for session', session.sessionId, 'of type', event.messageType); 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; let 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 (this.currentDrmInfo_.keySystem == 'com.microsoft.playready' || this.currentDrmInfo_.keySystem == 'com.chromecast.playready') { this.unpackPlayReadyRequest_(request); } if (this.currentDrmInfo_.keySystem.startsWith('com.apple.fps') && this.config_.fairPlayTransform) { this.formatFairPlayRequest_(request); } const startTimeRequest = Date.now(); this.playerInterface_.netEngine.request(requestType, request).promise .then(function(response) { if (this.isDestroying_) { return Promise.reject(); } if (this.currentDrmInfo_.keySystem.startsWith('com.apple.fps') && this.config_.fairPlayTransform) { this.parseFairPlayResponse_(response); } this.licenseTimeSeconds_ += (Date.now() - startTimeRequest) / 1000; // Request succeeded, now pass the response to the CDM. shaka.log.v1('Updating session', session.sessionId); return session.update(response.data).then(function() { let event = new shaka.util.FakeEvent('drmsessionupdate'); this.playerInterface_.onEvent(event); 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_); } }.bind(this)); }.bind(this), function(error) { // Ignore destruction errors if (this.isDestroying_) { return; } // Request failed! goog.asserts.assert(error instanceof shaka.util.Error, 'Wrong NetworkingEngine error type!'); let 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); } }.bind(this)).catch(function(error) { // Ignore destruction errors if (this.isDestroying_) { return; } // Session update failed! let 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); } }.bind(this)); }; /** * Unpacks PlayReady license requests. Modifies the request object. * @param {shaka.extern.Request} request * @private */ shaka.media.DrmEngine.prototype.unpackPlayReadyRequest_ = function(request) { // On IE and 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> let xml = shaka.util.StringUtils.fromUTF16( request.body, true /* littleEndian */, true /* noThrow */); if (!xml.includes('PlayReadyKeyMessage')) { // This does not appear to be a wrapped message as on IE and 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.'); let dom = new DOMParser().parseFromString(xml, 'application/xml'); // Set request headers. let headers = dom.getElementsByTagName('HttpHeader'); for (let i = 0; i < headers.length; ++i) { let name = headers[i].getElementsByTagName('name')[0]; let value = headers[i].getElementsByTagName('value')[0]; goog.asserts.assert(name && value, 'Malformed PlayReady headers!'); request.headers[name.textContent] = value.textContent; } // Unpack the base64-encoded challenge. let 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).buffer; }; /** * Formats FairPlay license requests. Modifies the request object. * * @param {shaka.extern.Request} request * @private */ shaka.media.DrmEngine.prototype.formatFairPlayRequest_ = function(request) { // The standard format for FairPlay seems to be to place the request into a // POST parameter (spc=). const originalPayload = new Uint8Array(request.body); const base64Payload = shaka.util.Uint8ArrayUtils.toBase64(originalPayload); const params = 'spc=' + base64Payload; request.headers['Content-Type'] = 'application/x-www-form-urlencoded'; request.body = shaka.util.StringUtils.toUTF8(params); }; /** * Parse FairPlay license response format. Modifies the response object. * This will run after any response filters, so application-specific formats * can still be handled by the app. * * @param {shaka.extern.Response} response * @private */ shaka.media.DrmEngine.prototype.parseFairPlayResponse_ = function(response) { // In Apple's docs, responses can be of the form: // '\n<ckc>base64encoded</ckc>\n' or 'base64encoded' // We have also seen responses in JSON format from some of our partners. // In all of these text-based formats, the CKC data is base64-encoded. // This handles all of the above. Other formats should be handled via // application-level response filters. let responseText; try { // Convert it to text for further processing. responseText = shaka.util.StringUtils.fromUTF8(response.data); } catch (error) { // Assume it's not a text format of any kind and leave it alone. return; } // Trim whitespace. responseText = responseText.trim(); // Look for <ckc> wrapper and remove it. if (responseText.substr(0, 5) === '<ckc>' && responseText.substr(-6) === '</ckc>') { responseText = responseText.slice(5, -6); } // Look for a JSON wrapper and remove it. try { const responseObject = JSON.parse(responseText); responseText = responseObject['ckc']; } catch (error) { // It wasn't JSON. Fall through with other transformations. } // Decode the base64-encoded data into the format the browser expects. // It's not clear why FairPlay license servers don't just serve this directly. response.data = shaka.util.Uint8ArrayUtils.fromBase64(responseText).buffer; }; /** * @param {!Event} event * @private * @suppress {invalidCasts} to swap keyId and status */ shaka.media.DrmEngine.prototype.onKeyStatusesChange_ = function(event) { const session = /** @type {!MediaKeySession} */(event.target); shaka.log.v2('Key status changed for session', session.sessionId); const found = this.activeSessions_.get(session); let keyStatusMap = session.keyStatuses; let hasExpiredKeys = false; keyStatusMap.forEach(function(status, keyId) { // Th