import ExtendedMediaError from '../util/ExtendedMediaError.js';
/**
* Converts a string to a Uint16Array.
* @private
* @memberof module:src/streaming/FairPlay
* @param {string} string Input string.
* @returns {Uint16Array} Converted array.
*/
const stringToUint16Array = string => new Uint16Array(new ArrayBuffer(string.length * 2)).map((entry, index) => string.charCodeAt(index));
/**
* Converts a Uint16Array to a string.
* @private
* @memberof module:src/streaming/FairPlay
* @param {Uint16Array} array Input array.
* @returns {string} Converted string.
*/
const uInt16arrayToString = array => String.fromCharCode.apply(null, new Uint16Array(array.buffer));
/**
* Converts a Uint8Array to a string.
* @private
* @memberof module:src/streaming/FairPlay
* @param {Uint8Array} array Input array.
* @returns {string} Converted string.
*/
const uInt8ArrayToString = array => String.fromCharCode.apply(null, array);
/**
* Decodes a base64-encoded string into a Uint8Array.
* @private
* @memberof module:src/streaming/FairPlay
* @param {string} input Input string.
* @returns {Uint8Array} Converted array.
*/
const base64DecodeUint8Array = input => Uint8Array.from(atob(input), c => c.charCodeAt(0));
/**
* Encodes a Uint8Array into base64 string.
* @private
* @memberof module:src/streaming/FairPlay
* @param {Uint8Array} input Input array.
* @returns {string} Converted string.
*/
const base64EncodeUint8Array = input => btoa(uInt8ArrayToString(input));
/**
* The FairPlay component handles Apple FairPlay DRM for HLS streams on Safari browsers.
* It works by setting up a WebKit key session using the `webkitneedkey` API, obtaining a DRM certificate,
* creating the SPC message, retrieving the license, and initializing secure playback.
* The plugin only activates on Safari browsers and registers itself during initialization.
* @exports module:src/streaming/FairPlay
* @requires src/util/ExtendedMediaError
* @author Frank Kudermann - alphanull
* @version 1.0.0
* @license MIT
*/
export default class FairPlay {
/**
* Reference to the main player instance.
* @type {module:src/core/Player}
*/
#player;
/**
* Reference to the video Element.
* @type {HTMLVideoElement}
*/
#videoEle;
/**
* Secret key only known to the player instance and initialized components.
* Used to be able to restrict access to API methods in conjunction with secure mode.
* @type {symbol}
*/
#apiKey;
/**
* License server URL.
* @type {string}
*/
#licenseUrl = '';
/**
* License request headers.
* @type {Object<string, string>}
*/
#headers = {};
/**
* The current MediaKeySession instance.
* @type {MediaKeySession}
*/
#keySession;
/**
* The resolve callback to unblock media.load().
* @type {Function}
*/
#resolve;
/**
* Creates an instance of the FairPlay plugin.
* @param {module:src/core/Player} player Reference to the player instance.
* @param {module:src/core/Media} mediaComponent Reference to the engine (video) instance.
* @param {symbol} apiKey Token for extended access to the player API.
*/
constructor(player, mediaComponent, { apiKey }) {
if (!player.getClient('safari')) return [false];
this.#player = player;
this.#apiKey = apiKey;
mediaComponent.registerPlugin(this);
this.#player.constructor.addFormat({
extensions: ['m3u8'],
mimeTypeAudio: ['audio/mpegurl'],
mimeTypeVideo: ['application/x-mpegURL']
});
}
/**
* Checks if this plugin can handle a given MIME type and DRM system.
* @param {module:src/core/Media~metaData} metaData The data to test.
* @param {string} metaData.mimeType The mime type to test.
* @param {string} [metaData.drmSystem] Optional DRM system info.
* @returns {string} "maybe" if playable, otherwise an empty string.
*/
canPlay({ mimeType, drmSystem } = {}) {
return this.#player.getClient('safari') && mimeType === 'application/x-mpegURL' && drmSystem === 'FairPlay'/* ) */ ? 'maybe' : '';
}
/**
* Initializes the FairPlay session if the source is HLS and has FairPlay DRM. Loads the certificate if not provided inline,
* sets up the video src, and waits for "webkitneedkey" event to start encryption handling.
* @param {module:src/core/Media~metaData} metaData metaData to load.
* @param {string} metaData.src The HLS URL to load.
* @throws {Error} If not Safari or .m3u8 or lacking 'drm.FairPlay'.
*/
async load({ src }) {
const { drm } = this.#player.data.getMediaData(),
path = src.split(/[?#]/)[0],
suffix = path.slice((path.lastIndexOf(".") - 1 >>> 0) + 2).toLowerCase(); // eslint-disable-line
if (!this.#player.getClient('safari') || suffix !== 'm3u8' || !drm || !drm.FairPlay) {
throw new Error('[FairPlay] Cannot handle request');
}
const { certificateUrl, certificate, licenseUrl, header } = drm.FairPlay;
this.#licenseUrl = licenseUrl;
this.#headers = header || {};
this.#headers['Content-type'] = 'application/x-www-form-urlencoded';
this.#videoEle = this.#player.media.getElement(this.#apiKey);
try {
const waitFor = (target, type) => new Promise(resolve => {
target.addEventListener(type, resolve, { once: true });
});
const cert = certificate ? base64DecodeUint8Array(certificate) : await FairPlay.#loadCertificate(certificateUrl);
this.#videoEle.src = src;
const event = await waitFor(this.#videoEle, 'webkitneedkey');
await this.#encrypted(event, cert);
} catch (e) {
this.#player.publish('media/error', { error: new ExtendedMediaError(99, e.message) }, this.#apiKey);
throw e;
}
}
/**
* Initializes and prepares the FairPlay key session.
* @param {Event} event The "webkitneedkey" event.
* @param {Uint8Array} certificate The DRM certificate.
* @returns {Promise<void>}
* @throws {Error} If creating mediaKeys or the key sessions fails for any reason.
*/
async #encrypted(event, certificate) {
this.destroy();
const initData = new Uint8Array(event.initData);
let contentId = uInt16arrayToString(initData);
contentId = contentId.substring(contentId.indexOf('skd://') + 6);
if (!this.#videoEle.webkitKeys) {
try {
await this.#videoEle.webkitSetMediaKeys(new window.WebKitMediaKeys('com.apple.fps.1_0'));
} catch (e) {
throw new Error('[FairPlay Plugin] Could not create MediaKeys', { cause: e });
}
}
return new Promise(resolve => {
this.#resolve = resolve;
try {
this.#keySession = this.#videoEle.webkitKeys.createSession('video/mp4', FairPlay.#createSpc(contentId, event.initData, certificate));
this.#keySession.addEventListener('webkitkeymessage', this.#keyMessage);
this.#keySession.addEventListener('webkitkeyadded', this.#keyAdded);
this.#keySession.addEventListener('webkitkeyerror', this.#keyError); // for testing purposes, adding webkitkeyerror must be the last item in this method
} catch (e) { // eslint-disable-line no-unused-vars
throw new Error('[FairPlay Plugin] Could not create key session');
}
});
}
/**
* Handles license requests when "webkitkeymessage" event is fired.
* @param {Event} event Event containing the license challenge.
*/
#keyMessage = async event => {
try {
const response = await FairPlay.#getLicense(event, this.#licenseUrl, this.#headers);
this.#keySession.update(new Uint8Array(response));
} catch (e) { // eslint-disable-line no-unused-vars
this.#player.publish('media/error', { error: new ExtendedMediaError(99, '[FairPlay Plugin] Error loading license') }, this.#apiKey);
}
};
/**
* Called when "webkitkeyadded" fires, meaning the DRM session is successfully set up.
*/
#keyAdded = () => {
this.#resolve();
};
/**
* Called when "webkitkeyerror" fires, meaning the DRM session encountered an error.
* @throws {Error} If a key session error code is found.
*/
#keyError = () => {
const { error } = this.#keySession;
throw new Error(`[FairPlay Plugin] KeySession error: code ${error.code}, systemCode ${error.systemCode}`);
};
/**
* This method removes all events, subscriptions and DOM nodes created by this component.
*/
destroy() {
if (this.#keySession) {
this.#keySession.removeEventListener('webkitkeymessage', this.#keyMessage);
this.#keySession.addEventListener('webkitkeyadded', this.#keyAdded);
this.#keySession.removeEventListener('webkitkeyerror', this.#keyError);
}
this.#player = this.#videoEle = this.#apiKey = null;
}
/**
* Loads the FairPlay DRM certificate from the specified URL.
* @param {string} certificateUrl URL to the DRM certificate.
* @returns {Promise<Uint8Array>} The fetched certificate as Uint8Array.
*/
static async #loadCertificate(certificateUrl) {
try {
const response = await fetch(certificateUrl, { mode: 'cors' });
const text = await response.text();
return base64DecodeUint8Array(text); // apparently we need to base64DecodeUint8Array this
} catch (e) {
throw new Error(`[FairPlay Plugin] Could not load certificate at ${certificateUrl}`, { cause: e });
}
}
/**
* Creates the FairPlay "SPC" message to request a license.
* @param {string} contentId Content identifier (from SKD URL).
* @param {Uint8Array} initData Initialization data.
* @param {Uint8Array} cert DRM certificate.
* @returns {Uint8Array} SPC request message.
*/
static #createSpc(contentId, initData, cert) {
// layout:
// [initData]
// [4 byte: idLength]
// [idLength byte: id]
// [4 byte:certLength]
// [certLength byte: cert]
let offset = 0;
const id = typeof contentId === 'string' ? stringToUint16Array(contentId) : contentId,
buffer = new ArrayBuffer(initData.byteLength + 4 + id.byteLength + 4 + cert.byteLength),
dataView = new DataView(buffer),
initDataArray = new Uint8Array(buffer, offset, initData.byteLength);
initDataArray.set(initData);
offset += initData.byteLength;
dataView.setUint32(offset, id.byteLength, true);
offset += 4;
const idArray = new Uint16Array(buffer, offset, id.length);
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);
}
/**
* Fetches the license by sending the SPC message to the DRM license server. Expects a base64-encoded response.
* @param {Event} event The webkitkeymessage event containing the message.
* @param {string} spcPath The license server url.
* @param {Object<string, string>} headers HTTP headers for the license request.
* @returns {Promise<Uint8Array>} The license data.
*/
static async #getLicense(event, spcPath, headers) {
const licenseResponse = await fetch(spcPath, {
method: 'POST',
headers: new Headers(headers),
body: `spc=${base64EncodeUint8Array(new Uint8Array(event.message))}`
});
const license = await licenseResponse.text();
return base64DecodeUint8Array(license);
}
}