From 4e2ae386241131bedb854c1f51193c253c0da994 Mon Sep 17 00:00:00 2001 From: Jamie Stackhouse Date: Thu, 10 Jan 2019 18:16:53 -0400 Subject: [PATCH 1/2] Convert eme-controller to TypeScript. Added TypeScript types to the code. Handled media detached and removing the encrypted event listener. --- .eslintrc.js | 1 + .../{eme-controller.js => eme-controller.ts} | 332 +++++++++--------- 2 files changed, 176 insertions(+), 157 deletions(-) rename src/controller/{eme-controller.js => eme-controller.ts} (56%) diff --git a/.eslintrc.js b/.eslintrc.js index db84e90afd4..70e5a613c8a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,5 +1,6 @@ module.exports = { 'env': { + 'browser': true, 'commonjs': true, 'es6': true }, diff --git a/src/controller/eme-controller.js b/src/controller/eme-controller.ts similarity index 56% rename from src/controller/eme-controller.js rename to src/controller/eme-controller.ts index d387ff9c487..176577d9bcc 100644 --- a/src/controller/eme-controller.js +++ b/src/controller/eme-controller.ts @@ -10,17 +10,15 @@ import { ErrorTypes, ErrorDetails } from '../errors'; import { logger } from '../utils/logger'; -const { XMLHttpRequest } = window; - const MAX_LICENSE_REQUEST_FAILURES = 3; /** * @see https://developer.mozilla.org/en-US/docs/Web/API/Navigator/requestMediaKeySystemAccess */ -const KeySystems = { - WIDEVINE: 'com.widevine.alpha', - PLAYREADY: 'com.microsoft.playready' -}; +enum KeySystems { + WIDEVINE = 'com.widevine.alpha', + PLAYREADY = 'com.microsoft.playready', +} /** * @see https://developer.mozilla.org/en-US/docs/Web/API/MediaKeySystemConfiguration @@ -30,20 +28,18 @@ const KeySystems = { * @returns {Array} An array of supported configurations */ -const createWidevineMediaKeySystemConfigurations = function (audioCodecs, videoCodecs, drmSystemOptions) { /* jshint ignore:line */ - const baseConfig = { +const createWidevineMediaKeySystemConfigurations = function (audioCodecs: string[], videoCodecs: string[]): MediaKeySystemConfiguration[] { /* jshint ignore:line */ + const baseConfig: MediaKeySystemConfiguration = { // initDataTypes: ['keyids', 'mp4'], // label: "", // persistentState: "not-allowed", // or "required" ? // distinctiveIdentifier: "not-allowed", // or "required" ? // sessionTypes: ['temporary'], - videoCapabilities: [ - // { contentType: 'video/mp4; codecs="avc1.42E01E"' } - ] + videoCapabilities: [] // { contentType: 'video/mp4; codecs="avc1.42E01E"' } }; videoCodecs.forEach((codec) => { - baseConfig.videoCapabilities.push({ + baseConfig.videoCapabilities!.push({ contentType: `video/mp4; codecs="${codec}"` }); }); @@ -57,22 +53,31 @@ const createWidevineMediaKeySystemConfigurations = function (audioCodecs, videoC * 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 or returning null. + * 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} audioCodecs List of required audio codecs to support * @param {Array} videoCodecs List of required video codecs to support - * @returns {Array | null} A non-empty Array of MediaKeySystemConfiguration objects or `null` + * @throws will throw an error if a unknown key system is passed + * @returns {Array} A non-empty Array of MediaKeySystemConfiguration objects */ -const getSupportedMediaKeySystemConfigurations = function (keySystem, audioCodecs, videoCodecs) { +const getSupportedMediaKeySystemConfigurations = function (keySystem: KeySystems, audioCodecs: string[], videoCodecs: string[]): MediaKeySystemConfiguration[] { switch (keySystem) { case KeySystems.WIDEVINE: return createWidevineMediaKeySystemConfigurations(audioCodecs, videoCodecs); default: - throw Error('Unknown key-system: ' + keySystem); + 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 @@ -81,6 +86,16 @@ const getSupportedMediaKeySystemConfigurations = function (keySystem, audioCodec * @constructor */ class EMEController extends EventHandler { + private _widevineLicenseUrl: string; + private _licenseXhrSetup: (xhr: XMLHttpRequest, url: string) => void; + private _emeEnabled: boolean; + private _requestMediaKeySystemAccess: (keySystem: KeySystems, supportedConfigurations: MediaKeySystemConfiguration[]) => Promise + + private _mediaKeysList: MediaKeysListItem[] = [] + private _media: HTMLMediaElement | null = null; + private _hasSetMediaKeys: boolean = false; + private _requestLicenseFailureCount: number = 0; + /** * @constructs * @param {Hls} hls Our Hls.js instance @@ -94,44 +109,21 @@ class EMEController extends EventHandler { this._widevineLicenseUrl = hls.config.widevineLicenseUrl; this._licenseXhrSetup = hls.config.licenseXhrSetup; this._emeEnabled = hls.config.emeEnabled; - this._requestMediaKeySystemAccess = hls.config.requestMediaKeySystemAccessFunc; - - this._mediaKeysList = []; - this._media = null; - - this._hasSetMediaKeys = false; - this._isMediaEncrypted = false; - - this._requestLicenseFailureCount = 0; } /** - * - * @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) - */ - getLicenseServerUrl (keySystem) { - let url; + * @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: - url = this._widevineLicenseUrl; - break; - default: - url = null; - break; - } - - if (!url) { - logger.error(`No license server URL configured for key-system "${keySystem}"`); - this.hls.trigger(Event.ERROR, { - type: ErrorTypes.KEY_SYSTEM_ERROR, - details: ErrorDetails.KEY_SYSTEM_LICENSE_REQUEST_FAILED, - fatal: true - }); + return this._widevineLicenseUrl; } - return url; + throw new Error(`no license server URL configured for key-system "${keySystem}"`); } /** @@ -140,17 +132,14 @@ class EMEController extends EventHandler { * @param {string} keySystem System ID (see `KeySystems`) * @param {Array} audioCodecs List of required audio codecs to support * @param {Array} videoCodecs List of required video codecs to support + * @throws When a unsupported KeySystem is passed */ - _attemptKeySystemAccess (keySystem, audioCodecs, videoCodecs) { + private _attemptKeySystemAccess (keySystem: KeySystems, audioCodecs: string[], videoCodecs: string[]) { // TODO: add other DRM "options" + // This can throw, but is caught in event handler callpath const mediaKeySystemConfigs = getSupportedMediaKeySystemConfigurations(keySystem, audioCodecs, videoCodecs); - if (!mediaKeySystemConfigs) { - logger.warn('Can not create config for key-system (maybe because platform is not supported):', keySystem); - return; - } - logger.log('Requesting encrypted media key-system access'); // expecting interface like window.navigator.requestMediaKeySystemAccess @@ -173,16 +162,14 @@ class EMEController extends EventHandler { /** * Handles obtaining access to a key-system - * + * @private * @param {string} keySystem * @param {MediaKeySystemAccess} mediaKeySystemAccess https://developer.mozilla.org/en-US/docs/Web/API/MediaKeySystemAccess */ - _onMediaKeySystemAccessObtained (keySystem, mediaKeySystemAccess) { + private _onMediaKeySystemAccessObtained (keySystem: KeySystems, mediaKeySystemAccess: MediaKeySystemAccess) { logger.log(`Access for key-system "${keySystem}" obtained`); - const mediaKeysListItem = { - mediaKeys: null, - mediaKeysSession: null, + const mediaKeysListItem: MediaKeysListItem = { mediaKeysSessionInitialized: false, mediaKeySystemAccess: mediaKeySystemAccess, mediaKeySystemDomain: keySystem @@ -204,52 +191,68 @@ class EMEController extends EventHandler { } /** - * 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. - */ - _onMediaKeysCreated () { + * 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) { - mediaKeysListItem.mediaKeysSession = mediaKeysListItem.mediaKeys.createSession(); + // mediaKeys is definitely initialized here + mediaKeysListItem.mediaKeysSession = mediaKeysListItem.mediaKeys!.createSession(); this._onNewMediaKeySession(mediaKeysListItem.mediaKeysSession); } }); } /** - * + * @private * @param {*} keySession */ - _onNewMediaKeySession (keySession) { + private _onNewMediaKeySession (keySession: MediaKeySession) { logger.log(`New key-system session ${keySession.sessionId}`); - keySession.addEventListener('message', (event) => { + keySession.addEventListener('message', (event: MediaKeyMessageEvent) => { this._onKeySessionMessage(keySession, event.message); }, false); } - _onKeySessionMessage (keySession, message) { + /** + * @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) => { + this._requestLicense(message, (data: ArrayBuffer) => { logger.log('Received license data, updating key-session'); keySession.update(data); }); } - _onMediaEncrypted (initDataType, initData) { - logger.log(`Media is encrypted using "${initDataType}" init data type`); - - this._isMediaEncrypted = true; - this._mediaEncryptionInitDataType = initDataType; - this._mediaEncryptionInitData = initData; + /** + * @private + * @param {string} initDataType + * @param {ArrayBuffer|null} initData + */ + private _onMediaEncrypted = (e: MediaEncryptedEvent) => { + logger.log(`Media is encrypted using "${e.initDataType}" init data type`); this._attemptSetMediaKeys(); - this._generateRequestWithPreferredKeySession(); + this._generateRequestWithPreferredKeySession(e.initDataType, e.initData); } - _attemptSetMediaKeys () { + /** + * @private + */ + private _attemptSetMediaKeys () { + 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]; @@ -270,7 +273,10 @@ class EMEController extends EventHandler { } } - _generateRequestWithPreferredKeySession () { + /** + * @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) { @@ -296,13 +302,10 @@ class EMEController extends EventHandler { details: ErrorDetails.KEY_SYSTEM_NO_SESSION, fatal: true }); + return; } - const initDataType = this._mediaEncryptionInitDataType; - const initData = this._mediaEncryptionInitData; - logger.log(`Generating key-session request for "${initDataType}" init data type`); - keysListItem.mediaKeysSessionInitialized = true; keySession.generateRequest(initDataType, initData) @@ -320,12 +323,14 @@ class EMEController extends EventHandler { } /** - * @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 - */ - _createLicenseXhr (url, keyMessage, callback) { + * @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(); const licenseXhrSetup = this._licenseXhrSetup; @@ -345,15 +350,10 @@ class EMEController extends EventHandler { } } catch (e) { // IE11 throws an exception on xhr.open if attempting to access an HTTP resource over HTTPS - logger.error('Error setting up key-system license XHR', e); - this.hls.trigger(Event.ERROR, { - type: ErrorTypes.KEY_SYSTEM_ERROR, - details: ErrorDetails.KEY_SYSTEM_LICENSE_REQUEST_FAILED, - fatal: true - }); - return; + throw new Error(`issue setting up KeySystem license XHR ${e}`); } + // Because we set responseType to ArrayBuffer here, callback is typed as handling only array buffers xhr.responseType = 'arraybuffer'; xhr.onreadystatechange = this._onLicenseRequestReadyStageChange.bind(this, xhr, url, keyMessage, callback); @@ -361,82 +361,88 @@ class EMEController extends EventHandler { } /** - * @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 - * - */ - _onLicenseRequestReadyStageChange (xhr, url, keyMessage, callback) { + * @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'); + + if (xhr.responseType !== 'arraybuffer') { + logger.warn('xhr response type was not set to the expected arraybuffer for license request'); + } callback(xhr.response); } else { logger.error(`License Request XHR failed (${url}). Status: ${xhr.status} (${xhr.statusText})`); - this._requestLicenseFailureCount++; - if (this._requestLicenseFailureCount <= MAX_LICENSE_REQUEST_FAILURES) { - const attemptsLeft = MAX_LICENSE_REQUEST_FAILURES - this._requestLicenseFailureCount + 1; - logger.warn(`Retrying license request, ${attemptsLeft} attempts left`); - this._requestLicense(keyMessage, callback); + if (this._requestLicenseFailureCount > MAX_LICENSE_REQUEST_FAILURES) { + this.hls.trigger(Event.ERROR, { + type: ErrorTypes.KEY_SYSTEM_ERROR, + details: ErrorDetails.KEY_SYSTEM_LICENSE_REQUEST_FAILED, + fatal: true + }); return; } - this.hls.trigger(Event.ERROR, { - type: ErrorTypes.KEY_SYSTEM_ERROR, - details: ErrorDetails.KEY_SYSTEM_LICENSE_REQUEST_FAILED, - fatal: true - }); + const attemptsLeft = MAX_LICENSE_REQUEST_FAILURES - this._requestLicenseFailureCount + 1; + logger.warn(`Retrying license request, ${attemptsLeft} attempts left`); + this._requestLicense(keyMessage, callback); } break; } } /** - * @param {object} keysListItem - * @param {ArrayBuffer} keyMessage - * @returns {ArrayBuffer} Challenge data posted to license server - */ - _generateLicenseRequestChallenge (keysListItem, keyMessage) { - let challenge; - - if (keysListItem.mediaKeySystemDomain === KeySystems.PLAYREADY) { - logger.error('PlayReady is not supported (yet)'); - - // 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 in key message'; - } - var headerNames = keyMessageXml.getElementsByTagName('name'); - var headerValues = keyMessageXml.getElementsByTagName('value'); - if (headerNames.length !== headerValues.length) { - throw 'Mismatched header / pair in key message'; - } - for (var i = 0; i < headerNames.length; i++) { - xhr.setRequestHeader(headerNames[i].childNodes[0].nodeValue, headerValues[i].childNodes[0].nodeValue); - } + * @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 in key message'; + } + var headerNames = keyMessageXml.getElementsByTagName('name'); + var headerValues = keyMessageXml.getElementsByTagName('value'); + if (headerNames.length !== headerValues.length) { + throw 'Mismatched header / pair in key message'; } - */ - } else if (keysListItem.mediaKeySystemDomain === KeySystems.WIDEVINE) { + 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. - challenge = keyMessage; - } else { - logger.error('Unsupported key-system:', keysListItem.mediaKeySystemDomain); + return keyMessage; } - return challenge; + throw new Error(`unsupported key-system: ${keysListItem.mediaKeySystemDomain}`); } - _requestLicense (keyMessage, callback) { + /** + * @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]; @@ -450,15 +456,23 @@ class EMEController extends EventHandler { return; } - const url = this.getLicenseServerUrl(keysListItem.mediaKeySystemDomain); - const xhr = this._createLicenseXhr(url, keyMessage, callback); - - logger.log(`Sending license request to URL: ${url}`); - - xhr.send(this._generateLicenseRequestChallenge(keysListItem, keyMessage)); + 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(Event.ERROR, { + type: ErrorTypes.KEY_SYSTEM_ERROR, + details: ErrorDetails.KEY_SYSTEM_LICENSE_REQUEST_FAILED, + fatal: true + }); + } } - onMediaAttached (data) { + onMediaAttached (data: { media: HTMLMediaElement; }) { if (!this._emeEnabled) { return; } @@ -468,14 +482,18 @@ class EMEController extends EventHandler { // keep reference of media this._media = media; - // FIXME: also handle detaching media ! + media.addEventListener('encrypted', this._onMediaEncrypted); + } - media.addEventListener('encrypted', (e) => { - this._onMediaEncrypted(e.initDataType, e.initData); - }); + onMediaDetached () { + if (this._media) { + this._media.removeEventListener('encrypted', this._onMediaEncrypted); + this._media = null; // release reference + } } - onManifestParsed (data) { + // TODO: Use manifest types here when they are defined + onManifestParsed (data: any) { if (!this._emeEnabled) { return; } From 63db40fc87f05c850bcae23a4a0b4b461d836547 Mon Sep 17 00:00:00 2001 From: Jamie Stackhouse <1956521+itsjamie@users.noreply.github.com> Date: Wed, 23 Jan 2019 04:53:22 -0400 Subject: [PATCH 2/2] Listen to media detached Clears the encrypted event listener from the HTMLMediaElement in controller, and releases the stored reference. --- src/controller/eme-controller.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/controller/eme-controller.ts b/src/controller/eme-controller.ts index 176577d9bcc..df27575d5bc 100644 --- a/src/controller/eme-controller.ts +++ b/src/controller/eme-controller.ts @@ -103,6 +103,7 @@ class EMEController extends EventHandler { constructor (hls) { super(hls, Event.MEDIA_ATTACHED, + Event.MEDIA_DETACHED, Event.MANIFEST_PARSED );