UNPKG

rx-player

Version:
925 lines (924 loc) 44.6 kB
/** * Copyright 2015 CANAL+ Group * * 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. */ import eme, { getInitData } from "../../compat/eme"; import config from "../../config"; import { EncryptedMediaError, OtherError } from "../../errors"; import log from "../../log"; import areArraysOfNumbersEqual from "../../utils/are_arrays_of_numbers_equal"; import arrayFind from "../../utils/array_find"; import arrayIncludes from "../../utils/array_includes"; import EventEmitter from "../../utils/event_emitter"; import isNullOrUndefined from "../../utils/is_null_or_undefined"; import { objectValues } from "../../utils/object_values"; import { bytesToHex } from "../../utils/string_parsing"; import TaskCanceller from "../../utils/task_canceller"; import createOrLoadSession from "./create_or_load_session"; import initMediaKeys from "./init_media_keys"; import SessionEventsListener, { BlacklistedSessionError, } from "./session_events_listener"; import setServerCertificate from "./set_server_certificate"; import { ContentDecryptorState } from "./types"; import { DecommissionedSessionError } from "./utils/check_key_statuses"; import cleanOldStoredPersistentInfo from "./utils/clean_old_stored_persistent_info"; import getDrmSystemId from "./utils/get_drm_system_id"; import InitDataValuesContainer from "./utils/init_data_values_container"; import isCompatibleCodecSupported from "./utils/is_compatible_codec_supported"; import { areAllKeyIdsContainedIn, areSomeKeyIdsContainedIn, } from "./utils/key_id_comparison"; import MediaKeysAttacher from "./utils/media_keys_attacher"; /** * Module communicating with the Content Decryption Module (or CDM) to be able * to decrypt contents. * * The `ContentDecryptor` starts communicating with the CDM, to initialize the * key system, as soon as it is created. * * You can be notified of various events, such as fatal errors, by registering * to one of its multiple events (@see IContentDecryptorEvent). * * @class ContentDecryptor */ export default class ContentDecryptor extends EventEmitter { /** * `true` if the EME API are available on the current platform according to * the default EME implementation used. * `false` otherwise. * @returns {boolean} */ static hasEmeApis() { return !isNullOrUndefined(eme.requestMediaKeySystemAccess); } /** * Create a new `ContentDecryptor`, and initialize its decryption capabilities * right away. * Goes into the `WaitingForAttachment` state once that initialization is * done, after which you should call the `attach` method when you're ready for * those decryption capabilities to be attached to the HTMLMediaElement. * * @param {HTMLMediaElement} mediaElement - The MediaElement which will be * associated to a MediaKeys object * @param {Array.<Object>} ksOptions - key system configuration. * The `ContentDecryptor` can be given one or multiple key system * configurations. It will choose the appropriate one depending on user * settings and browser support. */ constructor(mediaElement, ksOptions) { super(); log.debug("DRM: Starting ContentDecryptor logic."); const canceller = new TaskCanceller(); this._currentSessions = []; this._canceller = canceller; this._initDataQueue = []; this._stateData = { state: ContentDecryptorState.Initializing, isMediaKeysAttached: 0 /* MediaKeyAttachmentStatus.NotAttached */, isInitDataQueueLocked: true, data: null, }; this._supportedCodecWhenEncrypted = []; this.error = null; eme.onEncrypted(mediaElement, (evt) => { log.debug("DRM: Encrypted event received from media element."); const initData = getInitData(evt); if (initData !== null) { this.onInitializationData(initData); } }, canceller.signal); initMediaKeys(mediaElement, ksOptions, canceller.signal) .then((mediaKeysInfo) => { const { options, mediaKeySystemAccess } = mediaKeysInfo; this._supportedCodecWhenEncrypted = mediaKeysInfo.codecSupport; /** * String identifying the key system, allowing the rest of the code to * only advertise the required initialization data for license requests. * * Note that we only set this value if retro-compatibility to older * persistent logic in the RxPlayer is not important, as the * optimizations this property unlocks can break the loading of * MediaKeySessions persisted in older RxPlayer's versions. */ let systemId; if (isNullOrUndefined(options.persistentLicenseConfig) || options.persistentLicenseConfig.disableRetroCompatibility === true) { systemId = getDrmSystemId(mediaKeySystemAccess.keySystem); } this.systemId = systemId; if (this._stateData.state === ContentDecryptorState.Initializing) { log.debug("DRM: Waiting for attachment."); this._stateData = { state: ContentDecryptorState.WaitingForAttachment, isInitDataQueueLocked: true, isMediaKeysAttached: 0 /* MediaKeyAttachmentStatus.NotAttached */, data: { mediaKeysInfo, mediaElement }, }; this.trigger("stateChange", this._stateData.state); } }) .catch((err) => { this._onFatalError(err); }); } /** * Returns the current state of the ContentDecryptor. * @see ContentDecryptorState * @returns {Object} */ getState() { return this._stateData.state; } /** * Attach the current decryption capabilities to the HTMLMediaElement. * This method should only be called once the `ContentDecryptor` is in the * `WaitingForAttachment` state. * * You might want to first set the HTMLMediaElement's `src` attribute before * calling this method, and only push data to it once the `ReadyForContent` * state is reached, for compatibility reasons. */ attach() { if (this._stateData.state !== ContentDecryptorState.WaitingForAttachment) { throw new Error("`attach` should only be called when " + "in the WaitingForAttachment state"); } else if (this._stateData.isMediaKeysAttached !== 0 /* MediaKeyAttachmentStatus.NotAttached */) { log.warn("DRM: ContentDecryptor's `attach` method called more than once."); return; } const { mediaElement, mediaKeysInfo } = this._stateData.data; const { options, mediaKeys, mediaKeySystemAccess, stores, askedConfiguration } = mediaKeysInfo; const shouldDisableLock = options.disableMediaKeysAttachmentLock === true; if (shouldDisableLock) { log.debug("DRM: disabling MediaKeys attachment lock. Ready for content"); this._stateData = { state: ContentDecryptorState.ReadyForContent, isInitDataQueueLocked: true, isMediaKeysAttached: 1 /* MediaKeyAttachmentStatus.Pending */, data: { mediaKeysInfo, mediaElement }, }; this.trigger("stateChange", this._stateData.state); // previous trigger might have lead to disposal if (this._isStopped()) { return; } } this._stateData.isMediaKeysAttached = 1 /* MediaKeyAttachmentStatus.Pending */; const stateToAttach = { emeImplementation: eme, loadedSessionsStore: stores.loadedSessionsStore, mediaKeySystemAccess, mediaKeys, askedConfiguration, keySystemOptions: options, }; log.debug("DRM: Attaching current MediaKeys"); MediaKeysAttacher.attach(mediaElement, stateToAttach) .then(async () => { if (this._isStopped()) { // We might be stopped since then return; } this._stateData.isMediaKeysAttached = 2 /* MediaKeyAttachmentStatus.Attached */; const { serverCertificate } = options; if (!isNullOrUndefined(serverCertificate)) { const resSsc = await setServerCertificate(mediaKeys, serverCertificate); if (resSsc.type === "error") { this.trigger("warning", resSsc.value); } } if (this._isStopped()) { // We might be stopped since then return; } const prevState = this._stateData.state; this._stateData = { state: ContentDecryptorState.ReadyForContent, isMediaKeysAttached: 2 /* MediaKeyAttachmentStatus.Attached */, isInitDataQueueLocked: false, data: { mediaKeysData: mediaKeysInfo }, }; if (prevState !== ContentDecryptorState.ReadyForContent) { this.trigger("stateChange", ContentDecryptorState.ReadyForContent); } if (!this._isStopped()) { this._processCurrentInitDataQueue(); } }) .catch((err) => { this._onFatalError(err); }); } /** * Stop this `ContentDecryptor` instance: * - stop listening and reacting to the various event listeners * - abort all operations. * * Once disposed, a `ContentDecryptor` cannot be used anymore. */ dispose() { this.removeEventListener(); this._stateData = { state: ContentDecryptorState.Disposed, isMediaKeysAttached: undefined, isInitDataQueueLocked: undefined, data: null, }; this._canceller.cancel(); this.trigger("stateChange", this._stateData.state); } /** * Returns `true` if the given mimeType and codec couple should be supported * by the current key system. * Returns `false` if it isn't. * * Returns `undefined` if we cannot determine if it is supported. * * @param {string} mimeType * @param {string} codec * @returns {boolean} */ isCodecSupported(mimeType, codec) { if (this._stateData.state === ContentDecryptorState.Initializing) { log.error("DRM: Asking for codec support while the ContentDecryptor is still initializing"); return undefined; } if (this._stateData.state === ContentDecryptorState.Error || this._stateData.state === ContentDecryptorState.Disposed) { log.error("DRM: Asking for codec support while the ContentDecryptor is disposed"); } return isCompatibleCodecSupported(mimeType, codec, this._supportedCodecWhenEncrypted); } /** * Method to call when new protection initialization data is encounted on the * content. * * When called, the `ContentDecryptor` will try to obtain the decryption key * if not already obtained. * * @param {Object} initializationData */ onInitializationData(initializationData) { if (this._stateData.isInitDataQueueLocked !== false) { if (this._isStopped()) { throw new Error("ContentDecryptor either disposed or stopped."); } this._initDataQueue.push(initializationData); return; } const { mediaKeysData } = this._stateData.data; const processedInitializationData = Object.assign(Object.assign({}, initializationData), { values: new InitDataValuesContainer(initializationData.values) }); this._processInitializationData(processedInitializationData, mediaKeysData).catch((err) => { this._onFatalError(err); }); } /** * Async logic run each time new initialization data has to be processed. * The promise return may reject, in which case a fatal error should be linked * the current `ContentDecryptor`. * * The Promise's resolution however provides no semantic value. * @param {Object} initializationData * @returns {Promise.<void>} */ async _processInitializationData(initializationData, mediaKeysData) { var _a, _b, _c; if (log.hasLevel("DEBUG")) { log.debug("DRM: processing init data", (_a = initializationData.content) === null || _a === void 0 ? void 0 : _a.adaptation.type, (_b = initializationData.content) === null || _b === void 0 ? void 0 : _b.representation.bitrate, ((_c = initializationData.keyIds) !== null && _c !== void 0 ? _c : []).map((k) => bytesToHex(k)).join(", ")); } const { mediaKeySystemAccess, stores, options } = mediaKeysData; if (this._tryToUseAlreadyCreatedSession(initializationData, mediaKeysData) || this._isStopped()) { // _isStopped is voluntarly checked after here return; } if (options.singleLicensePer === "content") { const firstCreatedSession = arrayFind(this._currentSessions, (x) => x.source === "created-session" /* MediaKeySessionLoadingType.Created */); if (firstCreatedSession !== undefined) { // We already fetched a `singleLicensePer: "content"` license, yet we // could not use the already-created MediaKeySession with it. // It means that we'll never handle it and we should thus blacklist it. const keyIds = initializationData.keyIds; if (keyIds === undefined) { if (initializationData.content === undefined) { log.warn("DRM: Unable to fallback from a non-decipherable quality."); } else { log.debug("DRM: Blacklisting new init data (due to singleLicensePer content policy)"); this.trigger("blackListProtectionData", initializationData); } return; } firstCreatedSession.record.associateKeyIds(keyIds); if (initializationData.content === undefined) { log.warn("DRM: Unable to fallback from a non-decipherable quality."); } else { if (log.hasLevel("DEBUG")) { const hexKids = keyIds.reduce((acc, kid) => `${acc}, ${bytesToHex(kid)}`, ""); log.debug("DRM: Blacklisting new key ids", hexKids); } this.trigger("keyIdsCompatibilityUpdate", { whitelistedKeyIds: [], blacklistedKeyIds: keyIds, delistedKeyIds: [], }); } return; } } else if (options.singleLicensePer === "periods" && initializationData.content !== undefined) { const { period } = initializationData.content; const createdSessions = this._currentSessions.filter((x) => x.source === "created-session" /* MediaKeySessionLoadingType.Created */); const periodKeys = new Set(); addKeyIdsFromPeriod(periodKeys, period); for (const createdSess of createdSessions) { const periodKeysArr = Array.from(periodKeys); for (const kid of periodKeysArr) { if (createdSess.record.isAssociatedWithKeyId(kid)) { createdSess.record.associateKeyIds(periodKeys.values()); // Re-loop through the Period's key ids to blacklist ones that are missing // from `createdSess`'s `keyStatuses` and to update the content's // decipherability. for (const innerKid of periodKeysArr) { if (!createdSess.keyStatuses.whitelisted.some((k) => areArraysOfNumbersEqual(k, innerKid)) && !createdSess.keyStatuses.blacklisted.some((k) => areArraysOfNumbersEqual(k, innerKid))) { createdSess.keyStatuses.blacklisted.push(innerKid); } } if (log.hasLevel("DEBUG")) { log.debug("DRM: Session already created for", bytesToHex(kid), 'under singleLicensePer "periods" policy'); } this.trigger("keyIdsCompatibilityUpdate", { whitelistedKeyIds: createdSess.keyStatuses.whitelisted, blacklistedKeyIds: createdSess.keyStatuses.blacklisted, delistedKeyIds: [], }); return; } } } } // /!\ Do not forget to unlock when done // TODO this is error-prone and can lead to performance issue when loading // persistent sessions. // Can we find a better strategy? this._lockInitDataQueue(); let wantedSessionType; if (canCreatePersistentSession(mediaKeySystemAccess) && (!isNullOrUndefined(options.persistentLicenseConfig) || !canCreateTemporarySession(mediaKeySystemAccess))) { wantedSessionType = "persistent-license"; } else { wantedSessionType = "temporary"; } const { EME_DEFAULT_MAX_SIMULTANEOUS_MEDIA_KEY_SESSIONS, EME_MAX_STORED_PERSISTENT_SESSION_INFORMATION, } = config.getCurrent(); const maxSessionCacheSize = typeof options.maxSessionCacheSize === "number" ? options.maxSessionCacheSize : EME_DEFAULT_MAX_SIMULTANEOUS_MEDIA_KEY_SESSIONS; const sessionRes = await createOrLoadSession(initializationData, stores, wantedSessionType, maxSessionCacheSize, this._canceller.signal); if (this._isStopped()) { return; } const sessionInfo = { record: sessionRes.value.keySessionRecord, source: sessionRes.type, keyStatuses: { whitelisted: [], blacklisted: [] }, blacklistedSessionError: null, }; this._currentSessions.push(sessionInfo); const { mediaKeySession, sessionType } = sessionRes.value; /** * We only store persistent sessions once its keys are known. * This boolean allows to know if this session has already been * persisted or not. */ let isSessionPersisted = false; SessionEventsListener(mediaKeySession, options, mediaKeySystemAccess.keySystem, { onKeyUpdate: (value) => { const linkedKeys = getKeyIdsLinkedToSession(initializationData, sessionInfo.record, options.singleLicensePer, sessionInfo.source === "created-session" /* MediaKeySessionLoadingType.Created */, value.whitelistedKeyIds, value.blacklistedKeyIds); sessionInfo.record.associateKeyIds(linkedKeys.whitelisted); sessionInfo.record.associateKeyIds(linkedKeys.blacklisted); sessionInfo.keyStatuses = { whitelisted: linkedKeys.whitelisted, blacklisted: linkedKeys.blacklisted, }; if (sessionInfo.record.getAssociatedKeyIds().length !== 0 && sessionType === "persistent-license" && stores.persistentSessionsStore !== null && !isSessionPersisted) { const { persistentSessionsStore } = stores; cleanOldStoredPersistentInfo(persistentSessionsStore, EME_MAX_STORED_PERSISTENT_SESSION_INFORMATION - 1); persistentSessionsStore.add(initializationData, sessionInfo.record.getAssociatedKeyIds(), mediaKeySession); isSessionPersisted = true; } if (initializationData.content !== undefined) { this.trigger("keyIdsCompatibilityUpdate", { whitelistedKeyIds: linkedKeys.whitelisted, blacklistedKeyIds: linkedKeys.blacklisted, delistedKeyIds: [], }); } this._unlockInitDataQueue(); }, onWarning: (value) => { this.trigger("warning", value); }, onError: (err) => { var _a; if (err instanceof DecommissionedSessionError) { log.warn("DRM: A session's closing condition has been triggered"); this._lockInitDataQueue(); const indexOf = this._currentSessions.indexOf(sessionInfo); if (indexOf >= 0) { this._currentSessions.splice(indexOf); } if (initializationData.content !== undefined) { this.trigger("keyIdsCompatibilityUpdate", { whitelistedKeyIds: [], blacklistedKeyIds: [], delistedKeyIds: sessionInfo.record.getAssociatedKeyIds(), }); } (_a = stores.persistentSessionsStore) === null || _a === void 0 ? void 0 : _a.delete(mediaKeySession.sessionId); stores.loadedSessionsStore .closeSession(mediaKeySession) .catch((e) => { const closeError = e instanceof Error ? e : "unknown error"; log.warn("DRM: failed to close expired session", closeError); }) .then(() => this._unlockInitDataQueue()) .catch((retryError) => this._onFatalError(retryError)); if (!this._isStopped()) { this.trigger("warning", err.reason); } return; } if (!(err instanceof BlacklistedSessionError)) { this._onFatalError(err); return; } sessionInfo.blacklistedSessionError = err; if (initializationData.content !== undefined) { log.info("DRM: blacklisting Representations based on " + "protection data."); this.trigger("blackListProtectionData", initializationData); } this._unlockInitDataQueue(); // TODO warning for blacklisted session? }, }, this._canceller.signal); if (options.singleLicensePer === undefined || options.singleLicensePer === "init-data") { this._unlockInitDataQueue(); } if (sessionRes.type === "created-session" /* MediaKeySessionLoadingType.Created */) { const requestData = initializationData.values.constructRequestData(); try { await stores.loadedSessionsStore.generateLicenseRequest(mediaKeySession, initializationData.type, requestData); } catch (error) { // First check that the error was not due to the MediaKeySession closing // or being closed const entry = stores.loadedSessionsStore.getEntryForSession(mediaKeySession); if (entry === null || entry.closingStatus.type !== "none") { // MediaKeySession closing/closed: Just remove from handled list and abort. const indexInCurrent = this._currentSessions.indexOf(sessionInfo); if (indexInCurrent >= 0) { this._currentSessions.splice(indexInCurrent, 1); } return Promise.resolve(); } throw new EncryptedMediaError("KEY_GENERATE_REQUEST_ERROR", error instanceof Error ? error.toString() : "Unknown error"); } } return Promise.resolve(); } _tryToUseAlreadyCreatedSession(initializationData, mediaKeysData) { const { stores, options } = mediaKeysData; /** * If set, a currently-used key session is already compatible to this * initialization data. */ const compatibleSessionInfo = arrayFind(this._currentSessions, (x) => x.record.isCompatibleWith(initializationData)); if (compatibleSessionInfo === undefined) { return false; } /** * On Safari using Directfile, the old EME implementation triggers * the "webkitneedkey" event instead of "encrypted". There's an issue in Safari * where "webkitneedkey" fires too early before all tracks are added from an HLS playlist. * Safari incorrectly assumes some keys are missing for these tracks, * leading to repeated "webkitneedkey" events. Because RxPlayer recognizes * it already has a session for these keys and ignores the events, * the content remains frozen. To resolve this, the session is re-created. */ const forceSessionRecreation = initializationData.forceSessionRecreation; if (forceSessionRecreation === true) { this.removeSessionForInitData(initializationData, mediaKeysData); return false; } // Check if the compatible session is blacklisted const blacklistedSessionError = compatibleSessionInfo.blacklistedSessionError; if (!isNullOrUndefined(blacklistedSessionError)) { if (initializationData.type === undefined || initializationData.content === undefined) { log.error("DRM: This initialization data has already been blacklisted " + "but the current content is not known."); return true; } else { log.info("DRM: This initialization data has already been blacklisted. " + "Blacklisting the related content."); this.trigger("blackListProtectionData", initializationData); return true; } } // Check if the current key id(s) has been blacklisted by this session if (initializationData.keyIds !== undefined) { /** * If set to `true`, the Representation(s) linked to this * initialization data's key id should be marked as "not decipherable". */ let isUndecipherable; if (options.singleLicensePer === undefined || options.singleLicensePer === "init-data") { // Note: In the default "init-data" mode, we only avoid a // Representation if the key id was originally explicitely // blacklisted (and not e.g. if its key was just not present in // the license). // // This is to enforce v3.x.x retro-compatibility: we cannot // fallback from a Representation unless some RxPlayer option // documentating this behavior has been set. const { blacklisted } = compatibleSessionInfo.keyStatuses; isUndecipherable = areSomeKeyIdsContainedIn(initializationData.keyIds, blacklisted); } else { // In any other mode, as soon as not all of this initialization // data's linked key ids are explicitely whitelisted, we can mark // the corresponding Representation as "not decipherable". // This is because we've no such retro-compatibility guarantee to // make there. const { whitelisted } = compatibleSessionInfo.keyStatuses; isUndecipherable = !areAllKeyIdsContainedIn(initializationData.keyIds, whitelisted); } if (isUndecipherable) { if (initializationData.content === undefined) { log.error("DRM: Cannot forbid key id, the content is unknown."); return true; } log.info("DRM: Current initialization data is linked to blacklisted keys. " + "Marking Representations as not decipherable"); this.trigger("keyIdsCompatibilityUpdate", { whitelistedKeyIds: [], blacklistedKeyIds: initializationData.keyIds, delistedKeyIds: [], }); return true; } } // If we reached here, it means that this initialization data is not // blacklisted in any way. // Search loaded session and put it on top of the cache if it exists. const entry = stores.loadedSessionsStore.reuse(initializationData); if (entry !== null) { // TODO update decipherability to `true` if not? log.debug("DRM: Init data already processed. Skipping it."); return true; } // Session not found in `loadedSessionsStore`, it might have been closed // since. // Remove from `this._currentSessions` and start again. const indexOf = this._currentSessions.indexOf(compatibleSessionInfo); if (indexOf === -1) { log.error("DRM: Unable to remove processed init data: not found."); } else { log.debug("DRM: A session from a processed init data is not available " + "anymore. Re-processing it."); this._currentSessions.splice(indexOf, 1); } return false; } /** * Remove the session corresponding to the initData provided, and close it. * It does nothing if no session was found for this initData. * @param {Object} initData : The initialization data corresponding to the session * that need to be removed * @param {Object} mediaKeysData : The media keys data */ removeSessionForInitData(initData, mediaKeysData) { const { stores } = mediaKeysData; /** Remove the session and close it from the loadedSessionStore */ const entry = stores.loadedSessionsStore.reuse(initData); if (entry !== null) { stores.loadedSessionsStore .closeSession(entry.mediaKeySession) .catch(() => log.error("DRM: Cannot close the session from the loaded session store")); } /** * If set, a currently-used key session is already compatible to this * initialization data. */ const compatibleSessionInfo = arrayFind(this._currentSessions, (x) => x.record.isCompatibleWith(initData)); if (compatibleSessionInfo === undefined) { return; } /** Remove the session from the currentSessions */ const indexOf = this._currentSessions.indexOf(compatibleSessionInfo); if (indexOf !== -1) { log.debug("DRM: A session from a processed init is removed due to forceSessionRecreation policy."); this._currentSessions.splice(indexOf, 1); } } /** * Callback that should be called if an error that made the current * `ContentDecryptor` instance unusable arised. * This callbacks takes care of resetting state and sending the right events. * * Once called, no further actions should be taken. * * @param {*} err - The error object which describes the issue. Will be * formatted and sent in an "error" event. */ _onFatalError(err) { if (this._canceller.isUsed()) { return; } const formattedErr = err instanceof Error ? err : new OtherError("NONE", "Unknown decryption error"); this.error = formattedErr; this._initDataQueue.length = 0; this._stateData = { state: ContentDecryptorState.Error, isMediaKeysAttached: undefined, isInitDataQueueLocked: undefined, data: null, }; this._canceller.cancel(); this.trigger("error", formattedErr); // The previous trigger might have lead to a disposal of the `ContentDecryptor`. if (this._stateData.state === ContentDecryptorState.Error) { this.trigger("stateChange", this._stateData.state); } } /** * Return `true` if the `ContentDecryptor` has either been disposed or * encountered a fatal error which made it stop. * @returns {boolean} */ _isStopped() { return (this._stateData.state === ContentDecryptorState.Disposed || this._stateData.state === ContentDecryptorState.Error); } /** * Start processing the next initialization data of the `_initDataQueue` if it * isn't lock. */ _processCurrentInitDataQueue() { while (this._stateData.isInitDataQueueLocked === false) { const initData = this._initDataQueue.shift(); if (initData === undefined) { return; } this.onInitializationData(initData); } } /** * Lock new initialization data (from the `_initDataQueue`) from being * processed until `_unlockInitDataQueue` is called. * * You may want to call this method when performing operations which may have * an impact on the handling of other initialization data. */ _lockInitDataQueue() { if (this._stateData.isInitDataQueueLocked === false) { this._stateData.isInitDataQueueLocked = true; } } /** * Unlock `_initDataQueue` and start processing the first element. * * Should have no effect if the `_initDataQueue` was not locked. */ _unlockInitDataQueue() { if (this._stateData.isMediaKeysAttached !== 2 /* MediaKeyAttachmentStatus.Attached */) { log.error("DRM: Trying to unlock in the wrong state"); return; } this._stateData.isInitDataQueueLocked = false; this._processCurrentInitDataQueue(); } } /** * Returns `true` if the given MediaKeySystemAccess can create * "persistent-license" MediaKeySessions. * @param {MediaKeySystemAccess} mediaKeySystemAccess * @returns {Boolean} */ function canCreatePersistentSession(mediaKeySystemAccess) { const { sessionTypes } = mediaKeySystemAccess.getConfiguration(); return sessionTypes !== undefined && arrayIncludes(sessionTypes, "persistent-license"); } /** * Returns `true` if the given MediaKeySystemAccess can create * "temporary" MediaKeySessions. * @param {MediaKeySystemAccess} mediaKeySystemAccess * @returns {Boolean} */ function canCreateTemporarySession(mediaKeySystemAccess) { const { sessionTypes } = mediaKeySystemAccess.getConfiguration(); return sessionTypes !== undefined && arrayIncludes(sessionTypes, "temporary"); } /** * Return the list of key IDs present in the `expectedKeyIds` array * but that are not present in `actualKeyIds`. * @param {Uint8Array[]} expectedKeyIds - Array of key IDs expected to be found. * @param {Uint8Array[]} actualKeyIds - Array of key IDs to test. * @returns {Uint8Array[]} An array of key IDs that are missing from `actualKeyIds`. */ export function getMissingKeyIds(expectedKeyIds, actualKeyIds) { return expectedKeyIds.filter((expected) => { return !actualKeyIds.some((actual) => areArraysOfNumbersEqual(actual, expected)); }); } /** * Returns an array of all key IDs that are known by the `KeySessionRecord` * but are missing in the provided array of key IDs `newKeyIds`. * @param {KeySessionRecord} keySessionRecord - The KeySessionRecord containing known key IDs. * @param {Uint8Array[]} newKeyIds - Array of key IDs. * @returns {Uint8Array[]} An array of key IDs that are known by the `keySessionRecord` * but are missing in the license. */ export function getMissingKnownKeyIds(keySessionRecord, newKeyIds) { const allKnownKeyIds = keySessionRecord.getAssociatedKeyIds(); const missingKeyIds = getMissingKeyIds(allKnownKeyIds, newKeyIds); if (missingKeyIds.length > 0 && log.hasLevel("DEBUG")) { log.debug("DRM: KeySessionRecord's keys missing in the license, blacklisting them", missingKeyIds.map((m) => bytesToHex(m)).join(", ")); } return missingKeyIds; } /** * Returns an array of all key IDs that are present in InitData * but are missing in the provided array of key IDs `newKeyIds`. * @param {IProcessedProtectionData} initializationData - The initialization data containing key IDs. * @param {Uint8Array[]} newKeyIds - Array of key IDs. * @returns {Uint8Array[]} An array of key IDs that are present in initializationData * but are missing in the license. */ export function getMissingInitDataKeyIds(initializationData, newKeyIds) { let missingKeyIds = []; const { keyIds: expectedKeyIds } = initializationData; if (expectedKeyIds !== undefined) { missingKeyIds = getMissingKeyIds(expectedKeyIds, newKeyIds); } if (missingKeyIds.length > 0 && log.hasLevel("DEBUG")) { log.debug("DRM: init data keys missing in the license, blacklisting them", missingKeyIds.map((m) => bytesToHex(m)).join(", ")); } return missingKeyIds; } /** * Returns set of all usable and unusable keys - explicit or implicit - that are * linked to a `MediaKeySession`. * * In the RxPlayer, there is a concept of "explicit" key ids, which are key ids * found in a license whose status can be known through the `keyStatuses` * property from a `MediaKeySession`, and of "implicit" key ids, which are key * ids which were expected to be in a fetched license, but apparently weren't. * * @param {Object} initializationData - Initialization data object used to make * the request for the current license. * @param {Object} keySessionRecord - The `KeySessionRecord` associated with the * session that has been loaded. It might give supplementary information on * keys implicitly linked to the license. * @param {string|undefined} singleLicensePer - Setting allowing to indicate the * scope a given license should have. * @param {boolean} isCurrentLicense - If `true` the license has been fetched * especially for the current content. * * Knowing this allows to determine that if decryption keys that should have * been referenced in the fetched license (according to the `singleLicensePer` * setting) are missing, then the keys surely must have been voluntarly * removed from the license. * * If it is however set to `false`, it means that the license is an older * license that might have been linked to another content, thus we cannot make * that assumption. * @param {Array.<Uint8Array>} usableKeyIds - Key ids that are present in the * license and can be used. * @param {Array.<Uint8Array>} unusableKeyIds - Key ids that are present in the * license yet cannot be used. * @returns {Object} - Returns an object with the following properties: * - `whitelisted`: Array of key ids for keys that are known to be usable * - `blacklisted`: Array of key ids for keys that are considered unusable. * The qualities linked to those keys should not be played. */ function getKeyIdsLinkedToSession(initializationData, keySessionRecord, singleLicensePer, isCurrentLicense, usableKeyIds, unusableKeyIds) { var _a; /** * Every key id associated with the MediaKeySession, starting with * whitelisted ones. */ const keyIdsInLicense = [...usableKeyIds, ...unusableKeyIds]; const missingKnownKeyIds = getMissingKnownKeyIds(keySessionRecord, keyIdsInLicense); const associatedKeyIds = keyIdsInLicense.concat(missingKnownKeyIds); if (singleLicensePer !== undefined && singleLicensePer !== "init-data") { // We want to add the current key ids in the blacklist if it is // not already there. // // We only do that when `singleLicensePer` is set to something // else than the default `"init-data"` because this logic: // 1. might result in a quality fallback, which is a v3.x.x // breaking change if some APIs (like `singleLicensePer`) // aren't used. // 2. Rely on the EME spec regarding key statuses being well // implemented on all supported devices, which we're not // sure yet. Because in any other `singleLicensePer`, we // need a good implementation anyway, it doesn't matter // there. const missingInitDataKeyIds = getMissingInitDataKeyIds(initializationData, associatedKeyIds); associatedKeyIds.push(...missingInitDataKeyIds); const { content } = initializationData; if (isCurrentLicense && content !== undefined) { if (singleLicensePer === "content") { // Put it in a Set to automatically filter out duplicates (by ref) const contentKeys = new Set(); const { manifest } = content; for (const period of manifest.periods) { addKeyIdsFromPeriod(contentKeys, period); } mergeKeyIdSetIntoArray(contentKeys, associatedKeyIds); } else if (singleLicensePer === "periods") { const { manifest } = content; for (const period of manifest.periods) { const periodKeys = new Set(); addKeyIdsFromPeriod(periodKeys, period); if (((_a = initializationData.content) === null || _a === void 0 ? void 0 : _a.period.id) === period.id) { mergeKeyIdSetIntoArray(periodKeys, associatedKeyIds); } else { const periodKeysArr = Array.from(periodKeys); for (const kid of periodKeysArr) { const isFound = associatedKeyIds.some((k) => areArraysOfNumbersEqual(k, kid)); if (isFound) { mergeKeyIdSetIntoArray(periodKeys, associatedKeyIds); break; } } } } } } } return { whitelisted: usableKeyIds, /** associatedKeyIds starts with the whitelisted one. */ blacklisted: associatedKeyIds.slice(usableKeyIds.length), }; } /** * Push all kei ids in the given `set` and add it to the `arr` Array only if it * isn't already present in it. * @param {Set.<Uint8Array>} set * @param {Array.<Uint8Array>} arr */ function mergeKeyIdSetIntoArray(set, arr) { const setArr = Array.from(set.values()); for (const kid of setArr) { const isFound = arr.some((k) => areArraysOfNumbersEqual(k, kid)); if (!isFound) { arr.push(kid); } } } /** * Add to the given `set` all key ids found in the given `Period`. * @param {Set.<Uint8Array>} set * @param {Object} period */ function addKeyIdsFromPeriod(set, period) { const adaptationsByType = period.adaptations; const adaptations = objectValues(adaptationsByType).reduce( // Note: the second case cannot happen. TS is just being dumb here (acc, adaps) => (!isNullOrUndefined(adaps) ? acc.concat(adaps) : acc), []); for (const adaptation of adaptations) { for (const representation of adaptation.representations) { if (representation.contentProtections !== undefined && representation.contentProtections.keyIds !== undefined) { for (const kid of representation.contentProtections.keyIds) { set.add(kid); } } } } }