src/controller/eme-controller.ts
- /**
- * @author Stephan Hesse <disparat@gmail.com> | <tchakabam@gmail.com>
- *
- * DRM support for Hls.js
- */
- import { Events } from '../events';
- import { ErrorTypes, ErrorDetails } from '../errors';
-
- import { logger } from '../utils/logger';
- import type { DRMSystemOptions, EMEControllerConfig } from '../config';
- import type { MediaKeyFunc } from '../utils/mediakeys-helper';
- import { KeySystems } from '../utils/mediakeys-helper';
- import type Hls from '../hls';
- import type { ComponentAPI } from '../types/component-api';
- import type { MediaAttachedData, ManifestParsedData } from '../types/events';
-
- const MAX_LICENSE_REQUEST_FAILURES = 3;
-
- /**
- * @see https://developer.mozilla.org/en-US/docs/Web/API/MediaKeySystemConfiguration
- * @param {Array<string>} audioCodecs List of required audio codecs to support
- * @param {Array<string>} videoCodecs List of required video codecs to support
- * @param {object} drmSystemOptions Optional parameters/requirements for the key-system
- * @returns {Array<MediaSystemConfiguration>} An array of supported configurations
- */
-
- const createWidevineMediaKeySystemConfigurations = function (
- audioCodecs: string[],
- videoCodecs: string[],
- drmSystemOptions: DRMSystemOptions
- ): MediaKeySystemConfiguration[] {
- /* jshint ignore:line */
- const baseConfig: MediaKeySystemConfiguration = {
- // initDataTypes: ['keyids', 'mp4'],
- // label: "",
- // persistentState: "not-allowed", // or "required" ?
- // distinctiveIdentifier: "not-allowed", // or "required" ?
- // sessionTypes: ['temporary'],
- audioCapabilities: [], // { contentType: 'audio/mp4; codecs="mp4a.40.2"' }
- videoCapabilities: [], // { contentType: 'video/mp4; codecs="avc1.42E01E"' }
- };
-
- audioCodecs.forEach((codec) => {
- baseConfig.audioCapabilities!.push({
- contentType: `audio/mp4; codecs="${codec}"`,
- robustness: drmSystemOptions.audioRobustness || '',
- });
- });
- videoCodecs.forEach((codec) => {
- baseConfig.videoCapabilities!.push({
- contentType: `video/mp4; codecs="${codec}"`,
- robustness: drmSystemOptions.videoRobustness || '',
- });
- });
-
- return [baseConfig];
- };
-
- /**
- * The idea here is to handle key-system (and their respective platforms) specific configuration differences
- * in order to work with the local requestMediaKeySystemAccess method.
- *
- * We can also rule-out platform-related key-system support at this point by throwing an error.
- *
- * @param {string} keySystem Identifier for the key-system, see `KeySystems` enum
- * @param {Array<string>} audioCodecs List of required audio codecs to support
- * @param {Array<string>} videoCodecs List of required video codecs to support
- * @throws will throw an error if a unknown key system is passed
- * @returns {Array<MediaSystemConfiguration>} A non-empty Array of MediaKeySystemConfiguration objects
- */
- const getSupportedMediaKeySystemConfigurations = function (
- keySystem: KeySystems,
- audioCodecs: string[],
- videoCodecs: string[],
- drmSystemOptions: DRMSystemOptions
- ): MediaKeySystemConfiguration[] {
- switch (keySystem) {
- case KeySystems.WIDEVINE:
- return createWidevineMediaKeySystemConfigurations(
- audioCodecs,
- videoCodecs,
- drmSystemOptions
- );
- default:
- throw new Error(`Unknown key-system: ${keySystem}`);
- }
- };
-
- interface MediaKeysListItem {
- mediaKeys?: MediaKeys;
- mediaKeysSession?: MediaKeySession;
- mediaKeysSessionInitialized: boolean;
- mediaKeySystemAccess: MediaKeySystemAccess;
- mediaKeySystemDomain: KeySystems;
- }
-
- /**
- * Controller to deal with encrypted media extensions (EME)
- * @see https://developer.mozilla.org/en-US/docs/Web/API/Encrypted_Media_Extensions_API
- *
- * @class
- * @constructor
- */
- class EMEController implements ComponentAPI {
- private hls: Hls;
- private _widevineLicenseUrl?: string;
- private _licenseXhrSetup?: (xhr: XMLHttpRequest, url: string) => void;
- private _licenseResponseCallback?: (
- xhr: XMLHttpRequest,
- url: string
- ) => ArrayBuffer;
- private _emeEnabled: boolean;
- private _requestMediaKeySystemAccess: MediaKeyFunc | null;
- private _drmSystemOptions: DRMSystemOptions;
-
- private _config: EMEControllerConfig;
- private _mediaKeysList: MediaKeysListItem[] = [];
- private _media: HTMLMediaElement | null = null;
- private _hasSetMediaKeys: boolean = false;
- private _requestLicenseFailureCount: number = 0;
-
- private mediaKeysPromise: Promise<MediaKeys> | null = null;
- private _onMediaEncrypted = this.onMediaEncrypted.bind(this);
-
- /**
- * @constructs
- * @param {Hls} hls Our Hls.js instance
- */
- constructor(hls: Hls) {
- this.hls = hls;
- this._config = hls.config;
-
- this._widevineLicenseUrl = this._config.widevineLicenseUrl;
- this._licenseXhrSetup = this._config.licenseXhrSetup;
- this._licenseResponseCallback = this._config.licenseResponseCallback;
- this._emeEnabled = this._config.emeEnabled;
- this._requestMediaKeySystemAccess =
- this._config.requestMediaKeySystemAccessFunc;
- this._drmSystemOptions = this._config.drmSystemOptions;
-
- this._registerListeners();
- }
-
- public destroy() {
- this._unregisterListeners();
- // @ts-ignore
- this.hls = this._onMediaEncrypted = null;
- this._requestMediaKeySystemAccess = null;
- }
-
- private _registerListeners() {
- this.hls.on(Events.MEDIA_ATTACHED, this.onMediaAttached, this);
- this.hls.on(Events.MEDIA_DETACHED, this.onMediaDetached, this);
- this.hls.on(Events.MANIFEST_PARSED, this.onManifestParsed, this);
- }
-
- private _unregisterListeners() {
- this.hls.off(Events.MEDIA_ATTACHED, this.onMediaAttached, this);
- this.hls.off(Events.MEDIA_DETACHED, this.onMediaDetached, this);
- this.hls.off(Events.MANIFEST_PARSED, this.onManifestParsed, this);
- }
-
- /**
- * @param {string} keySystem Identifier for the key-system, see `KeySystems` enum
- * @returns {string} License server URL for key-system (if any configured, otherwise causes error)
- * @throws if a unsupported keysystem is passed
- */
- getLicenseServerUrl(keySystem: KeySystems): string {
- switch (keySystem) {
- case KeySystems.WIDEVINE:
- if (!this._widevineLicenseUrl) {
- break;
- }
- return this._widevineLicenseUrl;
- }
-
- throw new Error(
- `no license server URL configured for key-system "${keySystem}"`
- );
- }
-
- /**
- * Requests access object and adds it to our list upon success
- * @private
- * @param {string} keySystem System ID (see `KeySystems`)
- * @param {Array<string>} audioCodecs List of required audio codecs to support
- * @param {Array<string>} videoCodecs List of required video codecs to support
- * @throws When a unsupported KeySystem is passed
- */
- private _attemptKeySystemAccess(
- keySystem: KeySystems,
- audioCodecs: string[],
- videoCodecs: string[]
- ) {
- // This can throw, but is caught in event handler callpath
- const mediaKeySystemConfigs = getSupportedMediaKeySystemConfigurations(
- keySystem,
- audioCodecs,
- videoCodecs,
- this._drmSystemOptions
- );
-
- logger.log('Requesting encrypted media key-system access');
-
- // expecting interface like window.navigator.requestMediaKeySystemAccess
- const keySystemAccessPromise = this.requestMediaKeySystemAccess(
- keySystem,
- mediaKeySystemConfigs
- );
-
- this.mediaKeysPromise = keySystemAccessPromise.then(
- (mediaKeySystemAccess) =>
- this._onMediaKeySystemAccessObtained(keySystem, mediaKeySystemAccess)
- );
-
- keySystemAccessPromise.catch((err) => {
- logger.error(`Failed to obtain key-system "${keySystem}" access:`, err);
- });
- }
-
- get requestMediaKeySystemAccess() {
- if (!this._requestMediaKeySystemAccess) {
- throw new Error('No requestMediaKeySystemAccess function configured');
- }
-
- return this._requestMediaKeySystemAccess;
- }
-
- /**
- * Handles obtaining access to a key-system
- * @private
- * @param {string} keySystem
- * @param {MediaKeySystemAccess} mediaKeySystemAccess https://developer.mozilla.org/en-US/docs/Web/API/MediaKeySystemAccess
- */
- private _onMediaKeySystemAccessObtained(
- keySystem: KeySystems,
- mediaKeySystemAccess: MediaKeySystemAccess
- ): Promise<MediaKeys> {
- logger.log(`Access for key-system "${keySystem}" obtained`);
-
- const mediaKeysListItem: MediaKeysListItem = {
- mediaKeysSessionInitialized: false,
- mediaKeySystemAccess: mediaKeySystemAccess,
- mediaKeySystemDomain: keySystem,
- };
-
- this._mediaKeysList.push(mediaKeysListItem);
-
- const mediaKeysPromise = Promise.resolve()
- .then(() => mediaKeySystemAccess.createMediaKeys())
- .then((mediaKeys) => {
- mediaKeysListItem.mediaKeys = mediaKeys;
-
- logger.log(`Media-keys created for key-system "${keySystem}"`);
-
- this._onMediaKeysCreated();
-
- return mediaKeys;
- });
-
- mediaKeysPromise.catch((err) => {
- logger.error('Failed to create media-keys:', err);
- });
-
- return mediaKeysPromise;
- }
-
- /**
- * Handles key-creation (represents access to CDM). We are going to create key-sessions upon this
- * for all existing keys where no session exists yet.
- *
- * @private
- */
- private _onMediaKeysCreated() {
- // check for all key-list items if a session exists, otherwise, create one
- this._mediaKeysList.forEach((mediaKeysListItem) => {
- if (!mediaKeysListItem.mediaKeysSession) {
- // mediaKeys is definitely initialized here
- mediaKeysListItem.mediaKeysSession =
- mediaKeysListItem.mediaKeys!.createSession();
- this._onNewMediaKeySession(mediaKeysListItem.mediaKeysSession);
- }
- });
- }
-
- /**
- * @private
- * @param {*} keySession
- */
- private _onNewMediaKeySession(keySession: MediaKeySession) {
- logger.log(`New key-system session ${keySession.sessionId}`);
-
- keySession.addEventListener(
- 'message',
- (event: MediaKeyMessageEvent) => {
- this._onKeySessionMessage(keySession, event.message);
- },
- false
- );
- }
-
- /**
- * @private
- * @param {MediaKeySession} keySession
- * @param {ArrayBuffer} message
- */
- private _onKeySessionMessage(
- keySession: MediaKeySession,
- message: ArrayBuffer
- ) {
- logger.log('Got EME message event, creating license request');
-
- this._requestLicense(message, (data: ArrayBuffer) => {
- logger.log(
- `Received license data (length: ${
- data ? data.byteLength : data
- }), updating key-session`
- );
- keySession.update(data);
- });
- }
-
- /**
- * @private
- * @param e {MediaEncryptedEvent}
- */
- private onMediaEncrypted(e: MediaEncryptedEvent) {
- logger.log(`Media is encrypted using "${e.initDataType}" init data type`);
-
- if (!this.mediaKeysPromise) {
- logger.error(
- 'Fatal: Media is encrypted but no CDM access or no keys have been requested'
- );
- this.hls.trigger(Events.ERROR, {
- type: ErrorTypes.KEY_SYSTEM_ERROR,
- details: ErrorDetails.KEY_SYSTEM_NO_KEYS,
- fatal: true,
- });
- return;
- }
-
- const finallySetKeyAndStartSession = (mediaKeys) => {
- if (!this._media) {
- return;
- }
- this._attemptSetMediaKeys(mediaKeys);
- this._generateRequestWithPreferredKeySession(e.initDataType, e.initData);
- };
-
- // Could use `Promise.finally` but some Promise polyfills are missing it
- this.mediaKeysPromise
- .then(finallySetKeyAndStartSession)
- .catch(finallySetKeyAndStartSession);
- }
-
- /**
- * @private
- */
- private _attemptSetMediaKeys(mediaKeys?: MediaKeys) {
- if (!this._media) {
- throw new Error(
- 'Attempted to set mediaKeys without first attaching a media element'
- );
- }
-
- if (!this._hasSetMediaKeys) {
- // FIXME: see if we can/want/need-to really to deal with several potential key-sessions?
- const keysListItem = this._mediaKeysList[0];
- if (!keysListItem || !keysListItem.mediaKeys) {
- logger.error(
- 'Fatal: Media is encrypted but no CDM access or no keys have been obtained yet'
- );
- this.hls.trigger(Events.ERROR, {
- type: ErrorTypes.KEY_SYSTEM_ERROR,
- details: ErrorDetails.KEY_SYSTEM_NO_KEYS,
- fatal: true,
- });
- return;
- }
-
- logger.log('Setting keys for encrypted media');
-
- this._media.setMediaKeys(keysListItem.mediaKeys);
- this._hasSetMediaKeys = true;
- }
- }
-
- /**
- * @private
- */
- private _generateRequestWithPreferredKeySession(
- initDataType: string,
- initData: ArrayBuffer | null
- ) {
- // FIXME: see if we can/want/need-to really to deal with several potential key-sessions?
- const keysListItem = this._mediaKeysList[0];
- if (!keysListItem) {
- logger.error(
- 'Fatal: Media is encrypted but not any key-system access has been obtained yet'
- );
- this.hls.trigger(Events.ERROR, {
- type: ErrorTypes.KEY_SYSTEM_ERROR,
- details: ErrorDetails.KEY_SYSTEM_NO_ACCESS,
- fatal: true,
- });
- return;
- }
-
- if (keysListItem.mediaKeysSessionInitialized) {
- logger.warn('Key-Session already initialized but requested again');
- return;
- }
-
- const keySession = keysListItem.mediaKeysSession;
- if (!keySession) {
- logger.error('Fatal: Media is encrypted but no key-session existing');
- this.hls.trigger(Events.ERROR, {
- type: ErrorTypes.KEY_SYSTEM_ERROR,
- details: ErrorDetails.KEY_SYSTEM_NO_SESSION,
- fatal: true,
- });
- return;
- }
-
- // initData is null if the media is not CORS-same-origin
- if (!initData) {
- logger.warn(
- 'Fatal: initData required for generating a key session is null'
- );
- this.hls.trigger(Events.ERROR, {
- type: ErrorTypes.KEY_SYSTEM_ERROR,
- details: ErrorDetails.KEY_SYSTEM_NO_INIT_DATA,
- fatal: true,
- });
- return;
- }
-
- logger.log(
- `Generating key-session request for "${initDataType}" init data type`
- );
- keysListItem.mediaKeysSessionInitialized = true;
-
- keySession
- .generateRequest(initDataType, initData)
- .then(() => {
- logger.debug('Key-session generation succeeded');
- })
- .catch((err) => {
- logger.error('Error generating key-session request:', err);
- this.hls.trigger(Events.ERROR, {
- type: ErrorTypes.KEY_SYSTEM_ERROR,
- details: ErrorDetails.KEY_SYSTEM_NO_SESSION,
- fatal: false,
- });
- });
- }
-
- /**
- * @private
- * @param {string} url License server URL
- * @param {ArrayBuffer} keyMessage Message data issued by key-system
- * @param {function} callback Called when XHR has succeeded
- * @returns {XMLHttpRequest} Unsent (but opened state) XHR object
- * @throws if XMLHttpRequest construction failed
- */
- private _createLicenseXhr(
- url: string,
- keyMessage: ArrayBuffer,
- callback: (data: ArrayBuffer) => void
- ): XMLHttpRequest {
- const xhr = new XMLHttpRequest();
- xhr.responseType = 'arraybuffer';
- xhr.onreadystatechange = this._onLicenseRequestReadyStageChange.bind(
- this,
- xhr,
- url,
- keyMessage,
- callback
- );
-
- let licenseXhrSetup = this._licenseXhrSetup;
- if (licenseXhrSetup) {
- try {
- licenseXhrSetup.call(this.hls, xhr, url);
- licenseXhrSetup = undefined;
- } catch (e) {
- logger.error(e);
- }
- }
- try {
- // if licenseXhrSetup did not yet call open, let's do it now
- if (!xhr.readyState) {
- xhr.open('POST', url, true);
- }
- if (licenseXhrSetup) {
- licenseXhrSetup.call(this.hls, xhr, url);
- }
- } catch (e) {
- // IE11 throws an exception on xhr.open if attempting to access an HTTP resource over HTTPS
- throw new Error(`issue setting up KeySystem license XHR ${e}`);
- }
-
- return xhr;
- }
-
- /**
- * @private
- * @param {XMLHttpRequest} xhr
- * @param {string} url License server URL
- * @param {ArrayBuffer} keyMessage Message data issued by key-system
- * @param {function} callback Called when XHR has succeeded
- */
- private _onLicenseRequestReadyStageChange(
- xhr: XMLHttpRequest,
- url: string,
- keyMessage: ArrayBuffer,
- callback: (data: ArrayBuffer) => void
- ) {
- switch (xhr.readyState) {
- case 4:
- if (xhr.status === 200) {
- this._requestLicenseFailureCount = 0;
- logger.log('License request succeeded');
- let data: ArrayBuffer = xhr.response;
- const licenseResponseCallback = this._licenseResponseCallback;
- if (licenseResponseCallback) {
- try {
- data = licenseResponseCallback.call(this.hls, xhr, url);
- } catch (e) {
- logger.error(e);
- }
- }
- callback(data);
- } else {
- logger.error(
- `License Request XHR failed (${url}). Status: ${xhr.status} (${xhr.statusText})`
- );
- this._requestLicenseFailureCount++;
- if (this._requestLicenseFailureCount > MAX_LICENSE_REQUEST_FAILURES) {
- this.hls.trigger(Events.ERROR, {
- type: ErrorTypes.KEY_SYSTEM_ERROR,
- details: ErrorDetails.KEY_SYSTEM_LICENSE_REQUEST_FAILED,
- fatal: true,
- });
- return;
- }
-
- const attemptsLeft =
- MAX_LICENSE_REQUEST_FAILURES - this._requestLicenseFailureCount + 1;
- logger.warn(
- `Retrying license request, ${attemptsLeft} attempts left`
- );
- this._requestLicense(keyMessage, callback);
- }
- break;
- }
- }
-
- /**
- * @private
- * @param {MediaKeysListItem} keysListItem
- * @param {ArrayBuffer} keyMessage
- * @returns {ArrayBuffer} Challenge data posted to license server
- * @throws if KeySystem is unsupported
- */
- private _generateLicenseRequestChallenge(
- keysListItem: MediaKeysListItem,
- keyMessage: ArrayBuffer
- ): ArrayBuffer {
- switch (keysListItem.mediaKeySystemDomain) {
- // case KeySystems.PLAYREADY:
- // from https://github.com/MicrosoftEdge/Demos/blob/master/eme/scripts/demo.js
- /*
- if (this.licenseType !== this.LICENSE_TYPE_WIDEVINE) {
- // For PlayReady CDMs, we need to dig the Challenge out of the XML.
- var keyMessageXml = new DOMParser().parseFromString(String.fromCharCode.apply(null, new Uint16Array(keyMessage)), 'application/xml');
- if (keyMessageXml.getElementsByTagName('Challenge')[0]) {
- challenge = atob(keyMessageXml.getElementsByTagName('Challenge')[0].childNodes[0].nodeValue);
- } else {
- throw 'Cannot find <Challenge> in key message';
- }
- var headerNames = keyMessageXml.getElementsByTagName('name');
- var headerValues = keyMessageXml.getElementsByTagName('value');
- if (headerNames.length !== headerValues.length) {
- throw 'Mismatched header <name>/<value> pair in key message';
- }
- for (var i = 0; i < headerNames.length; i++) {
- xhr.setRequestHeader(headerNames[i].childNodes[0].nodeValue, headerValues[i].childNodes[0].nodeValue);
- }
- }
- break;
- */
- case KeySystems.WIDEVINE:
- // For Widevine CDMs, the challenge is the keyMessage.
- return keyMessage;
- }
-
- throw new Error(
- `unsupported key-system: ${keysListItem.mediaKeySystemDomain}`
- );
- }
-
- /**
- * @private
- * @param keyMessage
- * @param callback
- */
- private _requestLicense(
- keyMessage: ArrayBuffer,
- callback: (data: ArrayBuffer) => void
- ) {
- logger.log('Requesting content license for key-system');
-
- const keysListItem = this._mediaKeysList[0];
- if (!keysListItem) {
- logger.error(
- 'Fatal error: Media is encrypted but no key-system access has been obtained yet'
- );
- this.hls.trigger(Events.ERROR, {
- type: ErrorTypes.KEY_SYSTEM_ERROR,
- details: ErrorDetails.KEY_SYSTEM_NO_ACCESS,
- fatal: true,
- });
- return;
- }
-
- try {
- const url = this.getLicenseServerUrl(keysListItem.mediaKeySystemDomain);
- const xhr = this._createLicenseXhr(url, keyMessage, callback);
- logger.log(`Sending license request to URL: ${url}`);
- const challenge = this._generateLicenseRequestChallenge(
- keysListItem,
- keyMessage
- );
- xhr.send(challenge);
- } catch (e) {
- logger.error(`Failure requesting DRM license: ${e}`);
- this.hls.trigger(Events.ERROR, {
- type: ErrorTypes.KEY_SYSTEM_ERROR,
- details: ErrorDetails.KEY_SYSTEM_LICENSE_REQUEST_FAILED,
- fatal: true,
- });
- }
- }
-
- onMediaAttached(event: Events.MEDIA_ATTACHED, data: MediaAttachedData) {
- if (!this._emeEnabled) {
- return;
- }
-
- const media = data.media;
-
- // keep reference of media
- this._media = media;
-
- media.addEventListener('encrypted', this._onMediaEncrypted);
- }
-
- onMediaDetached() {
- const media = this._media;
- const mediaKeysList = this._mediaKeysList;
- if (!media) {
- return;
- }
- media.removeEventListener('encrypted', this._onMediaEncrypted);
- this._media = null;
- this._mediaKeysList = [];
- // Close all sessions and remove media keys from the video element.
- Promise.all(
- mediaKeysList.map((mediaKeysListItem) => {
- if (mediaKeysListItem.mediaKeysSession) {
- return mediaKeysListItem.mediaKeysSession.close().catch(() => {
- // Ignore errors when closing the sessions. Closing a session that
- // generated no key requests will throw an error.
- });
- }
- })
- )
- .then(() => {
- return media.setMediaKeys(null);
- })
- .catch(() => {
- // Ignore any failures while removing media keys from the video element.
- });
- }
-
- onManifestParsed(event: Events.MANIFEST_PARSED, data: ManifestParsedData) {
- if (!this._emeEnabled) {
- return;
- }
-
- const audioCodecs = data.levels
- .map((level) => level.audioCodec)
- .filter(
- (audioCodec: string | undefined): audioCodec is string => !!audioCodec
- );
- const videoCodecs = data.levels
- .map((level) => level.videoCodec)
- .filter(
- (videoCodec: string | undefined): videoCodec is string => !!videoCodec
- );
-
- this._attemptKeySystemAccess(KeySystems.WIDEVINE, audioCodecs, videoCodecs);
- }
- }
-
- export default EMEController;