rx-player
Version:
Canal+ HTML5 Video Player
1,059 lines (964 loc) • 31.9 kB
text/typescript
import type {
IMediaKeySession,
IMediaKeySystemAccess,
IMediaKeys,
} from "../../../compat/browser_compatibility_types";
import EnvDetector from "../../../compat/env_detector";
import { getBoxOffsets } from "../../../parsers/containers/isobmff";
import arrayIncludes from "../../../utils/array_includes";
import assert from "../../../utils/assert";
import { base64ToBytes, bytesToBase64 } from "../../../utils/base64";
import { be4toi, le2toi, toUint8Array } from "../../../utils/byte_parsing";
import createUuid from "../../../utils/create_uuid";
import EventEmitter from "../../../utils/event_emitter";
import isNullOrUndefined from "../../../utils/is_null_or_undefined";
import noop from "../../../utils/noop";
import type { IReadOnlySharedReference } from "../../../utils/reference";
import SharedReference from "../../../utils/reference";
import sliceUint8array from "../../../utils/slice_uint8array";
import {
bytesToHex,
guidToUuid,
hexToBytes,
strToUtf8,
utf16LEToStr,
utf8ToStr,
} from "../../../utils/string_parsing";
export interface IRequestMediaKeySystemAccessConfig {
/**
* For the given arguments of the `navigator.requestMediaKeySystemAccess`
* API, returns the resulting `MediaKeySystemConfiguration`, or `null` if
* that configuration should be anounced as not supported.
*
* If not set, the first configuration will be anounced as supported for a
* supported key system.
*
* @param {string} keySystem
* @param {Array.<Object>} configs
* @returns {Object} config
*/
getMediaKeySystemConfiguration?:
| ((
keySystem: string,
configs: MediaKeySystemConfiguration[],
) => MediaKeySystemConfiguration | null)
| undefined;
/**
* For the given key system, returns `true` if it should be anounced as
* supported or `false` if it should be anounced as unsupported.
*
* If not set, all keySystems are supported.
* @param {string} keySystem
* @returns {boolean}
*/
isKeySystemSupported: ((keySystem: string) => boolean) | undefined;
}
/**
* Return a configured re-implementation of the EME
* `navigator.requestMediaKeySystemAccess` API.
* @param {Object|undefined} [config]
* @returns {Function}
*/
export function createRequestMediaKeySystemAccess(
config?: IRequestMediaKeySystemAccessConfig | undefined,
) {
/**
* Re-implementation of the EME `navigator.requestMediaKeySystemAccess` API.
* @param {string} keySystem
* @param {Array.<Object>} supportedConfigurations
* @returns {Promise.<Object>}
*/
return function requestMediaKeySystemAccess(
keySystem: string,
supportedConfigurations: MediaKeySystemConfiguration[],
): Promise<DummyMediaKeySystemAccess> {
if (keySystem === "") {
return Promise.reject(
new TypeError("`requestMediaKeySystemAccess` error: empty string"),
);
}
if (supportedConfigurations.length === 0) {
return Promise.reject(
new TypeError("`requestMediaKeySystemAccess` error: no given configuration."),
);
}
if (
typeof config?.isKeySystemSupported === "function" &&
!config.isKeySystemSupported(keySystem)
) {
const error = new Error(`"${keySystem}" is not a supported keySystem`);
error.name = "NotSupportedError";
return Promise.reject(error);
}
let supportedConfiguration: MediaKeySystemConfiguration | null =
supportedConfigurations[0] ?? null;
if (typeof config?.getMediaKeySystemConfiguration === "function") {
supportedConfiguration = config.getMediaKeySystemConfiguration(
keySystem,
supportedConfigurations,
);
}
if (supportedConfiguration === null) {
const error = new Error(
"`requestMediaKeySystemAccess` error: No configuration supported.",
);
error.name = "NotSupportedError";
return Promise.reject(error);
}
// check some mandatory configuration state
// Clone so following setter don't update the source object
supportedConfiguration = { ...supportedConfiguration };
supportedConfiguration.persistentState =
isNullOrUndefined(supportedConfiguration.persistentState) ||
supportedConfiguration.persistentState === "optional"
? "not-allowed"
: supportedConfiguration.persistentState;
supportedConfiguration.distinctiveIdentifier =
isNullOrUndefined(supportedConfiguration.distinctiveIdentifier) ||
supportedConfiguration.distinctiveIdentifier === "optional"
? "not-allowed"
: supportedConfiguration.distinctiveIdentifier;
return Promise.resolve(
new DummyMediaKeySystemAccess(keySystem, supportedConfiguration),
);
};
}
/**
* Re-implementation of the EME `MediaKeySystemAccess` Object.
* @class DummyMediaKeySystemAccess
*/
export class DummyMediaKeySystemAccess implements IMediaKeySystemAccess {
public readonly keySystem: string;
private _configuration: MediaKeySystemConfiguration;
/**
* @param {string} keySystem
* @param {Object} configuration
*/
constructor(keySystem: string, configuration: MediaKeySystemConfiguration) {
this.keySystem = keySystem;
this._configuration = configuration;
}
/**
* @returns {Object}
*/
public getConfiguration(): MediaKeySystemConfiguration {
return this._configuration;
}
/**
* @returns {Promise.<Object>}
*/
public createMediaKeys(): Promise<DummyMediaKeys> {
// TODO persistent-license
return Promise.resolve(
new DummyMediaKeys(this.keySystem, ["temporary" /* , "persistent-license" */]),
);
}
}
/**
* Re-implementation of the EME `MediaKeys` Object.
* @class DummyMediaKeys
*/
export class DummyMediaKeys implements IMediaKeys {
private _keySystem: string;
private _sessionTypes: MediaKeySessionType[];
private _serverCertificateRef: SharedReference<Uint8Array | null>;
public dummySessions: DummyMediaKeySession[];
public onDummySessionKeyUpdates: (() => void) | null;
constructor(keySystem: string, sessionTypes: MediaKeySessionType[]) {
this._keySystem = keySystem;
this._sessionTypes = sessionTypes;
this._serverCertificateRef = new SharedReference<Uint8Array | null>(null);
this.dummySessions = [];
this.onDummySessionKeyUpdates = null;
}
/**
* @param {string} sessionType
* @returns {Object}
*/
public createSession(
sessionType: MediaKeySessionType = "temporary",
): DummyMediaKeySession {
if (!arrayIncludes(this._sessionTypes, sessionType)) {
const error = new Error(
`\`createSession\`: ${sessionType} sessionType not supported`,
);
error.name = "NotSupportedError";
throw error;
}
// eslint-disable-next-line @typescript-eslint/no-this-alias
const self = this;
const newSession = new DummyMediaKeySession({
keySystem: this._keySystem,
sessionType,
serverCertificateRef: this._serverCertificateRef,
callbacks: {
onClosed() {
const index = self.dummySessions.indexOf(newSession);
if (index >= 0) {
self.dummySessions.splice(index, 1);
}
},
onKeysUpdate() {
self.onDummySessionKeyUpdates?.();
},
},
});
this.dummySessions.push(newSession);
return newSession;
}
/**
* @param {BufferSource} serverCertificate
* @returns {Promise.<boolean>}
*/
setServerCertificate(serverCertificate: BufferSource): Promise<boolean> {
if (serverCertificate.byteLength === 0) {
throw new TypeError(
"Cannot set `serverCertificate`: an empty certificate was given",
);
}
const clonedServerCertificate = toUint8Array(serverCertificate).slice();
this._serverCertificateRef.setValue(clonedServerCertificate);
return Promise.resolve(true);
}
}
interface IDrmChallenge {
certificate: string | null;
persistent: boolean;
keyIds: string[];
}
interface IDrmLicense {
type: "license";
persistent: boolean;
expiration?: number;
keys: Record<
/** Key id, as lower case hex values */
string,
{
/**
* "Restriction level" for this key id, a higher number meaning more
* restrictive.
*/
policyLevel: number;
}
>;
}
/**
* Re-implementation of the EME `MediaKeySession` Object.
* @class DummyMediaKeySession
*/
export class DummyMediaKeySession
extends EventEmitter<MediaKeySessionEventMap>
implements IMediaKeySession
{
public readonly closed: Promise<MediaKeySessionClosedReason>;
public readonly keyStatuses: DummyMediaKeyStatusMap;
public expiration: number;
public sessionId: string;
private _sessionType: MediaKeySessionType;
private _unitialized: boolean;
private _callable: boolean;
private _closing: boolean;
private _closed: boolean;
private _keySystem: string;
private _onSessionClosed: () => void;
private _serverCertificateRef: IReadOnlySharedReference<Uint8Array | null>;
private _callbacks: IDummyMediaKeySessionCallbacks;
private _currentPolicyLevel: number;
/**
* @param {Object} args
*/
constructor({
keySystem,
sessionType,
serverCertificateRef,
callbacks,
}: {
keySystem: string;
sessionType: MediaKeySessionType;
serverCertificateRef: IReadOnlySharedReference<Uint8Array | null>;
callbacks: IDummyMediaKeySessionCallbacks;
}) {
super();
this._callbacks = callbacks;
this._onSessionClosed = noop; // Just here to make TypeScript happy
this.closed = new Promise((resolve) => {
this._onSessionClosed = () => {
this.removeEventListener();
try {
this._callbacks.onClosed();
} catch {
// we don't care
}
resolve("closed-by-application");
};
});
this._currentPolicyLevel = 100;
this.expiration = NaN;
this.keyStatuses = new DummyMediaKeyStatusMap(keySystem);
this.sessionId = "";
this._serverCertificateRef = serverCertificateRef;
this._keySystem = keySystem;
this._sessionType = sessionType;
this._unitialized = true;
this._callable = false;
this._closing = false;
this._closed = false;
}
/**
* @returns {Promise}
*/
public close(): Promise<void> {
// 1. If this object's closing or closed value is true, return a resolved promise.
if (this._closing || this._closed) {
return Promise.resolve();
}
// 2. If this object's callable value is false, return a promise rejected with an InvalidStateError.
if (!this._callable) {
const err = new Error("Cannot call `close` at this time");
err.name = "InvalidStateError";
return Promise.reject(err);
}
// 4. Set this object's closing or closed value to true.
this._closing = true;
this.keyStatuses.clear();
return new Promise((resolve) => {
setTimeout(() => {
try {
this._callbacks.onKeysUpdate();
} catch {
// We don't care
}
try {
this.trigger("keystatuseschange", new Event("keystatuseschange"));
} catch {
// We don't care
}
this.expiration = NaN;
this._closed = true;
this._onSessionClosed();
resolve();
}, 0);
});
}
/**
* @param {string} initDataType
* @param {BufferSource} initData
* @returns {Promise}
*/
public generateRequest(initDataType: string, initData: BufferSource): Promise<void> {
// 1. If this object's closing or closed value is true, return a promise
// rejected with an InvalidStateError.
if (this._closing || this._closed) {
const err = new Error("Cannot call `generateRequest`: closing session");
err.name = "InvalidStateError";
return Promise.reject(err);
}
// 2. If this object's uninitialized value is false, return a promise
// rejected with an InvalidStateError.
if (!this._unitialized) {
const err = new Error("Cannot call `generateRequest`: already initialized");
err.name = "InvalidStateError";
return Promise.reject(err);
}
// 3. Let this object's uninitialized value be false.
this._unitialized = false;
// 4. If initDataType is the empty string, return a promise rejected with a
// newly created TypeError.
if (typeof initDataType !== "string" || initDataType === "") {
return Promise.reject(
new TypeError("Invalid `generateRequest` call: empty initDataType"),
);
}
// 5. If initData is an empty array, return a promise rejected with a newly
// created TypeError.
let initDataU8;
if (initData instanceof ArrayBuffer) {
initDataU8 = new Uint8Array(initData);
} else if (initData instanceof Uint8Array) {
initDataU8 = initData;
} else {
initDataU8 = new Uint8Array(initData.buffer);
}
if (initDataU8.byteLength === 0) {
return Promise.reject(
new TypeError("Invalid `generateRequest` call: empty initData"),
);
}
// 6. If the Key System implementation represented by this object's cdm
// implementation value does not support initDataType as an Initialization
// Data Type, return a promise rejected with a NotSupportedError. String
// comparison is case-sensitive.
if (initDataType !== "cenc") {
const err = new Error(
`Cannot call \`generateRequest\`: unsupported initDataType "${initDataType}"`,
);
err.name = "NotSupportedError";
return Promise.reject(err);
}
// 7. Let init data be a copy of the contents of the initData parameter.
const clonedInitData = initDataU8.slice();
const psshs = splitPsshBoxes(clonedInitData);
let keyIds: Uint8Array[] | null = null;
for (const pssh of psshs) {
try {
const psshInfo = getKeyIdsFromPssh(pssh, 0);
if (psshInfo !== null) {
switch (psshInfo.systemId) {
case undefined:
if (psshInfo.kids.length === 0) {
keyIds = psshInfo.kids;
}
break;
case "PlayReady":
if (this._keySystem.indexOf("playready") >= 0) {
keyIds = psshInfo.kids;
}
break;
case "Widevine":
if (this._keySystem.indexOf("widevine") >= 0) {
keyIds = psshInfo.kids;
}
break;
case "Nagra":
if (this._keySystem.indexOf("nagra") >= 0) {
keyIds = psshInfo.kids;
}
break;
}
}
} catch {
/* noop */
}
}
if (keyIds === null || keyIds.length === 0) {
throw new TypeError("No key id found in initialization data");
}
const kids = keyIds.map((k) => bytesToHex(k));
this.sessionId = createUuid();
this._callable = true;
setTimeout(() => {
const certificateBase = this._serverCertificateRef.getValue();
const certificate =
certificateBase === null ? null : bytesToBase64(certificateBase);
const message: IDrmChallenge = {
certificate,
persistent: this._sessionType === "persistent-license",
keyIds: kids,
};
this.trigger(
"message",
new MediaKeyMessageEvent("message", {
messageType: "license-request",
message: strToUtf8(JSON.stringify(message)).buffer,
}),
);
}, 0);
return Promise.resolve();
}
/**
* @param {string} sessionId
* @returns {Promise.<boolean>}
*/
public load(sessionId: string): Promise<boolean> {
// 1. If this object's closing or closed value is true, return a promise
// rejected with an InvalidStateError.
if (this._closing || this._closed) {
const err = new Error("Cannot call `load`: closing session");
err.name = "InvalidStateError";
return Promise.reject(err);
}
// 2. If this object's uninitialized value is false, return a promise
// rejected with an InvalidStateError.
if (!this._unitialized) {
const err = new Error("Cannot call `load`: already initialized");
err.name = "InvalidStateError";
return Promise.reject(err);
}
// 3. Let this object's uninitialized value be false.
this._unitialized = false;
// 4. If sessionId is the empty string, return a promise rejected with a
// newly created TypeError.
if (typeof sessionId !== "string" || sessionId === "") {
return Promise.reject(new TypeError("Invalid `load` call: empty sessionId"));
}
// TODO persistent license
return Promise.reject(new TypeError("Persistent license not implemented yet."));
}
/**
* @returns {Promise}
*/
public remove(): Promise<void> {
// 1. If this object's closing or closed value is true, return a promise
// rejected with an InvalidStateError.
if (this._closing || this._closed) {
const err = new Error("Cannot call `remove`: closing session");
err.name = "InvalidStateError";
return Promise.reject(err);
}
// 2. If this object's callable value is false, return a promise rejected
// with an InvalidStateError.
if (!this._callable) {
const err = new Error("Cannot call `remove` at this time");
err.name = "InvalidStateError";
return Promise.reject(err);
}
// Run the Update Key Statuses algorithm on the session, providing all key
// ID(s) in the session along with the "released" MediaKeyStatus value for
// each.
const keymap = this.keyStatuses.getInnerMap();
keymap.forEach(({ policyLevel }, key) => {
keymap.set(key, { status: "released", policyLevel });
});
if (this.keyStatuses.size > 0) {
try {
this._callbacks.onKeysUpdate();
} catch {
// We don't care
}
setTimeout(() => {
this.trigger("keystatuseschange", new Event("keystatuseschange"));
}, 0);
}
this.expiration = NaN;
return new Promise((resolve) => {
resolve();
});
}
/**
* @param {BufferSource} response
* @returns {Promise}
*/
public update(response: BufferSource): Promise<void> {
// 1. If this object's closing or closed value is true, return a promise
// rejected with an InvalidStateError.
if (this._closing || this._closed) {
const err = new Error("Cannot call `update`: closing session");
err.name = "InvalidStateError";
return Promise.reject(err);
}
// 2. If this object's callable value is false, return a promise
// rejected with an InvalidStateError.
if (!this._callable) {
const err = new Error("Cannot call `update` at this time");
err.name = "InvalidStateError";
return Promise.reject(err);
}
// 3. If response is an empty array, return a promise rejected with a
// newly created TypeError.
if (response.byteLength === 0) {
return Promise.reject(new TypeError("Invalid `update` call: empty response"));
}
const responseU8 = toUint8Array(response);
const parsed = utf8ToStr(responseU8);
let hasUpdatedKeys = false;
try {
const parsedObj = JSON.parse(parsed) as IDrmLicense;
for (const key of Object.keys(parsedObj.keys)) {
const { policyLevel } = parsedObj.keys[key];
if (policyLevel > this._currentPolicyLevel) {
this.keyStatuses.set(key, "output-restricted", policyLevel);
} else {
this.keyStatuses.set(key, "usable", policyLevel);
}
hasUpdatedKeys = true;
}
if (parsedObj.expiration !== undefined) {
this.expiration = parsedObj.expiration;
}
} catch (err) {
throw new TypeError(err instanceof Error ? err.message : "Invalid message");
}
if (hasUpdatedKeys) {
setTimeout(() => {
try {
this._callbacks.onKeysUpdate();
} catch {
// We don't care
}
this.trigger("keystatuseschange", new Event("keystatuseschange"));
}, 0);
}
return Promise.resolve();
}
public getPolicyLevel(): number {
return this._currentPolicyLevel;
}
public updatePolicyLevel(newLevel: number) {
this._currentPolicyLevel = newLevel;
let hasUpdatedKeys = false;
try {
const keymap = this.keyStatuses.getInnerMap();
keymap.forEach(({ status, policyLevel }, key) => {
if (policyLevel > newLevel) {
if (status !== "output-restricted") {
keymap.set(key, { status: "output-restricted", policyLevel });
hasUpdatedKeys = true;
}
} else if (status === "output-restricted") {
keymap.set(key, { status: "usable", policyLevel });
hasUpdatedKeys = true;
}
});
} catch {
// we don't care
}
if (hasUpdatedKeys) {
setTimeout(() => {
try {
this._callbacks.onKeysUpdate();
} catch {
// We don't care
}
this.trigger("keystatuseschange", new Event("keystatuseschange"));
}, 0);
}
}
}
interface IDummyMediaKeySessionCallbacks {
onClosed: () => void;
onKeysUpdate: () => void;
}
/**
* Re-implementation of the `MediaKeyStatusMap` where insertion is possible.
*
* Used to mock the corresponding EME API.
* @class DummyMediaKeyStatusMap
*/
export class DummyMediaKeyStatusMap implements MediaKeyStatusMap {
/**
* Inner Map corresponding to what's that fake `MediaKeyStatusMap` maintains.
* Key id are actually stored as strings as hex representation of the
* underlying bytes.
*/
private _innerMap: Map<
string,
{
status: MediaKeyStatus;
policyLevel: number;
}
>;
/**
* KeySystem maintaining this Map.
* Needed to perform some work-around on "special" platforms.
*/
private _keySystem: string;
/**
* @returns {number} - the number of elements in the `DummyMediaKeyStatusMap`.
*/
get size(): number {
return this._innerMap.size;
}
/**
* @param {string} keySystem - KeySystem linked to this Map.
* Needed to perform some work-around on "special" platforms.
*/
constructor(keySystem: string) {
this._innerMap = new Map();
this._keySystem = keySystem;
}
/**
* Returns the actual Map backing this `DummyMediaKeyStatusMap`.
* Useful to perform complex modifications.
* @returns {Map}
*/
public getInnerMap(): Map<string, { status: MediaKeyStatus; policyLevel: number }> {
return this._innerMap;
}
/**
* Executes a provided function once per each key/value pair in the
* `DummyMediaKeyStatusMap`, in insertion order.
* @param {function} callbackfn
*/
public forEach(
callbackfn: (
value: MediaKeyStatus,
key: ArrayBuffer,
parent: MediaKeyStatusMap,
) => void,
): void {
return this._innerMap.forEach((value, key) => {
const toLocalFormat = kidToPlatformKid(
this._keySystem,
toUint8Array(hexToBytes(key)),
);
callbackfn(value.status, toLocalFormat.buffer, this);
});
}
/**
* Adds a new element with a specified key id and `MediaKeyStatus` to the
* `DummyMediaKeyStatusMap`.
* If an element with the same key id already exists, the element will be
* updated.
* @param {string} key - Key as an hex string in lower case
* @param {string} status
* @param {number} policyLevel
* @returns {Object}
*/
public set(key: string, status: MediaKeyStatus, policyLevel: number): this {
this._innerMap.set(key, { status, policyLevel });
return this;
}
/**
* Returns a specified element from this `DummyMediaKeyStatusMap` object.
* @param {BufferSource} key
* @returns {string|undefined} - Returns the element associated with the
* specified key id.
* If no element is associated with the specified key id, undefined is
* returned.
*/
public get(key: BufferSource): MediaKeyStatus | undefined {
const toLocalFormat = kidToPlatformKid(this._keySystem, toUint8Array(key));
const keyStr = bytesToHex(toLocalFormat);
return this._innerMap.get(keyStr)?.status;
}
/**
* @returns {boolean} - Indicate whether an element with the specified key id
* exists or not.
*/
public has(key: BufferSource): boolean {
const toLocalFormat = kidToPlatformKid(this._keySystem, toUint8Array(key));
const keyStr = bytesToHex(toLocalFormat);
return this._innerMap.has(keyStr);
}
/**
* Remove all stored key ids from this Map.
*/
public clear(): void {
return this._innerMap.clear();
}
}
/* eslint-disable @typescript-eslint/naming-convention */
const SYSTEM_IDS: Record<string, "cenc" | "PlayReady" | "Nagra" | "Widevine"> = {
"1077EFECC0B24D02ACE33C1E52E2FB4B": "cenc",
// "1F83E1E86EE94F0DBA2F5EC4E3ED1A66": "SecureMedia",
// "35BF197B530E42D78B651B4BF415070F": "DivX DRM",
// "45D481CB8FE049C0ADA9AB2D2455B2F2": "CoreCrypt",
// "5E629AF538DA4063897797FFBD9902D4": "Marlin",
// "616C7469636173742D50726F74656374": "AltiProtect",
// "644FE7B5260F4FAD949A0762FFB054B4": "CMLA",
// "69F908AF481646EA910CCD5DCCCB0A3A": "Marlin",
// "6A99532D869F59229A91113AB7B1E2F3": "MobiDRM",
// "80A6BE7E14484C379E70D5AEBE04C8D2": "Irdeto",
// "94CE86FB07FF4F43ADB893D2FA968CA2": "FairPlay",
// "992C46E6C4374899B6A050FA91AD0E39": "SteelKnot",
"9A04F07998404286AB92E65BE0885F95": "PlayReady",
// "9A27DD82FDE247258CBC4234AA06EC09": "Verimatrix VCAS",
// "A68129D3575B4F1A9CBA3223846CF7C3": "VideoGuard Everywhere",
ADB41C242DBF4A6D958B4457C0D27B95: "Nagra",
// "B4413586C58CFFB094A5D4896C1AF6C3": "Viaccess-Orca",
// "DCF4E3E362F158187BA60A6FE33FF3DD": "DigiCAP",
// E2719D58A985B3C9781AB030AF78D30E: "ClearKey",
EDEF8BA979D64ACEA3C827DCD51D21ED: "Widevine",
// "F239E769EFA348509C16A903C6932EFB": "PrimeTime",
};
/* eslint-enable @typescript-eslint/naming-convention */
type ValueOf<T> = T[keyof T];
function getKeyIdsFromPssh(
buf: Uint8Array,
baseOffset: number,
): {
systemId: ValueOf<typeof SYSTEM_IDS> | undefined;
kids: Uint8Array[];
} | null {
let offset = baseOffset + 4 + 4;
const version = buf[offset];
if (version === undefined || version > 1) {
throw new Error("Invalid PSSH: Invalid version");
}
offset++;
if (buf.length < offset + offset + 19) {
throw new Error("Invalid PSSH: too short");
}
offset += 3; // flags
const systemId = bytesToHex(buf.subarray(offset, offset + 16));
offset += 16;
if (version === 1) {
if (buf.length < offset + 4) {
return null;
}
const kidCount = be4toi(buf, offset);
offset += 4;
const kids = [];
let i = kidCount;
while (i-- > 0) {
if (buf.length < offset + 16) {
return null;
}
kids.push(buf.subarray(offset, offset + 16));
offset += 16;
}
return {
systemId: undefined,
kids,
};
}
const systemIdStr = SYSTEM_IDS[systemId.toUpperCase()];
switch (systemIdStr) {
case "PlayReady": {
const kid = getPlayReadyKIDFromPssh(buf, baseOffset);
return {
systemId: "PlayReady",
kids: [hexToBytes(kid)],
};
}
case "Widevine": {
let innerOffset =
4 /* box length */ +
4 /* box name */ +
4 /* version + flags */ +
16 /* system id */ +
4; /* length of widevine header. */
// TODO real widevine PSSH parsing.
while (true) {
if (buf.byteLength < baseOffset + innerOffset + 16 + 2) {
return null;
}
if (
buf[baseOffset + innerOffset] === 0x12 &&
buf[baseOffset + innerOffset + 1] === 0x10
) {
const kid = buf.subarray(
baseOffset + innerOffset + 2,
baseOffset + innerOffset + 2 + 16,
);
return {
systemId: "Widevine",
kids: [kid],
};
}
innerOffset += 1;
}
}
case "Nagra": {
const innerOffset =
baseOffset +
4 /* box length */ +
4 /* box name */ +
4 /* version + flags */ +
16 /* system id */ +
4; /* length */
const nagraBase64 = utf8ToStr(buf.subarray(innerOffset));
const decodedBase64 = base64ToBytes(nagraBase64);
const nagraStr = utf8ToStr(decodedBase64);
const parsed = JSON.parse(nagraStr) as
| {
contentId?: string;
keyId?: string;
}
| undefined
| null;
if (parsed === null || parsed === undefined || parsed.keyId === undefined) {
throw new Error("Unrecognized Nagra PSSH");
}
return {
systemId: "Nagra",
kids: [hexToBytes(parsed.keyId.replace(/-/g, ""))],
};
}
case "cenc":
throw new Error("cenc pssh should have been set to version 1");
}
}
/**
* Parse PlayReady pssh to get its Hexa-coded KeyID.
* @param {Uint8Array} buf
* @param {number} baseOffset
* @returns {string}
*/
export function getPlayReadyKIDFromPssh(buf: Uint8Array, baseOffset: number): string {
const innerOffset =
baseOffset +
4 /* box length */ +
4 /* box name */ +
4 /* version + flags */ +
16; /* system id */
const xmlLength = le2toi(buf.subarray(innerOffset), 4);
const xml = utf16LEToStr(buf.subarray(innerOffset + 14, innerOffset + 14 + xmlLength));
const doc = new DOMParser().parseFromString(xml, "application/xml");
const kidElement = doc.querySelector("KID");
if (kidElement === null) {
throw new Error("Cannot parse PlayReady PSSH: invalid XML");
}
const b64guidKid = kidElement.textContent === null ? "" : kidElement.textContent;
const uuidKid = guidToUuid(base64ToBytes(b64guidKid));
return bytesToHex(uuidKid).toLowerCase();
}
/**
* @param {Uint8Array} data
* @returns {Array.<Uint8Array>} - The extracted PSSH boxes. In the order they
* are encountered.
*/
function splitPsshBoxes(data: Uint8Array): Uint8Array[] {
let i = 0;
const psshBoxes: Uint8Array[] = [];
while (i < data.length) {
let psshOffsets;
try {
psshOffsets = getBoxOffsets(data, 0x70737368 /* pssh */);
} catch {
return psshBoxes;
}
if (psshOffsets === null) {
return psshBoxes;
}
const pssh = sliceUint8array(data, psshOffsets[0], psshOffsets[2]);
psshBoxes.push(pssh);
i = psshOffsets[2];
}
return psshBoxes;
}
/**
* On EDGE, Microsoft Playready KID are presented into little-endian GUID, this
* function ensures that everything is in the expected format for the platfrm.
* @param {String} keySystem
* @param {Uint8Array} baseKeyId
* @returns {Uint8Array}
*/
export default function kidToPlatformKid<T extends ArrayBufferLike>(
keySystem: string,
baseKeyId: Uint8Array<T>,
): Uint8Array<T | ArrayBuffer> {
if (
keySystem.indexOf("playready") !== -1 &&
(EnvDetector.browser === EnvDetector.BROWSERS.EdgeChromium ||
EnvDetector.browser === EnvDetector.BROWSERS.OtherIeOrEdgePreEdgeChromium ||
EnvDetector.browser === EnvDetector.BROWSERS.Ie11)
) {
return uuidToGuid(baseKeyId);
}
return baseKeyId;
}
/**
* Convert big-endian UUID into little-endian GUID.
* @param {Uint8Array} uuid
* @returns {Uint8Array} - guid
* @throws AssertionError - The uuid length is not 16
*/
function uuidToGuid(uuid: Uint8Array): Uint8Array<ArrayBuffer> {
assert(uuid.length === 16, "UUID length should be 16");
const p1A = uuid[0];
const p1B = uuid[1];
const p1C = uuid[2];
const p1D = uuid[3];
const p2A = uuid[4];
const p2B = uuid[5];
const p3A = uuid[6];
const p3B = uuid[7];
const guid = new Uint8Array(16);
// swapping byte endian on 4 bytes
// [4, 3, 2, 1] => [1, 2, 3, 4]
guid[0] = p1D;
guid[1] = p1C;
guid[2] = p1B;
guid[3] = p1A;
// swapping byte endian on 2 bytes
// [6, 5] => [5, 6]
guid[4] = p2B;
guid[5] = p2A;
// swapping byte endian on 2 bytes
// [8, 7] => [7, 8]
guid[6] = p3B;
guid[7] = p3A;
guid.set(uuid.subarray(8, 16), 8);
return guid;
}