diff --git a/src/main_thread/decrypt/content_decryptor.ts b/src/main_thread/decrypt/content_decryptor.ts index 00075bb02f..d1989a3188 100644 --- a/src/main_thread/decrypt/content_decryptor.ts +++ b/src/main_thread/decrypt/content_decryptor.ts @@ -30,11 +30,12 @@ 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 flatMap from "../../utils/flat_map"; 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 createOrLoadSession, { NoSessionSpaceError } from "./create_or_load_session"; import type { ICodecSupportList } from "./find_key_system"; import type { IMediaKeysInfos } from "./get_media_keys"; import initMediaKeys from "./init_media_keys"; @@ -50,6 +51,8 @@ import type { IContentDecryptorEvent, } from "./types"; import { MediaKeySessionLoadingType, ContentDecryptorState } from "./types"; +import type { IActiveSessionInfo } from "./utils/active_sessions_store"; +import ActiveSessionsStore from "./utils/active_sessions_store"; import { DecommissionedSessionError } from "./utils/check_key_statuses"; import cleanOldStoredPersistentInfo from "./utils/clean_old_stored_persistent_info"; import getDrmSystemId from "./utils/get_drm_system_id"; @@ -112,12 +115,11 @@ export default class ContentDecryptor extends EventEmitter { this._onFatalError(err); @@ -417,6 +439,91 @@ export default class ContentDecryptor extends EventEmitter} keyIds + * @returns {boolean} + */ + public freeKeyIds(keyIds: Uint8Array[]): boolean { + if (this._stateData.isMediaKeysAttached !== MediaKeyAttachmentStatus.Attached) { + log.warn("DRM", "Invalid state when freeing key ids", { + attachmentState: this._stateData.isMediaKeysAttached, + }); + return false; + } + + const loadedSessionsStore = + this._stateData.data.mediaKeysData.stores.loadedSessionsStore; + const entries = loadedSessionsStore.getAll(); + for (const entry of entries) { + const { keySessionRecord } = entry; + const keyIdsFromSession = keySessionRecord.getAssociatedKeyIds(); + if (areAllKeyIdsContainedIn(keyIdsFromSession, keyIds)) { + loadedSessionsStore.closeSession(entry.mediaKeySession).catch((err) => { + const errorMsg = err instanceof Error ? err.message : "Unknown Error"; + log.warn("DRM", "Failed to close mediaKeySession in `freeKeyIds`", errorMsg); + }); + } + } + + const currentActiveSessions = this._activeSessionsStore.getSessions(); + for (let i = currentActiveSessions.length - 1; i >= 0; i--) { + const session = currentActiveSessions[i]; + if (!loadedSessionsStore.hasEntryForRecord(session.record)) { + this._activeSessionsStore.removeSession(session); + } + } + + return !this._activeSessionsStore.isFull(); + } + + /** + * Returns `true` if the `ContentDecryptor` is known to currently depend on + * too much `MediaKeySession` for the current configuration, and is thus + * unable to create more, limiting the possibility to handle further keys. + * + * To allow the `ContentDecryptor` to remove some of those `MediaKeySession`, + * you're advised to call the `freeKeyIds` method for keys you don't want to + * be handling anymore. + * + * @returns {boolean} + */ + public hasTooMuchSessions(): boolean { + return ( + this._initDataQueues.overflowQueue.length > 0 && this._activeSessionsStore.isFull() + ); + } + /** * 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 @@ -451,7 +558,7 @@ export default class ContentDecryptor extends EventEmitter x.source === MediaKeySessionLoadingType.Created, ); @@ -494,9 +601,9 @@ export default class ContentDecryptor extends EventEmitter x.source === MediaKeySessionLoadingType.Created, - ); + const createdSessions = this._activeSessionsStore + .getSessions() + .filter((x) => x.source === MediaKeySessionLoadingType.Created); const periodKeys = new Set(); addKeyIdsFromPeriod(periodKeys, period); for (const createdSess of createdSessions) { @@ -566,13 +673,51 @@ export default class ContentDecryptor extends EventEmitter s.record); + let sessionRes; + try { + sessionRes = await createOrLoadSession( + { + activeRecords, + initializationData, + sessionStores: stores, + sessionType: wantedSessionType, + maxSessionCacheSize, + }, + this._canceller.signal, + ); + } catch (err) { + if (!(err instanceof NoSessionSpaceError)) { + throw err; + } + if (this._isStopped()) { + return; + } + + // We have no space for further sessions for that content, trigger a + // "tooMuchSessions" event. + this._activeSessionsStore.markAsFull(); + if (this._initDataQueues.overflowQueue.indexOf(initializationData) === -1) { + this._initDataQueues.overflowQueue.push(initializationData); + } + + // We unlock the init data queue first, to avoid weird states. + this._unlockInitDataQueue(); + if (this._isStopped()) { + return; + } + if (this._activeSessionsStore.isFull()) { + this.trigger("tooMuchSessions", { + waitingKeyIds: flatMap(this._initDataQueues.overflowQueue, (k) => { + return k.keyIds ?? []; + }), + activeKeyIds: flatMap(activeRecords, (r: KeySessionRecord): Uint8Array[] => { + return r.getAssociatedKeyIds(); + }), + }); + } + return; + } if (this._isStopped()) { return; } @@ -583,7 +728,7 @@ export default class ContentDecryptor extends EventEmitter= 0) { - this._currentSessions.splice(indexOf); - } + this._activeSessionsStore.removeSession(sessionInfo); if (initializationData.content !== undefined) { this.trigger("keyIdsCompatibilityUpdate", { whitelistedKeyIds: [], @@ -719,10 +861,7 @@ export default class ContentDecryptor extends EventEmitter= 0) { - this._currentSessions.splice(indexInCurrent, 1); - } + this._activeSessionsStore.removeSession(sessionInfo); return Promise.resolve(); } throw new EncryptedMediaError( @@ -750,8 +889,9 @@ export default class ContentDecryptor extends EventEmitter - x.record.isCompatibleWith(initializationData), + const compatibleSessionInfo = arrayFind( + this._activeSessionsStore.getSessions(), + (x) => x.record.isCompatibleWith(initializationData), ); if (compatibleSessionInfo === undefined) { @@ -866,9 +1006,9 @@ export default class ContentDecryptor extends EventEmitter - x.record.isCompatibleWith(initData), + const compatibleSessionInfo = arrayFind( + this._activeSessionsStore.getSessions(), + (x) => x.record.isCompatibleWith(initData), ); if (compatibleSessionInfo === undefined) { return; } /** Remove the session from the currentSessions */ - const indexOf = this._currentSessions.indexOf(compatibleSessionInfo); - if (indexOf !== -1) { + const hasRemoved = this._activeSessionsStore.removeSession(compatibleSessionInfo); + if (hasRemoved) { log.debug( "DRM", "A session from a processed init is removed due to forceSessionRecreation policy.", ); - this._currentSessions.splice(indexOf, 1); } } @@ -941,7 +1080,8 @@ export default class ContentDecryptor extends EventEmitter 0 && + !this._activeSessionsStore.isFull() + ? this._initDataQueues.overflowQueue.shift() + : this._initDataQueues.mainQueue.shift(); if (initData === undefined) { return; } - this.onInitializationData(initData); + const { mediaKeysData } = this._stateData.data; + this._processInitializationData(initData, mediaKeysData).catch((err) => { + this._onFatalError(err); + }); } } @@ -1365,38 +1512,6 @@ type IErrorStateData = IContentDecryptorStateBase< null // data >; -/** Information linked to a session created by the `ContentDecryptor`. */ -interface IActiveSessionInfo { - /** - * Record associated to the session. - * Most notably, it allows both to identify the session as well as to - * anounce and find out which key ids are already handled. - */ - record: KeySessionRecord; - - /** Current keys' statuses linked that session. */ - keyStatuses: { - /** Key ids linked to keys that are "usable". */ - whitelisted: Uint8Array[]; - /** - * Key ids linked to keys that are not considered "usable". - * Content linked to those keys are not decipherable and may thus be - * fallbacked from. - */ - blacklisted: Uint8Array[]; - }; - - /** Source of the MediaKeySession linked to that record. */ - source: MediaKeySessionLoadingType; - - /** - * If different than `null`, all initialization data compatible with this - * processed initialization data has been blacklisted with this corresponding - * error. - */ - blacklistedSessionError: BlacklistedSessionError | null; -} - /** * Sent when the created (or already created) MediaKeys is attached to the * current HTMLMediaElement element. diff --git a/src/main_thread/decrypt/create_or_load_session.ts b/src/main_thread/decrypt/create_or_load_session.ts index 8a3ae46d55..6682aa70c0 100644 --- a/src/main_thread/decrypt/create_or_load_session.ts +++ b/src/main_thread/decrypt/create_or_load_session.ts @@ -20,10 +20,14 @@ import type { CancellationSignal } from "../../utils/task_canceller"; import createSession from "./create_session"; import type { IProcessedProtectionData, IMediaKeySessionStores } from "./types"; import { MediaKeySessionLoadingType } from "./types"; -import cleanOldLoadedSessions from "./utils/clean_old_loaded_sessions"; +import cleanOldLoadedSessions, { + NoSessionSpaceError, +} from "./utils/clean_old_loaded_sessions"; import isSessionUsable from "./utils/is_session_usable"; import type KeySessionRecord from "./utils/key_session_record"; +export { NoSessionSpaceError }; + /** * Handle MediaEncryptedEvents sent by a HTMLMediaElement: * Either create a MediaKeySession, recuperate a previous MediaKeySession or @@ -34,24 +38,34 @@ import type KeySessionRecord from "./utils/key_session_record"; * `EME_MAX_SIMULTANEOUS_MEDIA_KEY_SESSIONS` config property. * * You can refer to the events emitted to know about the current situation. - * @param {Object} initializationData - * @param {Object} stores - * @param {string} wantedSessionType - * @param {number} maxSessionCacheSize + * @param {Object} arg + * @param {Object} arg.initializationData + * @param {Object} arg.sessionStores + * @param {string} arg.sessionType + * @param {number} arg.maxSessionCacheSize * @param {Object} cancelSignal * @returns {Promise} */ export default async function createOrLoadSession( - initializationData: IProcessedProtectionData, - stores: IMediaKeySessionStores, - wantedSessionType: MediaKeySessionType, - maxSessionCacheSize: number, + { + initializationData, + sessionStores, + sessionType, + activeRecords, + maxSessionCacheSize, + }: { + initializationData: IProcessedProtectionData; + sessionStores: IMediaKeySessionStores; + sessionType: MediaKeySessionType; + activeRecords: KeySessionRecord[]; + maxSessionCacheSize: number; + }, cancelSignal: CancellationSignal, ): Promise { /** Store previously-loaded compatible MediaKeySession, if one. */ let previousLoadedSession: IMediaKeySession | null = null; - const { loadedSessionsStore, persistentSessionsStore } = stores; + const { loadedSessionsStore, persistentSessionsStore } = sessionStores; const entry = loadedSessionsStore.reuse(initializationData); if (entry !== null) { previousLoadedSession = entry.mediaKeySession; @@ -86,6 +100,7 @@ export default async function createOrLoadSession( await cleanOldLoadedSessions( loadedSessionsStore, + activeRecords, // Account for the next session we will be creating // Note that `maxSessionCacheSize < 0 has special semantic (no limit)` maxSessionCacheSize <= 0 ? maxSessionCacheSize : maxSessionCacheSize - 1, @@ -95,9 +110,9 @@ export default async function createOrLoadSession( } const evt = await createSession( - stores, + sessionStores, initializationData, - wantedSessionType, + sessionType, cancelSignal, ); return { diff --git a/src/main_thread/decrypt/types.ts b/src/main_thread/decrypt/types.ts index cfeb419487..870cfd743c 100644 --- a/src/main_thread/decrypt/types.ts +++ b/src/main_thread/decrypt/types.ts @@ -41,6 +41,11 @@ export interface IContentDecryptorEvent { */ warning: IPlayerError; + tooMuchSessions: { + waitingKeyIds: Uint8Array[]; + activeKeyIds: Uint8Array[]; + }; + /** * Event emitted when the `ContentDecryptor`'s state changed. * States are a central aspect of the `ContentDecryptor`, be sure to check the diff --git a/src/main_thread/decrypt/utils/__tests__/clean_old_loaded_sessions.test.ts b/src/main_thread/decrypt/utils/__tests__/clean_old_loaded_sessions.test.ts index 746529e136..ef321cd7d6 100644 --- a/src/main_thread/decrypt/utils/__tests__/clean_old_loaded_sessions.test.ts +++ b/src/main_thread/decrypt/utils/__tests__/clean_old_loaded_sessions.test.ts @@ -1,24 +1,38 @@ import { describe, it, expect, vi } from "vitest"; import cleanOldLoadedSessions from "../clean_old_loaded_sessions"; +import InitDataValuesContainer from "../init_data_values_container"; +import KeySessionRecord from "../key_session_record"; import type LoadedSessionsStore from "../loaded_sessions_store"; const entry1 = { initializationData: { data: new Uint8Array([1, 6, 9]), type: "test" }, mediaKeySession: { sessionId: "toto" }, sessionType: "", + keySessionRecord: new KeySessionRecord({ + type: undefined, + values: new InitDataValuesContainer([]), + }), }; const entry2 = { initializationData: { data: new Uint8Array([4, 8]), type: "foo" }, mediaKeySession: { sessionId: "titi" }, sessionType: "", + keySessionRecord: new KeySessionRecord({ + type: undefined, + values: new InitDataValuesContainer([]), + }), }; const entry3 = { initializationData: { data: new Uint8Array([7, 3, 121, 87]), type: "bar" }, mediaKeySession: { sessionId: "tutu" }, sessionType: "", + keySessionRecord: new KeySessionRecord({ + type: undefined, + values: new InitDataValuesContainer([]), + }), }; function createLoadedSessionsStore(): LoadedSessionsStore { @@ -64,7 +78,7 @@ async function checkNothingHappen( limit: number, ): Promise { const mockCloseSession = vi.spyOn(loadedSessionsStore, "closeSession"); - await cleanOldLoadedSessions(loadedSessionsStore, limit); + await cleanOldLoadedSessions(loadedSessionsStore, [], limit); expect(mockCloseSession).not.toHaveBeenCalled(); mockCloseSession.mockRestore(); } @@ -85,7 +99,7 @@ async function checkEntriesCleaned( entries: Array<{ sessionId: string }>, ): Promise { const mockCloseSession = vi.spyOn(loadedSessionsStore, "closeSession"); - const prom = cleanOldLoadedSessions(loadedSessionsStore, limit).then(() => { + const prom = cleanOldLoadedSessions(loadedSessionsStore, [], limit).then(() => { expect(mockCloseSession).toHaveBeenCalledTimes(entries.length); mockCloseSession.mockRestore(); }); diff --git a/src/main_thread/decrypt/utils/active_sessions_store.ts b/src/main_thread/decrypt/utils/active_sessions_store.ts new file mode 100644 index 0000000000..7a75cedae5 --- /dev/null +++ b/src/main_thread/decrypt/utils/active_sessions_store.ts @@ -0,0 +1,139 @@ +import type { BlacklistedSessionError } from "../session_events_listener"; +import type { MediaKeySessionLoadingType } from "../types"; +import type KeySessionRecord from "./key_session_record"; + +/** + * Contains information about all key sessions loaded for the current + * content. + * This object is most notably used to check which keys are already obtained, + * thus avoiding to perform new unnecessary license requests and CDM + * interactions. + * + * It is important to create only one `ActiveSessionsStore` for a given + * `MediaKeys` to prevent conflicts. + * + * An `ActiveSessionsStore` instance can also be "marked" as full with the + * `markAsFull` method. + * "Marking as full" this way does not change your ability do add new session, + * but the `isFull` method will return `true` until at least a single session is + * removed from this `ActiveSessionsInfo`. + * This "full" flag allows to simplify the management of having too many + * simultaneous `MediaKeySession` on the current device, by storing in a single + * place whether this event has been encountered and whether it had chance to + * be resolved since. + * + * @class ActiveSessionsInfo + */ +export default class ActiveSessionsStore { + /** Metadata on each `MediaKeySession` stored here. */ + private _sessions: IActiveSessionInfo[]; + + /** + * `true` after the `markAsFull` method has been called, until `removeSession` + * is called **and** led to a `MediaKeySession` has been removed. + * + * This boolean has no impact on the creation of new `MediaKeySession`, it is + * only here as a flag to indicate that a surplus of `MediaKeySession` + * linked to this `ActiveSessionsStore` has been detected and only resets to + * `false` when it has chances to be resolved (when a `MediaKeySession` has + * since been removed). + */ + private _isFull: boolean; + + constructor() { + this._sessions = []; + this._isFull = false; + } + + /** + * Set the `isFull` flag to true meaning that the `isFull` method will from + * now on return `true` until at least one `MediaKeySession` has been removed + * from this `ActiveSessionsStore` (through the `removeSession` method). + * + * This flag allows to store the information of whether too much + * `MediaKeySession` seems to be created right now. + */ + public markAsFull(): void { + this._isFull = true; + } + + /** + * Add a new `MediaKeySession`, and its associated information, to the + * `ActiveSessionsStore`. + * @param {Object} sessionInfo + */ + public addSession(sessionInfo: IActiveSessionInfo) { + this._sessions.push(sessionInfo); + } + + /** + * Returns all information in the `ActiveSessionsStore` by order of insertion. + * @returns {Array.} + */ + public getSessions(): IActiveSessionInfo[] { + return this._sessions; + } + + /** + * Remove element with the corresponding `MediaKeySession` information from + * the `ActiveSessionsStore` if found. + * + * Returns `true` if the corresponding element has been found and removed, or + * `false` if it wasn't found. + * + * @param {Object} sessionInfo + * @returns {boolean} + */ + public removeSession(sessionInfo: IActiveSessionInfo): boolean { + const indexOf = this._sessions.indexOf(sessionInfo); + if (indexOf >= 0) { + this._sessions.splice(indexOf, 1); + this._isFull = false; + return true; + } + return false; + } + + /** + * If `true`, we know that there's too much `MediaKeySession` currently + * created. + * + * @see `markAsFull` method. + * @returns {boolean} + */ + public isFull(): boolean { + return this._isFull; + } +} + +/** Information linked to a session created by the `ContentDecryptor`. */ +export interface IActiveSessionInfo { + /** + * Record associated to the session. + * Most notably, it allows both to identify the session as well as to + * anounce and find out which key ids are already handled. + */ + record: KeySessionRecord; + + /** Current keys' statuses linked that session. */ + keyStatuses: { + /** Key ids linked to keys that are "usable". */ + whitelisted: Uint8Array[]; + /** + * Key ids linked to keys that are not considered "usable". + * Content linked to those keys are not decipherable and may thus be + * fallbacked from. + */ + blacklisted: Uint8Array[]; + }; + + /** Source of the MediaKeySession linked to that record. */ + source: MediaKeySessionLoadingType; + + /** + * If different than `null`, all initialization data compatible with this + * processed initialization data has been blacklisted with this corresponding + * error. + */ + blacklistedSessionError: BlacklistedSessionError | null; +} diff --git a/src/main_thread/decrypt/utils/clean_old_loaded_sessions.ts b/src/main_thread/decrypt/utils/clean_old_loaded_sessions.ts index 7e76157ac8..d64cf16c8b 100644 --- a/src/main_thread/decrypt/utils/clean_old_loaded_sessions.ts +++ b/src/main_thread/decrypt/utils/clean_old_loaded_sessions.ts @@ -15,6 +15,8 @@ */ import log from "../../../log"; +import arrayIncludes from "../../../utils/array_includes"; +import type KeySessionRecord from "./key_session_record"; import type LoadedSessionsStore from "./loaded_sessions_store"; /** @@ -28,6 +30,7 @@ import type LoadedSessionsStore from "./loaded_sessions_store"; */ export default async function cleanOldLoadedSessions( loadedSessionsStore: LoadedSessionsStore, + activeRecords: KeySessionRecord[], limit: number, ): Promise { if (limit < 0 || limit >= loadedSessionsStore.getLength()) { @@ -38,11 +41,36 @@ export default async function cleanOldLoadedSessions( length: loadedSessionsStore.getLength(), }); const proms: Array> = []; - const entries = loadedSessionsStore.getAll().slice(); // clone - const toDelete = entries.length - limit; - for (let i = 0; i < toDelete; i++) { - const entry = entries[i]; - proms.push(loadedSessionsStore.closeSession(entry.mediaKeySession)); + const sessionsMetadata = loadedSessionsStore.getAll().slice(); // clone + let toDelete = sessionsMetadata.length - limit; + for (let i = 0; toDelete > 0 && i < sessionsMetadata.length; i++) { + const metadata = sessionsMetadata[i]; + if (!arrayIncludes(activeRecords, metadata.keySessionRecord)) { + proms.push(loadedSessionsStore.closeSession(metadata.mediaKeySession)); + toDelete--; + } + } + if (toDelete > 0) { + return Promise.all(proms).then(() => { + return Promise.reject( + new NoSessionSpaceError("Could not remove all sessions: some are still active"), + ); + }); } await Promise.all(proms); } + +/** + * Error thrown when the MediaKeySession is blacklisted. + * Such MediaKeySession should not be re-used but other MediaKeySession for the + * same content can still be used. + * @class NoSessionSpaceError + * @extends Error + */ +export class NoSessionSpaceError extends Error { + constructor(message: string) { + super(message); + // @see https://stackoverflow.com/questions/41102060/typescript-extending-error-class + Object.setPrototypeOf(this, NoSessionSpaceError.prototype); + } +} diff --git a/src/main_thread/decrypt/utils/loaded_sessions_store.ts b/src/main_thread/decrypt/utils/loaded_sessions_store.ts index 25c6628c3b..817bf44c76 100644 --- a/src/main_thread/decrypt/utils/loaded_sessions_store.ts +++ b/src/main_thread/decrypt/utils/loaded_sessions_store.ts @@ -124,6 +124,15 @@ export default class LoadedSessionsStore { return null; } + public hasEntryForRecord(keySessionRecord: KeySessionRecord): boolean { + for (const stored of this._storage) { + if (stored.keySessionRecord === keySessionRecord) { + return true; + } + } + return false; + } + /** * Get `LoadedSessionsStore`'s entry for a given MediaKeySession. * Returns `null` if the given MediaKeySession is not stored in the diff --git a/src/main_thread/init/directfile_content_initializer.ts b/src/main_thread/init/directfile_content_initializer.ts index cd7ac74ca8..86cafc0e53 100644 --- a/src/main_thread/init/directfile_content_initializer.ts +++ b/src/main_thread/init/directfile_content_initializer.ts @@ -108,6 +108,9 @@ export default class DirectFileContentInitializer extends ContentInitializer { onWarning: (err: IPlayerError) => this.trigger("warning", err), onBlackListProtectionData: noop, onKeyIdsCompatibilityUpdate: noop, + onTooMuchSessions: () => { + log.error("Init", "There's currently too much MediaKeySession created"); + }, }, cancelSignal, ); diff --git a/src/main_thread/init/media_source_content_initializer.ts b/src/main_thread/init/media_source_content_initializer.ts index 657dc04b5a..a6d16acb42 100644 --- a/src/main_thread/init/media_source_content_initializer.ts +++ b/src/main_thread/init/media_source_content_initializer.ts @@ -70,6 +70,7 @@ import createCorePlaybackObserver from "./utils/create_core_playback_observer"; import type { IInitialTimeOptions } from "./utils/get_initial_time"; import getInitialTime from "./utils/get_initial_time"; import getLoadedReference from "./utils/get_loaded_reference"; +import handleTooMuchMediaKeySessions from "./utils/handle_too_much_media_key_sessions"; import performInitialSeekAndPlay from "./utils/initial_seek_and_play"; import RebufferingController from "./utils/rebuffering_controller"; import StreamEventsEmitter from "./utils/stream_events_emitter/stream_events_emitter"; @@ -392,6 +393,7 @@ export default class MediaSourceContentInitializer extends ContentInitializer { const { statusRef: drmInitializationStatus, contentDecryptor } = this._initializeContentDecryption( mediaElement, + playbackObserver, lastContentProtection, mediaSourceStatus, () => notifyAndStartMediaSourceReload(0, undefined, undefined), @@ -1334,6 +1336,7 @@ export default class MediaSourceContentInitializer extends ContentInitializer { private _initializeContentDecryption( mediaElement: IMediaElement, + playbackObserver: IMediaElementPlaybackObserver, lastContentProtection: IReadOnlySharedReference, mediaSourceStatus: SharedReference, reloadMediaSource: () => void, @@ -1493,6 +1496,20 @@ export default class MediaSourceContentInitializer extends ContentInitializer { } }); + contentDecryptor.addEventListener("tooMuchSessions", (payload) => { + const manifest = this._currentContentInfo?.manifest; + if (isNullOrUndefined(manifest)) { + log.error("Init", "Received tooMuchSessions error before getting a Manifest"); + return; + } + handleTooMuchMediaKeySessions( + contentDecryptor, + manifest, + playbackObserver, + payload, + ); + }); + contentDecryptor.addEventListener("error", (error) => { this._onFatalError(error); }); diff --git a/src/main_thread/init/utils/handle_too_much_media_key_sessions.ts b/src/main_thread/init/utils/handle_too_much_media_key_sessions.ts new file mode 100644 index 0000000000..4a5b69ff4f --- /dev/null +++ b/src/main_thread/init/utils/handle_too_much_media_key_sessions.ts @@ -0,0 +1,79 @@ +import log from "../../../log"; +import type ContentDecryptor from "../../../main_thread/decrypt"; +import type { IManifestMetadata } from "../../../manifest"; +import { getAdaptations } from "../../../manifest"; +import type { IMediaElementPlaybackObserver } from "../../../playback_observer"; +import areArraysOfNumbersEqual from "../../../utils/are_arrays_of_numbers_equal"; +import isNullOrUndefined from "../../../utils/is_null_or_undefined"; + +/** + * Logic performed when the `ContentDecryptor` tells us that there are too + * many DRM sessions created for the current content. + * + * We here try to determine which keys aren't needed anymore on the current + * content, and indicate to the `ContentDecryptor` that it can "free" them. + * + * @param {Object} contentDecryptor - The `ContentDecryptor` instance which + * has encountered the issue. + * @param {Object} manifest - Metadata on the content currently being played. + * @param {Object} playbackObserver - The PlaybackObserver linked to the same + * media element than the one handled by the `ContentDecryptor`. + * @param {Object} payload - The payload from the `tooMuchSessions` event from + * the `ContentDecryptor`. + */ +export default function handleTooMuchMediaKeySessions( + contentDecryptor: ContentDecryptor, + manifest: IManifestMetadata, + playbackObserver: IMediaElementPlaybackObserver, + payload: { + waitingKeyIds: Uint8Array[]; + activeKeyIds: Uint8Array[]; + }, +): void { + if (isNullOrUndefined(manifest)) { + log.error("Init", "Received tooMuchSessions error before fetching the Manifest"); + return; + } + + // We will here free all keys that aren't needed for the content buffered + // forward. + + const basePosition = Math.min( + playbackObserver.getCurrentTime(), + playbackObserver.getReference().getValue().position.getWanted(), + ); + + const keyIdsToCheck = payload.activeKeyIds.slice(); + for (const period of manifest.periods) { + if (period.end !== undefined && period.end < basePosition) { + continue; + } + for (const adaptation of getAdaptations(period)) { + for (const representation of adaptation.representations) { + const repKeyIds = representation.contentProtections?.keyIds; + if (repKeyIds === undefined) { + break; + } + for (let i = keyIdsToCheck.length - 1; i >= 0; i--) { + const kidToCheck = keyIdsToCheck[i]; + for (const repKid of repKeyIds) { + if (areArraysOfNumbersEqual(kidToCheck, repKid)) { + keyIdsToCheck.splice(i, 1); + } + } + } + } + } + } + + if (keyIdsToCheck.length === 0) { + // FIXME: + log.error("Init", "Too much MediaKeySession but found none to free"); + } else { + const hasFreedSession = contentDecryptor.freeKeyIds(keyIdsToCheck); + if (!hasFreedSession) { + // FIXME: + log.error("Init", "Too much MediaKeySession even after freeing some keys"); + } + } +} diff --git a/src/main_thread/init/utils/initialize_content_decryption.ts b/src/main_thread/init/utils/initialize_content_decryption.ts index 848f3a963f..3bc9473a9c 100644 --- a/src/main_thread/init/utils/initialize_content_decryption.ts +++ b/src/main_thread/init/utils/initialize_content_decryption.ts @@ -40,6 +40,10 @@ export default function initializeContentDecryption( onWarning: (err: IPlayerError) => void; onError: (err: Error) => void; onBlackListProtectionData: (val: IProcessedProtectionData) => void; + onTooMuchSessions: (arg: { + waitingKeyIds: Uint8Array[]; + activeKeyIds: Uint8Array[]; + }) => void; onKeyIdsCompatibilityUpdate: (updates: { whitelistedKeyIds: Uint8Array[]; blacklistedKeyIds: Uint8Array[]; @@ -141,6 +145,10 @@ export default function initializeContentDecryption( callbacks.onKeyIdsCompatibilityUpdate(x); }); + contentDecryptor.addEventListener("tooMuchSessions", (e) => { + callbacks.onTooMuchSessions(e); + }); + decryptorCanceller.signal.register((err) => { contentDecryptor.dispose(err.reason); });