Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
287 changes: 201 additions & 86 deletions src/main_thread/decrypt/content_decryptor.ts

Large diffs are not rendered by default.

39 changes: 27 additions & 12 deletions src/main_thread/decrypt/create_or_load_session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<ICreateOrLoadSessionResult> {
/** 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;
Expand Down Expand Up @@ -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,
Expand All @@ -95,9 +110,9 @@ export default async function createOrLoadSession(
}

const evt = await createSession(
stores,
sessionStores,
initializationData,
wantedSessionType,
sessionType,
cancelSignal,
);
return {
Expand Down
5 changes: 5 additions & 0 deletions src/main_thread/decrypt/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -64,7 +78,7 @@ async function checkNothingHappen(
limit: number,
): Promise<void> {
const mockCloseSession = vi.spyOn(loadedSessionsStore, "closeSession");
await cleanOldLoadedSessions(loadedSessionsStore, limit);
await cleanOldLoadedSessions(loadedSessionsStore, [], limit);
expect(mockCloseSession).not.toHaveBeenCalled();
mockCloseSession.mockRestore();
}
Expand All @@ -85,7 +99,7 @@ async function checkEntriesCleaned(
entries: Array<{ sessionId: string }>,
): Promise<void> {
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();
});
Expand Down
139 changes: 139 additions & 0 deletions src/main_thread/decrypt/utils/active_sessions_store.ts
Original file line number Diff line number Diff line change
@@ -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.<Object>}
*/
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;
}
38 changes: 33 additions & 5 deletions src/main_thread/decrypt/utils/clean_old_loaded_sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand All @@ -28,6 +30,7 @@ import type LoadedSessionsStore from "./loaded_sessions_store";
*/
export default async function cleanOldLoadedSessions(
loadedSessionsStore: LoadedSessionsStore,
activeRecords: KeySessionRecord[],
limit: number,
): Promise<void> {
if (limit < 0 || limit >= loadedSessionsStore.getLength()) {
Expand All @@ -38,11 +41,36 @@ export default async function cleanOldLoadedSessions(
length: loadedSessionsStore.getLength(),
});
const proms: Array<Promise<unknown>> = [];
Copy link
Copy Markdown
Collaborator

@Florent-Bouisset Florent-Bouisset Dec 4, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can be typed as Promise boolean ?

Copy link
Copy Markdown
Collaborator Author

@peaBerberian peaBerberian Dec 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe, but here the return type was not important because we never rely on it. I typed it as unknown so it is not an obstacle now or in future evolutions.

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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What the purpose of cloning here?

Copy link
Copy Markdown
Collaborator Author

@peaBerberian peaBerberian Dec 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't seem necessary here.
I often do that to either ensure that infinite loops are prevented (due to a permanently updated array) or when I want to take a snapshot of an array at a particular point in time.

Here none of that appears to be necessary, do you think I should remove the slice call?

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);
}
}
9 changes: 9 additions & 0 deletions src/main_thread/decrypt/utils/loaded_sessions_store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/main_thread/init/directfile_content_initializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down
Loading