@playkit-js/playkit-js
Version:
[](https://github.com/kaltura/playkit-js/actions/workflows/run_canary.yaml) [ • 13.6 kB
text/typescript
import Error from '../../../../error/error';
import getLogger from '../../../../utils/logger';
import * as Utils from '../../../../utils/util';
import {RequestType} from '../../../../enums/request-type';
import {DrmScheme} from '../../../../drm/drm-scheme';
import { EventManager, ListenerType} from '../../../../event/event-manager';
import {PKDrmDataObject, PKRequestObject, PKResponseObject} from '../../../../types';
type WebkitEventsType = {[name: string]: string};
const KeySystem: string = 'com.apple.fps.1_0';
const WebkitEvents: WebkitEventsType = {
NEED_KEY: 'webkitneedkey',
KEY_MESSAGE: 'webkitkeymessage',
KEY_ADDED: 'webkitkeyadded',
KEY_ERROR: 'webkitkeyerror'
};
type FairPlayDrmConfigType = {licenseUrl: string, certificate?: string, network: {requestFilter?: (...args: any[]) => any, responseFilter: (...args: any[]) => any}, useKIDHeader?: boolean};
class FairPlayDrmHandler {
public static WebkitEvents: WebkitEventsType = WebkitEvents;
private _logger = getLogger('FairPlayDrmHandler');
private _eventManager: EventManager;
private _keySession: any;
private _config: FairPlayDrmConfigType;
private _onWebkitNeedKeyHandler: ListenerType;
private _errorCallback: (...args: any[]) => any;
private _drmResponseCallback: (...args: any[]) => any;
private _videoElement: HTMLVideoElement;
private _retryLicenseRequest: number = 4;
private _licenseRequestTime: number | undefined;
private _defaultConfig: FairPlayDrmConfigType = {
licenseUrl: '',
certificate: '',
network: {
responseFilter: (type, response) => {
let responseObj: any = {};
try {
const dataView = new DataView(response.data);
const decoder = new TextDecoder();
const keyText = decoder.decode(dataView).trim();
responseObj = JSON.parse(keyText);
} catch (error) {
this._onError(Error.Code.BAD_FAIRPLAY_RESPONSE, {
error,
responseText: response.data
});
return;
}
const isValidResponse = FairPlayDrmHandler._validateResponse(responseObj);
if (isValidResponse.valid) {
response.data = FairPlayDrmHandler._base64DecodeUint8Array(responseObj.ckc);
} else {
this._onError(Error.Code.BAD_FAIRPLAY_RESPONSE, isValidResponse);
}
}
}
};
/**
* Fairplay DRM handler
* @param {HTMLVideoElement} videoElement - the video element
* @param {FairPlayDrmConfigType} config - config object
* @param {Function} errorCallback - error callback function
* @param {Function} drmResponseCallback - drm license response callback function
*/
constructor(videoElement: HTMLVideoElement, config: FairPlayDrmConfigType, errorCallback: (...args: any[]) => any, drmResponseCallback: (...args: any[]) => any) {
this._config = Utils.Object.mergeDeep({}, this._defaultConfig, config);
this._errorCallback = errorCallback;
this._drmResponseCallback = drmResponseCallback;
this._videoElement = videoElement;
this._onWebkitNeedKeyHandler = (e: Event): void => this._onWebkitNeedKey(e);
this._eventManager = new EventManager();
this._eventManager.listen(this._videoElement, WebkitEvents.NEED_KEY, this._onWebkitNeedKeyHandler);
}
private _onWebkitNeedKey(event: any): void {
this._logger.debug('Webkit need key triggered');
const videoElement = event.target;
let initData = event.initData;
const contentId = FairPlayDrmHandler._extractContentId(initData, !!this._config.useKIDHeader);
const fpsCertificate = FairPlayDrmHandler._base64DecodeUint8Array(this._config.certificate);
initData = FairPlayDrmHandler._concatInitDataIdAndCertificate(initData, contentId, fpsCertificate);
if (!videoElement.webkitKeys) {
const keySystem = this._selectKeySystem();
this._logger.debug('Sets media keys');
videoElement.webkitSetMediaKeys(new WebKitMediaKeys(keySystem!));
}
if (!videoElement.webkitKeys) {
this._onError(Error.Code.COULD_NOT_CREATE_MEDIA_KEYS);
}
this._logger.debug('Creates session');
this._keySession = videoElement.webkitKeys.createSession('video/mp4', initData);
if (!this._keySession) {
this._onError(Error.Code.COULD_NOT_CREATE_KEY_SESSION);
}
this._keySession.contentId = contentId;
this._eventManager.listen(this._keySession, WebkitEvents.KEY_MESSAGE, (e: Event) => this._onWebkitKeyMessage(e));
this._eventManager.listen(this._keySession, WebkitEvents.KEY_ADDED, () => this._onWebkitKeyAdded());
this._eventManager.listen(this._keySession, WebkitEvents.KEY_ERROR, (e: Event) => this._onWebkitKeyError(e));
}
public getDrmInfo(): PKDrmDataObject {
const {certificate, licenseUrl} = this._config;
return {certificate, licenseUrl, scheme: DrmScheme.FAIRPLAY};
}
public destroy(): void {
this._eventManager.destroy();
this._keySession.close();
this._keySession = null;
}
private _onWebkitKeyMessage(event: any): void {
this._logger.debug('Webkit key message triggered');
const message = event.message;
// WebkitMediaKeySession has attribute contentId (which is the mykeyid from playlist's skd://mykeyid)
const session = event.target;
const kid = session?.contentId;
const kidBase64 = (kid !== undefined && kid !== null) ? btoa(kid) : '';
const request = new XMLHttpRequest();
request.responseType = 'arraybuffer';
this._eventManager.listenOnce(request, 'load', (e: Event) => this._licenseRequestLoaded(e));
const pkRequest: PKRequestObject = {
url: this._config.licenseUrl,
body: FairPlayDrmHandler._base64EncodeUint8Array(message),
headers: this._config.useKIDHeader ? {'X-Kaltura-InitData': kidBase64} : {} // pass the kid to uDRM
};
let requestFilterPromise;
const requestFilter = this._config.network.requestFilter;
if (requestFilter) {
this._logger.debug('Apply request filter');
try {
requestFilterPromise = requestFilter(RequestType.LICENSE, pkRequest);
} catch (error) {
requestFilterPromise = Promise.reject(error);
}
}
requestFilterPromise = requestFilterPromise || Promise.resolve(pkRequest);
requestFilterPromise
.then(updatedRequest => {
request.open('POST', updatedRequest.url, true);
let setContentType = true;
if (updatedRequest.headers) {
Object.entries(updatedRequest.headers).forEach(([header, value]) => {
typeof value === 'string' && request.setRequestHeader(header, value);
setContentType && (setContentType = header.toLowerCase() !== 'content-type');
});
}
if (typeof updatedRequest.withCredentials === 'boolean') {
request.withCredentials = updatedRequest.withCredentials;
}
setContentType && request.setRequestHeader('Content-type', 'application/json');
this._logger.debug('Ready for license request');
request.onerror = (): void => {
this._onError(Error.Code.LICENSE_REQUEST_FAILED, {
status: request.status,
responseText: request.responseText
});
};
this._licenseRequestTime = Date.now();
request.send(updatedRequest.body);
})
.catch(error => {
this._errorCallback(
new Error(
Error.Severity.CRITICAL,
Error.Category.NETWORK,
Error.Code.REQUEST_FILTER_ERROR,
error
)
);
this.destroy();
});
}
private _onWebkitKeyAdded(): void {
this._logger.debug('Decryption key was added to session');
}
private _onWebkitKeyError(e: any): void {
this._logger.error('A decryption key error was encountered', e);
if (this._retryLicenseRequest <= 0) {
this._onError(Error.Code.LICENSE_REQUEST_FAILED, e.target.error);
}
this._retryLicenseRequest--;
}
private _licenseRequestLoaded(event: any): void {
this._logger.debug('License request loaded');
const request = event.target;
if (request.status > 299) {
this._onError(Error.Code.LICENSE_REQUEST_FAILED, {
status: request.status,
error: request.responseText
});
return;
}
if (this._drmResponseCallback) {
const licenseTime = Date.now() - this._licenseRequestTime!;
this._drmResponseCallback({licenseTime: licenseTime / 1000, scheme: DrmScheme.FAIRPLAY});
}
const {responseURL: url, response: data} = request;
const originalUrl = this._config.licenseUrl;
const headers = Utils.Http.convertHeadersToDictionary(request.getAllResponseHeaders());
const pkResponse: PKResponseObject = {url, originalUrl, data, headers};
this._logger.debug('Apply response filter');
let responseFilterPromise;
try {
responseFilterPromise = this._config.network.responseFilter(RequestType.LICENSE, pkResponse);
} catch (error) {
responseFilterPromise = Promise.reject(error);
}
responseFilterPromise = responseFilterPromise || Promise.resolve(pkResponse);
responseFilterPromise
.then(updatedResponse => {
this._keySession.update(updatedResponse.data);
})
.catch(error => {
this._errorCallback(
new Error(
Error.Severity.CRITICAL,
Error.Category.NETWORK,
Error.Code.RESPONSE_FILTER_ERROR,
error
)
);
this.destroy();
});
}
private _onError(code: number, data?: any): void {
this._errorCallback(new Error(Error.Severity.CRITICAL, Error.Category.DRM, code, data));
}
public static _validateResponse(responseObj: any): any {
if ((responseObj.message && responseObj.message.indexOf('error') > 0) || responseObj.reference === null || responseObj.status_code === 500) {
return {
//todo: create & edit an error object
valid: false,
details: 'internal server error' // would be ERROR.INTERNAL or something like that
};
} else if (responseObj.ckc === '') {
return {
valid: false,
details: 'ckc is missing' // would be ERROR.MISSING_CKC or something like that
};
} else {
return {
valid: true
};
}
}
private _selectKeySystem(): string | null {
let keySystem: string | null = null;
if (WebKitMediaKeys.isTypeSupported(KeySystem, 'video/mp4')) {
keySystem = KeySystem;
} else {
this._logger.warn('Key System not supported');
}
return keySystem;
}
public static _extractContentId(initData: Uint8Array, useKIDHeader: boolean): string {
if (useKIDHeader) {
const url = FairPlayDrmHandler._arrayToString(initData);
const hostname = url.slice(url.lastIndexOf("skd://")+6,url.length); // handle the skd://mykeyid format
return hostname;
} else {
const link = document.createElement('a');
link.href = FairPlayDrmHandler._arrayToString(initData);
return link.hostname;
}
}
public static _arrayToString(array: Uint8Array): string {
return String.fromCharCode.apply(null, new Uint16Array(array.buffer));
}
public static _base64DecodeUint8Array(input?: string): Uint8Array {
const raw = window.atob(input!);
const rawLength = raw.length;
const array = new Uint8Array(new ArrayBuffer(rawLength));
for (let i = 0; i < rawLength; i++) {
array[i] = raw.charCodeAt(i);
}
return array;
}
public static _concatInitDataIdAndCertificate(initData: Uint8Array, id: string | Uint16Array, cert: Uint8Array): Uint8Array {
if (typeof id === 'string') {
id = FairPlayDrmHandler._stringToArray(id);
}
let offset = 0;
const buffer = new ArrayBuffer(initData.byteLength + 4 + id.byteLength + 4 + cert.byteLength);
const dataView = new DataView(buffer);
const initDataArray = new Uint8Array(buffer, offset, initData.byteLength);
initDataArray.set(initData);
offset += initData.byteLength;
dataView.setUint32(offset, id.byteLength, true);
offset += 4;
const idArray = new Uint8Array(buffer, offset, id.byteLength);
idArray.set(id);
offset += idArray.byteLength;
dataView.setUint32(offset, cert.byteLength, true);
offset += 4;
const certArray = new Uint8Array(buffer, offset, cert.byteLength);
certArray.set(cert);
return new Uint8Array(buffer, 0, buffer.byteLength);
}
public static _stringToArray(string: string): Uint16Array {
const buffer = new ArrayBuffer(string.length * 2);
const array = new Uint16Array(buffer);
for (let i = 0, strLen = string.length; i < strLen; i++) {
array[i] = string.charCodeAt(i);
}
return array;
}
public static _base64EncodeUint8Array(input: Uint8Array): string {
const keyStr = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
let output = '';
let chr1, chr2, chr3, enc1, enc2, enc3, enc4;
let i = 0;
while (i < input.length) {
chr1 = input[i++];
chr2 = i < input.length ? input[i++] : Number.NaN;
chr3 = i < input.length ? input[i++] : Number.NaN;
enc1 = chr1 >> 2;
enc2 = ((chr1 & 3) << 4) | (chr2 >> 4);
enc3 = ((chr2 & 15) << 2) | (chr3 >> 6);
enc4 = chr3 & 63;
if (isNaN(chr2)) {
enc3 = enc4 = 64;
} else if (isNaN(chr3)) {
enc4 = 64;
}
output += keyStr.charAt(enc1) + keyStr.charAt(enc2) + keyStr.charAt(enc3) + keyStr.charAt(enc4);
}
return output;
}
}
FairPlayDrmHandler.WebkitEvents = WebkitEvents;
export {FairPlayDrmHandler};
export type {FairPlayDrmConfigType};