shaka-player
Version:
DASH/EME video player library
1,514 lines (1,290 loc) • 74.6 kB
JavaScript
/**
* @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