rx-player
Version:
Canal+ HTML5 Video Player
606 lines (554 loc) • 21.9 kB
text/typescript
/**
* 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 type {
IMediaElement,
IMediaKeySystemAccess,
} from "../../compat/browser_compatibility_types";
import { canRelyOnRequestMediaKeySystemAccess } from "../../compat/can_rely_on_request_media_key_system_access";
import type { IEmeApiImplementation } from "../../compat/eme";
import {
generatePlayReadyInitData,
DUMMY_PLAY_READY_HEADER,
} from "../../compat/generate_init_data";
import shouldRenewMediaKeySystemAccess from "../../compat/should_renew_media_key_system_access";
import config from "../../config";
import { EncryptedMediaError } from "../../errors";
import log from "../../log";
import type { IKeySystemOption } from "../../public_types";
import { parseCodec } from "../../utils/are_codecs_compatible";
import arrayIncludes from "../../utils/array_includes";
import flatMap from "../../utils/flat_map";
import isNullOrUndefined from "../../utils/is_null_or_undefined";
import type { CancellationSignal } from "../../utils/task_canceller";
import MediaKeysAttacher from "./utils/media_keys_attacher";
type MediaKeysRequirement = "optional" | "required" | "not-allowed";
export type ICodecSupportList = Array<{
codec: string;
mimeType: string;
result: boolean;
}>;
export interface IMediaKeySystemAccessInfos {
/** `MediaKeySystemAccess` to use to create `MediaKeys` instances. */
mediaKeySystemAccess: IMediaKeySystemAccess;
/**
* The MediaKeySystemConfiguration that has been provided to the
* `requestMediaKeySystemAccess` API.
*/
askedConfiguration: MediaKeySystemConfiguration;
/**
* Corresponding `keySystems` element that has led to the creation of the
* `MediaKeySystemAccess`.
*/
options: IKeySystemOption;
/** Information on supported or unsupported codec on that `MediaKeySystemAccess`. */
codecSupport: ICodecSupportList;
}
export interface IReuseMediaKeySystemAccessEvent {
type: "reuse-media-key-system-access";
value: IMediaKeySystemAccessInfos;
}
export interface ICreateMediaKeySystemAccessEvent {
type: "create-media-key-system-access";
value: IMediaKeySystemAccessInfos;
}
export type IFoundMediaKeySystemAccessEvent =
| IReuseMediaKeySystemAccessEvent
| ICreateMediaKeySystemAccessEvent;
interface IKeySystemType {
/**
* Generic name for the key system. e.g. "clearkey", "widevine", "playready".
* Can be used to make exceptions depending on it.
* `undefined` if unknown or if this concept does not exist for this key
* system.
*/
keyName: string | undefined;
/** KeySystem type (e.g. "com.widevine.alpha") */
keyType: string;
/** The original keySystem object */
keySystemOptions: IKeySystemOption;
}
/**
* Takes a `newConfiguration` `MediaKeySystemConfiguration`, that is intended
* for the creation of a `MediaKeySystemAccess`, and a `prevConfiguration`
* `MediaKeySystemConfiguration`, that was the one used at creation of the
* current `MediaKeySystemAccess`.
*
* This function will then return `true` if it determined that the new
* configuration is conceptually compatible with the one used before, and
* `false` otherwise.
* @param {Object} newConfiguration - New wanted `MediaKeySystemConfiguration`
* @param {Object} prevConfiguration - The `MediaKeySystemConfiguration` that is
* relied on util now.
* @returns {boolean} - `true` if `newConfiguration` is compatible with
* `prevConfiguration`.
*/
function isNewMediaKeySystemConfigurationCompatibleWithPreviousOne(
newConfiguration: MediaKeySystemConfiguration,
prevConfiguration: MediaKeySystemConfiguration,
): boolean {
if (newConfiguration.label !== prevConfiguration.label) {
return false;
}
const prevDistinctiveIdentifier = prevConfiguration.distinctiveIdentifier ?? "optional";
const newDistinctiveIdentifier = newConfiguration.distinctiveIdentifier ?? "optional";
if (prevDistinctiveIdentifier !== newDistinctiveIdentifier) {
return false;
}
const prevPersistentState = prevConfiguration.persistentState ?? "optional";
const newPersistentState = newConfiguration.persistentState ?? "optional";
if (prevPersistentState !== newPersistentState) {
return false;
}
const prevInitDataTypes = prevConfiguration.initDataTypes ?? [];
const newInitDataTypes = newConfiguration.initDataTypes ?? [];
if (!isArraySubsetOf(newInitDataTypes, prevInitDataTypes)) {
return false;
}
const prevSessionTypes = prevConfiguration.sessionTypes ?? [];
const newSessionTypes = newConfiguration.sessionTypes ?? [];
if (!isArraySubsetOf(newSessionTypes, prevSessionTypes)) {
return false;
}
for (const prop of ["audioCapabilities", "videoCapabilities"] as const) {
const newCapabilities = newConfiguration[prop] ?? [];
const prevCapabilities = prevConfiguration[prop] ?? [];
const wasFound = newCapabilities.every((n) => {
for (let i = 0; i < prevCapabilities.length; i++) {
const prevCap = prevCapabilities[i];
if (
(prevCap.robustness ?? "") === (n.robustness ?? "") ||
(prevCap.encryptionScheme ?? null) === (n.encryptionScheme ?? null) ||
(prevCap.robustness ?? "") === (n.robustness ?? "")
) {
return true;
}
}
return false;
});
if (!wasFound) {
return false;
}
}
return true;
}
/**
* Find key system canonical name from key system type.
* @param {string} ksType - Obtained via inversion
* @returns {string|undefined} - Either the canonical name, or undefined.
*/
function findKeySystemCanonicalName(ksType: string): string | undefined {
const { EME_KEY_SYSTEMS } = config.getCurrent();
for (const ksName of Object.keys(EME_KEY_SYSTEMS)) {
if (arrayIncludes(EME_KEY_SYSTEMS[ksName] as string[], ksType)) {
return ksName;
}
}
return undefined;
}
/**
* Build configuration for the requestMediaKeySystemAccess EME API, based
* on the current keySystem object.
* @param {Object} keySystemTypeInfo
* @returns {Array.<Object>} - Configuration to give to the
* requestMediaKeySystemAccess API.
*/
function buildKeySystemConfigurations(
keySystemTypeInfo: IKeySystemType,
): MediaKeySystemConfiguration[] {
const { keyName, keyType, keySystemOptions: keySystem } = keySystemTypeInfo;
let sessionTypes: string[];
let persistentState: MediaKeysRequirement = "optional";
let distinctiveIdentifier: MediaKeysRequirement = "optional";
if (Array.isArray(keySystem.wantedSessionTypes)) {
sessionTypes = keySystem.wantedSessionTypes;
if (
arrayIncludes(keySystem.wantedSessionTypes, "persistent-license") &&
!isNullOrUndefined(keySystem.persistentLicenseConfig)
) {
persistentState = "required";
}
} else if (!isNullOrUndefined(keySystem.persistentLicenseConfig)) {
persistentState = "required";
sessionTypes = ["persistent-license"];
} else {
sessionTypes = ["temporary"];
}
if (!isNullOrUndefined(keySystem.persistentState)) {
persistentState = keySystem.persistentState;
}
if (!isNullOrUndefined(keySystem.distinctiveIdentifier)) {
distinctiveIdentifier = keySystem.distinctiveIdentifier;
}
const {
EME_DEFAULT_AUDIO_CODECS,
EME_DEFAULT_VIDEO_CODECS,
EME_DEFAULT_WIDEVINE_ROBUSTNESSES,
EME_DEFAULT_PLAYREADY_RECOMMENDATION_ROBUSTNESSES,
} = config.getCurrent();
// From the W3 EME spec, we have to provide videoCapabilities and
// audioCapabilities.
// These capabilities must specify a codec (even though you can use a
// completely different codec afterward).
// It is also strongly recommended to specify the required security
// robustness. As we do not want to forbide any security level, we specify
// every existing security level from highest to lowest so that the best
// security level is selected.
// More details here:
// https://storage.googleapis.com/wvdocs/Chrome_EME_Changes_and_Best_Practices.pdf
// https://www.w3.org/TR/encrypted-media/#get-supported-configuration-and-consent
let audioCapabilities: MediaKeySystemMediaCapability[];
let videoCapabilities: MediaKeySystemMediaCapability[];
const { audioCapabilitiesConfig, videoCapabilitiesConfig } = keySystem;
if (audioCapabilitiesConfig?.type === "full") {
audioCapabilities = audioCapabilitiesConfig.value;
} else {
let audioRobustnesses: Array<string | undefined>;
if (audioCapabilitiesConfig?.type === "robustness") {
audioRobustnesses = audioCapabilitiesConfig.value;
} else if (keyName === "widevine") {
audioRobustnesses = EME_DEFAULT_WIDEVINE_ROBUSTNESSES;
} else if (keyType === "com.microsoft.playready.recommendation") {
audioRobustnesses = EME_DEFAULT_PLAYREADY_RECOMMENDATION_ROBUSTNESSES;
} else {
audioRobustnesses = [];
}
if (audioRobustnesses.length === 0) {
audioRobustnesses.push(undefined);
}
const audioCodecs =
audioCapabilitiesConfig?.type === "contentType"
? audioCapabilitiesConfig.value
: EME_DEFAULT_AUDIO_CODECS;
audioCapabilities = flatMap(audioRobustnesses, (robustness) =>
audioCodecs.map((contentType) => {
return robustness !== undefined ? { contentType, robustness } : { contentType };
}),
);
}
if (videoCapabilitiesConfig?.type === "full") {
videoCapabilities = videoCapabilitiesConfig.value;
} else {
let videoRobustnesses: Array<string | undefined>;
if (videoCapabilitiesConfig?.type === "robustness") {
videoRobustnesses = videoCapabilitiesConfig.value;
} else if (keyName === "widevine") {
videoRobustnesses = EME_DEFAULT_WIDEVINE_ROBUSTNESSES;
} else if (keyType === "com.microsoft.playready.recommendation") {
videoRobustnesses = EME_DEFAULT_PLAYREADY_RECOMMENDATION_ROBUSTNESSES;
} else {
videoRobustnesses = [];
}
if (videoRobustnesses.length === 0) {
videoRobustnesses.push(undefined);
}
const videoCodecs =
videoCapabilitiesConfig?.type === "contentType"
? videoCapabilitiesConfig.value
: EME_DEFAULT_VIDEO_CODECS;
videoCapabilities = flatMap(videoRobustnesses, (robustness) =>
videoCodecs.map((contentType) => {
return robustness !== undefined ? { contentType, robustness } : { contentType };
}),
);
}
const wantedMediaKeySystemConfiguration: MediaKeySystemConfiguration = {
initDataTypes: ["cenc"],
videoCapabilities,
audioCapabilities,
distinctiveIdentifier,
persistentState,
sessionTypes,
};
if (audioCapabilitiesConfig !== undefined) {
if (videoCapabilitiesConfig !== undefined) {
return [wantedMediaKeySystemConfiguration];
}
return [
wantedMediaKeySystemConfiguration,
{
...wantedMediaKeySystemConfiguration,
// Re-try without `videoCapabilities` in case the EME implementation is
// buggy
videoCapabilities: undefined,
} as unknown as MediaKeySystemConfiguration,
];
} else if (videoCapabilitiesConfig !== undefined) {
return [
wantedMediaKeySystemConfiguration,
{
...wantedMediaKeySystemConfiguration,
// Re-try without `audioCapabilities` in case the EME implementation is
// buggy
audioCapabilities: undefined,
} as unknown as MediaKeySystemConfiguration,
];
}
return [
wantedMediaKeySystemConfiguration,
// Some legacy implementations have issues with `audioCapabilities` and
// `videoCapabilities`, so we're including a supplementary
// `MediaKeySystemConfiguration` without those properties.
{
...wantedMediaKeySystemConfiguration,
audioCapabilities: undefined,
videoCapabilities: undefined,
} as unknown as MediaKeySystemConfiguration,
];
}
/**
* Extract from the current mediaKeys the supported Codecs.
* @param {Object} initialConfiguration - The MediaKeySystemConfiguration given
* to the `navigator.requestMediaKeySystemAccess` API.
* @param {Object | undefined} mksConfiguration - The result of
* getConfiguration() of the media keys.
* @return {Array} The list of supported codec by the CDM.
*/
export function extractCodecSupportListFromConfiguration(
initialConfiguration: MediaKeySystemConfiguration,
mksConfiguration: MediaKeySystemConfiguration,
): ICodecSupportList {
const testedAudioCodecs =
initialConfiguration.audioCapabilities?.map((v) => v.contentType) ?? [];
const testedVideoCodecs =
initialConfiguration.videoCapabilities?.map((v) => v.contentType) ?? [];
const testedCodecs: string[] = testedAudioCodecs
.concat(testedVideoCodecs)
.filter((c): c is string => c !== undefined);
const supportedVideoCodecs = mksConfiguration.videoCapabilities?.map(
(entry) => entry.contentType,
);
const supportedAudioCodecs = mksConfiguration.audioCapabilities?.map(
(entry) => entry.contentType,
);
const supportedCodecs = [
...(supportedVideoCodecs ?? []),
...(supportedAudioCodecs ?? []),
].filter((contentType): contentType is string => contentType !== undefined);
if (supportedCodecs.length === 0) {
// Some legacy implementations have issues with `audioCapabilities` and
// `videoCapabilities` in requestMediaKeySystemAccess so the codecs are not provided.
// In this case, we can't tell which codec is supported or not.
// Let's instead provide an empty list.
// Note: on a correct EME implementation, if a list of codec is provided
// with `audioCapabilities` or `videoCapabilities`, but none of them is supported,
// requestMediaKeySystemAccess should yield an error "NotSupported" and we should
// never reach this code.
return [];
}
const codecSupportList: ICodecSupportList = testedCodecs.map((codec) => {
const { codecs, mimeType } = parseCodec(codec);
const isSupported = arrayIncludes(supportedCodecs, codec);
return {
codec: codecs,
mimeType,
result: isSupported,
};
});
return codecSupportList;
}
/**
* Try to find a compatible key system from the keySystems array given.
*
* This function will request a MediaKeySystemAccess based on the various
* keySystems provided.
*
* This Promise might either:
* - resolves the MediaKeySystemAccess and the keySystems as an object, when
* found.
* - reject if no compatible key system has been found.
*
* @param {Object} eme - current EME implementation
* @param {HTMLMediaElement} mediaElement
* @param {Array.<Object>} keySystemsConfigs - The keySystems you want to test.
* @param {Object} cancelSignal
* @returns {Promise.<Object>}
*/
export default function getMediaKeySystemAccess(
eme: IEmeApiImplementation,
mediaElement: IMediaElement,
keySystemsConfigs: IKeySystemOption[],
cancelSignal: CancellationSignal,
): Promise<IFoundMediaKeySystemAccessEvent> {
log.info("DRM", "Searching for compatible MediaKeySystemAccess");
/** Array of set keySystems for this content. */
const keySystemsType: IKeySystemType[] = keySystemsConfigs.reduce(
(arr: IKeySystemType[], keySystemOptions) => {
const { EME_KEY_SYSTEMS } = config.getCurrent();
const managedRDNs = EME_KEY_SYSTEMS[keySystemOptions.type];
let ksType;
if (!isNullOrUndefined(managedRDNs)) {
ksType = managedRDNs.map((keyType) => {
const keyName = keySystemOptions.type;
return { keyName, keyType, keySystemOptions };
});
} else {
const keyName = findKeySystemCanonicalName(keySystemOptions.type);
const keyType = keySystemOptions.type;
ksType = [{ keyName, keyType, keySystemOptions }];
}
return arr.concat(ksType);
},
[],
);
return recursivelyTestKeySystems(0);
/**
* Test all key system configuration stored in `keySystemsType` one by one
* recursively.
* Returns a Promise which will emit the MediaKeySystemAccess if one was
* found compatible with one of the configurations or just reject if none
* were found to be compatible.
* @param {Number} index - The index in `keySystemsType` to start from.
* Should be set to `0` when calling directly.
* @returns {Promise.<Object>}
*/
async function recursivelyTestKeySystems(
index: number,
): Promise<IFoundMediaKeySystemAccessEvent> {
// if we iterated over the whole keySystemsType Array, quit on error
if (index >= keySystemsType.length) {
throw new EncryptedMediaError(
"INCOMPATIBLE_KEYSYSTEMS",
"No key system compatible with your wanted " +
"configuration has been found in the current " +
"browser.",
{
keyStatuses: undefined,
keySystemConfiguration: undefined,
keySystem: undefined,
},
);
}
if (isNullOrUndefined(eme.requestMediaKeySystemAccess)) {
throw new Error("requestMediaKeySystemAccess is not implemented in your browser.");
}
const chosenType = keySystemsType[index];
const { keyType, keySystemOptions } = chosenType;
const keySystemConfigurations = buildKeySystemConfigurations(chosenType);
log.debug(`DRM`, `Request keysystem access`, {
keyType,
index,
length: keySystemsType.length,
});
let keySystemAccess;
const currentState = await MediaKeysAttacher.getAttachedMediaKeysState(mediaElement);
for (let configIdx = 0; configIdx < keySystemConfigurations.length; configIdx++) {
const keySystemConfiguration = keySystemConfigurations[configIdx];
// Check if the current `MediaKeySystemAccess` created cannot be reused here
if (
currentState !== null &&
!shouldRenewMediaKeySystemAccess(currentState.mediaKeySystemAccess.keySystem) &&
// TODO: Do it with MediaKeySystemAccess.prototype.keySystem instead?
keyType === currentState.mediaKeySystemAccess.keySystem &&
eme.implementation === currentState.emeImplementation.implementation &&
isNewMediaKeySystemConfigurationCompatibleWithPreviousOne(
keySystemConfiguration,
currentState.askedConfiguration,
)
) {
log.info("DRM", "Found cached compatible keySystem");
return Promise.resolve({
type: "reuse-media-key-system-access" as const,
value: {
mediaKeySystemAccess: currentState.mediaKeySystemAccess,
askedConfiguration: currentState.askedConfiguration,
options: keySystemOptions,
codecSupport: extractCodecSupportListFromConfiguration(
currentState.askedConfiguration,
currentState.mediaKeySystemAccess.getConfiguration(),
),
},
});
}
try {
keySystemAccess = await testKeySystem(eme, keyType, [keySystemConfiguration]);
log.info("DRM", "Found compatible keysystem", {
keyType,
index,
configIndex: configIdx,
});
return {
type: "create-media-key-system-access" as const,
value: {
options: keySystemOptions,
mediaKeySystemAccess: keySystemAccess,
askedConfiguration: keySystemConfiguration,
codecSupport: extractCodecSupportListFromConfiguration(
keySystemConfiguration,
keySystemAccess.getConfiguration(),
),
},
};
} catch (_) {
log.debug("DRM", "Rejected access to keysystem", {
keyType,
index,
configIndex: configIdx,
});
if (cancelSignal.cancellationError !== null) {
throw cancelSignal.cancellationError;
}
}
}
return recursivelyTestKeySystems(index + 1);
}
}
/**
* Test a key system configuration, resolves with the MediaKeySystemAccess
* or reject if the key system is unsupported.
* @param {Object} eme - current EME implementation
* @param {string} keyType - The KeySystem string to test (ex: com.microsoft.playready.recommendation)
* @param {Array.<MediaKeySystemMediaCapability>} keySystemConfigurations - Configurations for this keySystem
* @returns Promise resolving with the MediaKeySystemAccess. Rejects if unsupported.
*/
export async function testKeySystem(
eme: IEmeApiImplementation,
keyType: string,
keySystemConfigurations: MediaKeySystemConfiguration[],
): Promise<IMediaKeySystemAccess> {
const keySystemAccess = await eme.requestMediaKeySystemAccess(
keyType,
keySystemConfigurations,
);
if (!canRelyOnRequestMediaKeySystemAccess(keyType)) {
try {
const mediaKeys = await keySystemAccess.createMediaKeys();
const session = mediaKeys.createSession();
const initData = generatePlayReadyInitData(DUMMY_PLAY_READY_HEADER);
await session.generateRequest("cenc", initData);
session.close().catch(() => {
log.warn("DRM", "Failed to close the dummy session");
});
} catch (err) {
log.debug("DRM", "KeySystemAccess was granted but it is not usable");
throw err;
}
}
return keySystemAccess;
}
/**
* Returns `true` if `arr1`'s values are entirely contained in `arr2`.
* @param {string} arr1
* @param {string} arr2
* @return {boolean}
*/
function isArraySubsetOf(arr1: string[], arr2: string[]): boolean {
for (let i = 0; i < arr1.length; i++) {
if (!arrayIncludes(arr2, arr1[i])) {
return false;
}
}
return true;
}