From b98bf1178eb224cf4caa47b570c51bef72bf7496 Mon Sep 17 00:00:00 2001 From: Vincent Valot Date: Tue, 28 Dec 2021 14:46:00 +0100 Subject: [PATCH] Add support for com.apple.fps keySystem --- README.md | 5 +- api-extractor/report/hls.js.api.md | 38 ++-- docs/API.md | 17 ++ src/config.ts | 25 ++- src/controller/eme-controller.ts | 281 ++++++++++++++++++------ src/errors.ts | 3 + src/loader/m3u8-parser.ts | 4 +- src/utils/mediakeys-helper.ts | 1 + tests/unit/controller/eme-controller.js | 167 ++++++++++++++ 9 files changed, 460 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index 125d8a06e71..888d3afc8ca 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,6 @@ The following tags are added to their respective fragment's attribute list but a For a complete list of issues, see ["Top priorities" in the Release Planning and Backlog project tab](https://github.com/video-dev/hls.js/projects/6). Codec support is dependent on the runtime environment (for example, not all browsers on the same OS support HEVC). -- FairPlay and PlayReady DRM ( See [#3779](https://github.com/video-dev/hls.js/issues/2360) and [issues labeled DRM](https://github.com/video-dev/hls.js/issues?q=is%3Aissue+is%3Aopen+label%3ADRM)) - Advanced variant selection based on runtime media capabilities (See issues labeled [`media-capabilities`](https://github.com/video-dev/hls.js/labels/media-capabilities)) - HLS Content Steering - HLS Interstitials @@ -121,6 +120,10 @@ For a complete list of issues, see ["Top priorities" in the Release Planning and - `#EXT-X-GAP` filling [#2940](https://github.com/video-dev/hls.js/issues/2940) - `#EXT-X-I-FRAME-STREAM-INF` I-frame Media Playlist files - `SAMPLE-AES` with fmp4, aac, mp3, vtt... segments (MPEG-2 TS only) +- FairPlay DRM with MPEG-2 TS content +- PlayReady (See [#3779](https://github.com/video-dev/hls.js/issues/3779) and [issues labeled DRM](https://github.com/video-dev/hls.js/issues?q=is%3Aissue+is%3Aopen+label%3ADRM)) +- Advanced variant selection based on runtime media capabilities (See issues labeled [`media-capabilities`](https://github.com/video-dev/hls.js/labels/media-capabilities)) +- MP3 elementary stream audio in IE and Edge (<=18) on Windows 10 (See [#1641](https://github.com/video-dev/hls.js/issues/1641) and [Microsoft answers forum](https://answers.microsoft.com/en-us/ie/forum/all/ie11-on-windows-10-cannot-play-hls-with-mp3/2da994b5-8dec-4ae9-9201-7d138ede49d9)) ### Server-side-rendering (SSR) and `require` from a Node.js runtime diff --git a/api-extractor/report/hls.js.api.md b/api-extractor/report/hls.js.api.md index 1d199c20130..c06f1dc2cd2 100644 --- a/api-extractor/report/hls.js.api.md +++ b/api-extractor/report/hls.js.api.md @@ -352,10 +352,11 @@ export enum ElementaryStreamTypes { // // @public (undocumented) export type EMEControllerConfig = { - licenseXhrSetup?: (xhr: XMLHttpRequest, url: string) => void; - licenseResponseCallback?: (xhr: XMLHttpRequest, url: string) => ArrayBuffer; + licenseXhrSetup?: (xhr: XMLHttpRequest, url: string, keySystem: KeySystems) => void | Promise; + licenseResponseCallback?: (xhr: XMLHttpRequest, url: string, keySystem: KeySystems) => ArrayBuffer; emeEnabled: boolean; widevineLicenseUrl?: string; + drmSystems: DRMSystemsConfiguration; drmSystemOptions: DRMSystemOptions; requestMediaKeySystemAccessFunc: MediaKeyFunc | null; }; @@ -457,6 +458,12 @@ export enum ErrorDetails { // (undocumented) KEY_SYSTEM_NO_SESSION = "keySystemNoSession", // (undocumented) + KEY_SYSTEM_SERVER_CERTIFICATE_REQUEST_FAILED = "keySystemServerCertificateRequestFailed", + // (undocumented) + KEY_SYSTEM_SERVER_CERTIFICATE_UPDATE_FAILED = "keySystemServerCertificateUpdateFailed", + // (undocumented) + KEY_SYSTEM_SESSION_UPDATE_FAILED = "keySystemSessionUpdateFailed", + // (undocumented) LEVEL_EMPTY_ERROR = "levelEmptyError", // (undocumented) LEVEL_LOAD_ERROR = "levelLoadError", @@ -1241,6 +1248,8 @@ export interface KeyLoadingData { // // @public (undocumented) export enum KeySystems { + // (undocumented) + FAIRPLAY = "com.apple.fps", // (undocumented) PLAYREADY = "com.microsoft.playready", // (undocumented) @@ -2234,18 +2243,19 @@ export interface UserdataSample { // Warnings were encountered during analysis: // -// src/config.ts:169:3 - (ae-forgotten-export) The symbol "ILogger" needs to be exported by the entry point hls.d.ts -// src/config.ts:179:3 - (ae-forgotten-export) The symbol "AudioStreamController" needs to be exported by the entry point hls.d.ts -// src/config.ts:180:3 - (ae-forgotten-export) The symbol "AudioTrackController" needs to be exported by the entry point hls.d.ts -// src/config.ts:182:3 - (ae-forgotten-export) The symbol "SubtitleStreamController" needs to be exported by the entry point hls.d.ts -// src/config.ts:183:3 - (ae-forgotten-export) The symbol "SubtitleTrackController" needs to be exported by the entry point hls.d.ts -// src/config.ts:184:3 - (ae-forgotten-export) The symbol "TimelineController" needs to be exported by the entry point hls.d.ts -// src/config.ts:186:3 - (ae-forgotten-export) The symbol "EMEController" needs to be exported by the entry point hls.d.ts -// src/config.ts:189:3 - (ae-forgotten-export) The symbol "CMCDController" needs to be exported by the entry point hls.d.ts -// src/config.ts:191:3 - (ae-forgotten-export) The symbol "AbrController" needs to be exported by the entry point hls.d.ts -// src/config.ts:192:3 - (ae-forgotten-export) The symbol "BufferController" needs to be exported by the entry point hls.d.ts -// src/config.ts:193:3 - (ae-forgotten-export) The symbol "CapLevelController" needs to be exported by the entry point hls.d.ts -// src/config.ts:194:3 - (ae-forgotten-export) The symbol "FPSController" needs to be exported by the entry point hls.d.ts +// src/config.ts:84:3 - (ae-forgotten-export) The symbol "DRMSystemsConfiguration" needs to be exported by the entry point hls.d.ts +// src/config.ts:187:3 - (ae-forgotten-export) The symbol "ILogger" needs to be exported by the entry point hls.d.ts +// src/config.ts:197:3 - (ae-forgotten-export) The symbol "AudioStreamController" needs to be exported by the entry point hls.d.ts +// src/config.ts:198:3 - (ae-forgotten-export) The symbol "AudioTrackController" needs to be exported by the entry point hls.d.ts +// src/config.ts:200:3 - (ae-forgotten-export) The symbol "SubtitleStreamController" needs to be exported by the entry point hls.d.ts +// src/config.ts:201:3 - (ae-forgotten-export) The symbol "SubtitleTrackController" needs to be exported by the entry point hls.d.ts +// src/config.ts:202:3 - (ae-forgotten-export) The symbol "TimelineController" needs to be exported by the entry point hls.d.ts +// src/config.ts:204:3 - (ae-forgotten-export) The symbol "EMEController" needs to be exported by the entry point hls.d.ts +// src/config.ts:207:3 - (ae-forgotten-export) The symbol "CMCDController" needs to be exported by the entry point hls.d.ts +// src/config.ts:209:3 - (ae-forgotten-export) The symbol "AbrController" needs to be exported by the entry point hls.d.ts +// src/config.ts:210:3 - (ae-forgotten-export) The symbol "BufferController" needs to be exported by the entry point hls.d.ts +// src/config.ts:211:3 - (ae-forgotten-export) The symbol "CapLevelController" needs to be exported by the entry point hls.d.ts +// src/config.ts:212:3 - (ae-forgotten-export) The symbol "FPSController" needs to be exported by the entry point hls.d.ts // (No @packageDocumentation comment for this package) diff --git a/docs/API.md b/docs/API.md index ddbd625c1dd..749c01d40d8 100644 --- a/docs/API.md +++ b/docs/API.md @@ -97,6 +97,7 @@ - [`widevineLicenseUrl`](#widevineLicenseUrl) - [`licenseXhrSetup`](#licenseXhrSetup) - [`licenseResponseCallback`](#licenseResponseCallback) + - [`drmSystems`](#drmSystems) - [`drmSystemOptions`](#drmSystemOptions) - [`requestMediaKeySystemAccessFunc`](#requestMediaKeySystemAccessFunc) - [`cmcd`](#cmcd) @@ -400,6 +401,7 @@ var config = { emeEnabled: false, widevineLicenseUrl: undefined, licenseXhrSetup: undefined, + drmSystems: {}, drmSystemOptions: {}, requestMediaKeySystemAccessFunc: requestMediaKeySystemAccess, cmcd: undefined, @@ -1222,6 +1224,21 @@ var config = { A post-processor function for modifying the license response before passing it to the key-session (`MediaKeySession.update`). +### `drmSystems` + +(default: `{}`) + +Set `licenseUrl` and `serverCertificateUrl` for a given keySystem to your own DRM provider. `serverCertificateUrl` is not mandatory. Ex: + +```js +{ + 'com.widevine.alpha': { + licenseUrl: 'https://proxy.uat.widevine.com/proxy', + serverCertificateUrl: 'https://storage.googleapis.com/wvmedia/cert/cert_license_widevine_com_uat.bin' + } +} +``` + ### `drmSystemOptions` (default: `{}`) diff --git a/src/config.ts b/src/config.ts index 27798f2acd3..a2a45a70b79 100644 --- a/src/config.ts +++ b/src/config.ts @@ -16,7 +16,7 @@ import { requestMediaKeySystemAccess } from './utils/mediakeys-helper'; import { ILogger, logger } from './utils/logger'; import type { CuesInterface } from './utils/cues'; -import type { MediaKeyFunc } from './utils/mediakeys-helper'; +import type { MediaKeyFunc, KeySystems } from './utils/mediakeys-helper'; import type { FragmentLoaderContext, Loader, @@ -59,11 +59,29 @@ export type DRMSystemOptions = { videoRobustness?: string; }; +export type DRMSystemConfiguration = { + licenseUrl: string; + serverCertificateUrl?: string; +}; + +export type DRMSystemsConfiguration = Partial< + Record +>; + export type EMEControllerConfig = { - licenseXhrSetup?: (xhr: XMLHttpRequest, url: string) => void; - licenseResponseCallback?: (xhr: XMLHttpRequest, url: string) => ArrayBuffer; + licenseXhrSetup?: ( + xhr: XMLHttpRequest, + url: string, + keySystem: KeySystems + ) => void | Promise; + licenseResponseCallback?: ( + xhr: XMLHttpRequest, + url: string, + keySystem: KeySystems + ) => ArrayBuffer; emeEnabled: boolean; widevineLicenseUrl?: string; + drmSystems: DRMSystemsConfiguration; drmSystemOptions: DRMSystemOptions; requestMediaKeySystemAccessFunc: MediaKeyFunc | null; }; @@ -283,6 +301,7 @@ export const hlsDefaultConfig: HlsConfig = { minAutoBitrate: 0, // used by hls emeEnabled: false, // used by eme-controller widevineLicenseUrl: undefined, // used by eme-controller + drmSystems: {}, // used by eme-controller drmSystemOptions: {}, // used by eme-controller requestMediaKeySystemAccessFunc: requestMediaKeySystemAccess, // used by eme-controller testBandwidth: true, diff --git a/src/controller/eme-controller.ts b/src/controller/eme-controller.ts index 3db81779337..4cf5783c079 100644 --- a/src/controller/eme-controller.ts +++ b/src/controller/eme-controller.ts @@ -6,14 +6,20 @@ import { Events } from '../events'; import { ErrorTypes, ErrorDetails } from '../errors'; -import { logger } from '../utils/logger'; -import type { DRMSystemOptions, EMEControllerConfig } from '../config'; +import { logger, enableLogs } from '../utils/logger'; +import type { + DRMSystemOptions, + DRMSystemsConfiguration, + 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'; +enableLogs(true); + const MAX_LICENSE_REQUEST_FAILURES = 3; /** @@ -24,18 +30,19 @@ const MAX_LICENSE_REQUEST_FAILURES = 3; * @returns {Array} An array of supported configurations */ -const createWidevineMediaKeySystemConfigurations = function ( +const createMediaKeySystemConfigurations = function ( + initDataType: string, audioCodecs: string[], videoCodecs: string[], drmSystemOptions: DRMSystemOptions ): MediaKeySystemConfiguration[] { /* jshint ignore:line */ const baseConfig: MediaKeySystemConfiguration = { - // initDataTypes: ['keyids', 'mp4'], + initDataTypes: [initDataType], // label: "", - // persistentState: "not-allowed", // or "required" ? - // distinctiveIdentifier: "not-allowed", // or "required" ? - // sessionTypes: ['temporary'], + 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"' } }; @@ -76,7 +83,15 @@ const getSupportedMediaKeySystemConfigurations = function ( ): MediaKeySystemConfiguration[] { switch (keySystem) { case KeySystems.WIDEVINE: - return createWidevineMediaKeySystemConfigurations( + return createMediaKeySystemConfigurations( + 'cenc', + audioCodecs, + videoCodecs, + drmSystemOptions + ); + case KeySystems.FAIRPLAY: + return createMediaKeySystemConfigurations( + 'sinf', audioCodecs, videoCodecs, drmSystemOptions @@ -104,10 +119,16 @@ interface MediaKeysListItem { class EMEController implements ComponentAPI { private hls: Hls; private _widevineLicenseUrl?: string; - private _licenseXhrSetup?: (xhr: XMLHttpRequest, url: string) => void; + private _drmSystems: DRMSystemsConfiguration; + private _licenseXhrSetup?: ( + xhr: XMLHttpRequest, + url: string, + keySystem: KeySystems + ) => void | Promise; private _licenseResponseCallback?: ( xhr: XMLHttpRequest, - url: string + url: string, + keySystem: KeySystems ) => ArrayBuffer; private _emeEnabled: boolean; private _requestMediaKeySystemAccess: MediaKeyFunc | null; @@ -131,6 +152,7 @@ class EMEController implements ComponentAPI { this._config = hls.config; this._widevineLicenseUrl = this._config.widevineLicenseUrl; + this._drmSystems = this._config.drmSystems; this._licenseXhrSetup = this._config.licenseXhrSetup; this._licenseResponseCallback = this._config.licenseResponseCallback; this._emeEnabled = this._config.emeEnabled; @@ -166,12 +188,15 @@ class EMEController implements ComponentAPI { * @throws if a unsupported keysystem is passed */ getLicenseServerUrl(keySystem: KeySystems): string { - switch (keySystem) { - case KeySystems.WIDEVINE: - if (!this._widevineLicenseUrl) { - break; - } - return this._widevineLicenseUrl; + const keySystemConfiguration = this._drmSystems[keySystem]; + + if (keySystemConfiguration) { + return keySystemConfiguration.licenseUrl; + } + + // For backward compatibility + if (keySystem === KeySystems.WIDEVINE && this._widevineLicenseUrl) { + return this._widevineLicenseUrl; } throw new Error( @@ -179,6 +204,16 @@ class EMEController implements ComponentAPI { ); } + getServerCertificateUrl(keySystem: KeySystems): string | undefined { + const keySystemConfiguration = this._drmSystems[keySystem]; + + if (keySystemConfiguration) { + return keySystemConfiguration.serverCertificateUrl; + } + + return undefined; + } + /** * Requests access object and adds it to our list upon success * @private @@ -253,9 +288,13 @@ class EMEController implements ComponentAPI { logger.log(`Media-keys created for key-system "${keySystem}"`); - this._onMediaKeysCreated(); + return this._fetchAndSetServerCertificate(mediaKeysListItem).then( + () => { + this._onMediaKeysCreated(); - return mediaKeys; + return mediaKeys; + } + ); }); mediaKeysPromise.catch((err) => { @@ -316,8 +355,16 @@ class EMEController implements ComponentAPI { data ? data.byteLength : data }), updating key-session` ); - keySession.update(data).catch((err) => { - logger.warn(`Updating key-session failed: ${err}`); + + keySession.update(data).catch((error) => { + logger.error('Fatal: KeySession rejected data update', error); + + this.hls.trigger(Events.ERROR, { + type: ErrorTypes.KEY_SYSTEM_ERROR, + details: ErrorDetails.KEY_SYSTEM_SESSION_UPDATE_FAILED, + fatal: true, + error, + }); }); }); } @@ -456,6 +503,117 @@ class EMEController implements ComponentAPI { }); }); } + /** + * @private + * @param {MediaKeysListItem} mediaKeysListItem + * @returns Promise + */ + private _fetchAndSetServerCertificate( + mediaKeysListItem: MediaKeysListItem + ): Promise { + const url = this.getServerCertificateUrl( + mediaKeysListItem.mediaKeySystemDomain + ); + + if (!url) { + return Promise.resolve(); + } + + logger.log( + `Fetching serverCertificate for ${mediaKeysListItem.mediaKeySystemDomain} keySystem` + ); + + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + + xhr.open('GET', url, true); + xhr.responseType = 'arraybuffer'; + + xhr.onreadystatechange = () => { + switch (xhr.readyState) { + case XMLHttpRequest.DONE: + if (xhr.status === 200) { + mediaKeysListItem.mediaKeys + ?.setServerCertificate(xhr.response) + .then(() => { + logger.log('serverCertificate successfully fetched and set'); + + resolve(); + }) + .catch((error) => { + this.hls.trigger(Events.ERROR, { + type: ErrorTypes.KEY_SYSTEM_ERROR, + details: + ErrorDetails.KEY_SYSTEM_SERVER_CERTIFICATE_UPDATE_FAILED, + fatal: true, + error, + }); + + reject(error); + }); + } else { + logger.error( + `HTTP error ${xhr.status} happened while fetching server certificate` + ); + + this.hls.trigger(Events.ERROR, { + type: ErrorTypes.KEY_SYSTEM_ERROR, + details: + ErrorDetails.KEY_SYSTEM_SERVER_CERTIFICATE_REQUEST_FAILED, + fatal: true, + }); + + reject(new Error(xhr.response)); + } + break; + } + }; + + xhr.send(); + }); + } + + /** + * @private + * @param {XMLHttpRequest} xhr + * @param {string} url + * @returns Promise + */ + private _setupLicenseXHR = ( + xhr: XMLHttpRequest, + url: string, + keysListItem: MediaKeysListItem + ): Promise => { + const licenseXhrSetup = this._licenseXhrSetup; + + if (!licenseXhrSetup) { + xhr.open('POST', url, true); + + return Promise.resolve(); + } + + return Promise.resolve( + licenseXhrSetup(xhr, url, keysListItem.mediaKeySystemDomain) + ) + .catch(() => { + // let's try to open before running setup + xhr.open('POST', url, true); + + return licenseXhrSetup(xhr, url, keysListItem.mediaKeySystemDomain); + }) + .then(() => { + // if licenseXhrSetup did not yet call open, let's do it now + if (!xhr.readyState) { + xhr.open('POST', url, true); + } + }) + .catch((e) => { + // IE11 throws an exception on xhr.open if attempting to access an HTTP resource over HTTPS + return Promise.reject( + new Error(`issue setting up KeySystem license XHR ${e}`) + ); + }); + }; /** * @private @@ -466,43 +624,26 @@ class EMEController implements ComponentAPI { * @throws if XMLHttpRequest construction failed */ private _createLicenseXhr( - url: string, + keysListItem: MediaKeysListItem, keyMessage: ArrayBuffer, callback: (data: ArrayBuffer) => void - ): XMLHttpRequest { + ): Promise { + const url = this.getLicenseServerUrl(keysListItem.mediaKeySystemDomain); + + logger.log(`Sending license request to URL: ${url}`); + const xhr = new XMLHttpRequest(); xhr.responseType = 'arraybuffer'; xhr.onreadystatechange = this._onLicenseRequestReadyStageChange.bind( this, xhr, url, + keysListItem, 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; + return this._setupLicenseXHR(xhr, url, keysListItem).then(() => xhr); } /** @@ -515,6 +656,7 @@ class EMEController implements ComponentAPI { private _onLicenseRequestReadyStageChange( xhr: XMLHttpRequest, url: string, + keysListItem: MediaKeysListItem, keyMessage: ArrayBuffer, callback: (data: ArrayBuffer) => void ) { @@ -527,7 +669,12 @@ class EMEController implements ComponentAPI { const licenseResponseCallback = this._licenseResponseCallback; if (licenseResponseCallback) { try { - data = licenseResponseCallback.call(this.hls, xhr, url); + data = licenseResponseCallback.call( + this.hls, + xhr, + url, + keysListItem.mediaKeySystemDomain + ); } catch (e) { logger.error(e); } @@ -592,8 +739,9 @@ class EMEController implements ComponentAPI { } break; */ + // For Widevine and Fairplay CDMs, the challenge is the keyMessage. + case KeySystems.FAIRPLAY: case KeySystems.WIDEVINE: - // For Widevine CDMs, the challenge is the keyMessage. return keyMessage; } @@ -602,6 +750,16 @@ class EMEController implements ComponentAPI { ); } + private _onLicenseRequestError(error) { + logger.error(`Failure requesting DRM license: ${error}`); + + this.hls.trigger(Events.ERROR, { + type: ErrorTypes.KEY_SYSTEM_ERROR, + details: ErrorDetails.KEY_SYSTEM_LICENSE_REQUEST_FAILED, + fatal: true, + }); + } + /** * @private * @param keyMessage @@ -627,21 +785,19 @@ class EMEController implements ComponentAPI { } 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); + this._createLicenseXhr(keysListItem, keyMessage, callback) + .then((xhr) => { + const challenge = this._generateLicenseRequestChallenge( + keysListItem, + keyMessage + ); + xhr.send(challenge); + }) + .catch((error) => { + this._onLicenseRequestError(error); + }); } 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, - }); + this._onLicenseRequestError(e); } } @@ -702,7 +858,8 @@ class EMEController implements ComponentAPI { (videoCodec: string | undefined): videoCodec is string => !!videoCodec ); - this._attemptKeySystemAccess(KeySystems.WIDEVINE, audioCodecs, videoCodecs); + // TBD We should try a keySystem based on manifest information + this._attemptKeySystemAccess(KeySystems.FAIRPLAY, audioCodecs, videoCodecs); } } diff --git a/src/errors.ts b/src/errors.ts index 7552f0bd0f1..be459182628 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -20,7 +20,10 @@ export enum ErrorDetails { KEY_SYSTEM_NO_ACCESS = 'keySystemNoAccess', KEY_SYSTEM_NO_SESSION = 'keySystemNoSession', KEY_SYSTEM_LICENSE_REQUEST_FAILED = 'keySystemLicenseRequestFailed', + KEY_SYSTEM_SERVER_CERTIFICATE_REQUEST_FAILED = 'keySystemServerCertificateRequestFailed', + KEY_SYSTEM_SERVER_CERTIFICATE_UPDATE_FAILED = 'keySystemServerCertificateUpdateFailed', KEY_SYSTEM_NO_INIT_DATA = 'keySystemNoInitData', + KEY_SYSTEM_SESSION_UPDATE_FAILED = 'keySystemSessionUpdateFailed', // Identifier for a manifest load error - data: { url : faulty URL, response : { code: error code, text: error text }} MANIFEST_LOAD_ERROR = 'manifestLoadError', // Identifier for a manifest load timeout - data: { url : faulty URL, response : { code: error code, text: error text }} diff --git a/src/loader/m3u8-parser.ts b/src/loader/m3u8-parser.ts index 8fc7f51cc76..df6c4fb7632 100644 --- a/src/loader/m3u8-parser.ts +++ b/src/loader/m3u8-parser.ts @@ -377,8 +377,10 @@ export default class M3U8Parser { const decryptkeyformat = keyAttrs.enumeratedString('KEYFORMAT') ?? 'identity'; + // TBD we might need to check if we try to MSE playback mp2t content + // with com.apple.streamingkeydelivery keyformat (not supported) + const unsupportedKnownKeyformatsInManifest = [ - 'com.apple.streamingkeydelivery', 'com.microsoft.playready', 'urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed', // widevine (v2) 'com.widevine', // earlier widevine (v1) diff --git a/src/utils/mediakeys-helper.ts b/src/utils/mediakeys-helper.ts index 4428b519e89..fbad2414b3c 100644 --- a/src/utils/mediakeys-helper.ts +++ b/src/utils/mediakeys-helper.ts @@ -4,6 +4,7 @@ export enum KeySystems { WIDEVINE = 'com.widevine.alpha', PLAYREADY = 'com.microsoft.playready', + FAIRPLAY = 'com.apple.fps', } export type MediaKeyFunc = ( diff --git a/tests/unit/controller/eme-controller.js b/tests/unit/controller/eme-controller.js index 32a19be2391..2abbc189362 100644 --- a/tests/unit/controller/eme-controller.js +++ b/tests/unit/controller/eme-controller.js @@ -32,6 +32,23 @@ const setupEach = function (config) { emeController = new EMEController(new HlsMock(config)); }; +const setupXHRMock = function () { + const xhr = { + readyState: 0, + + open: () => {}, + send: () => {}, + }; + + self.XMLHttpRequest = function () { + return xhr; + }; + + self.XMLHttpRequest.DONE = 4; + + return xhr; +}; + describe('EMEController', function () { beforeEach(function () { setupEach(); @@ -155,6 +172,156 @@ describe('EMEController', function () { }, 0); }); + it('should fetch the server certificate and set it into the session', function (done) { + const xhr = setupXHRMock(); + + const mediaKeysSetServerCertificateSpy = sinon.spy(() => Promise.resolve()); + + const reqMediaKsAccessSpy = sinon.spy(function () { + return Promise.resolve({ + // Media-keys mock + createMediaKeys: sinon.spy(() => + Promise.resolve({ + setServerCertificate: mediaKeysSetServerCertificateSpy, + createSession: () => ({ + addEventListener: () => {}, + }), + }) + ), + }); + }); + + setupEach({ + emeEnabled: true, + requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy, + drmSystems: { + 'com.apple.fps': { + serverCertificateUrl: 'https://example.com/certificate.cer', + }, + }, + }); + + emeController.onMediaAttached(Events.MEDIA_ATTACHED, { media }); + emeController.onManifestParsed(Events.MANIFEST_PARSED, { levels: [] }); + + self.setTimeout(() => { + xhr.status = 200; + xhr.readyState = XMLHttpRequest.DONE; + xhr.response = new Uint8Array(); + xhr.onreadystatechange(); + }, 0); + + emeController.mediaKeysPromise.finally(() => { + expect(mediaKeysSetServerCertificateSpy).to.have.been.calledOnce; + expect(mediaKeysSetServerCertificateSpy.args[0][0]).to.equal( + xhr.response + ); + + done(); + }); + }); + + it('should fetch the server certificate and trigger update failed error', function (done) { + const xhr = setupXHRMock(); + + const mediaKeysSetServerCertificateSpy = sinon.spy(() => + Promise.reject(new Error('Failed')) + ); + + const reqMediaKsAccessSpy = sinon.spy(function () { + return Promise.resolve({ + // Media-keys mock + createMediaKeys: sinon.spy(() => + Promise.resolve({ + setServerCertificate: mediaKeysSetServerCertificateSpy, + createSession: () => ({ + addEventListener: () => {}, + }), + }) + ), + }); + }); + + setupEach({ + emeEnabled: true, + requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy, + drmSystems: { + 'com.apple.fps': { + serverCertificateUrl: 'https://example.com/certificate.cer', + }, + }, + }); + + emeController.onMediaAttached(Events.MEDIA_ATTACHED, { media }); + emeController.onManifestParsed(Events.MANIFEST_PARSED, { levels: [] }); + + self.setTimeout(() => { + xhr.status = 200; + xhr.readyState = XMLHttpRequest.DONE; + xhr.response = new Uint8Array(); + xhr.onreadystatechange(); + }, 0); + + emeController.mediaKeysPromise.finally(() => { + expect(mediaKeysSetServerCertificateSpy).to.have.been.calledOnce; + expect(mediaKeysSetServerCertificateSpy.args[0][0]).to.equal( + xhr.response + ); + + expect(emeController.hls.trigger).to.have.been.calledOnce; + expect(emeController.hls.trigger.args[0][1].details).to.equal( + ErrorDetails.KEY_SYSTEM_SERVER_CERTIFICATE_UPDATE_FAILED + ); + + done(); + }); + }); + + it('should fetch the server certificate and trigger request failed error', function (done) { + const xhr = setupXHRMock(); + + const reqMediaKsAccessSpy = sinon.spy(function () { + return Promise.resolve({ + // Media-keys mock + createMediaKeys: sinon.spy(() => + Promise.resolve({ + createSession: () => ({ + addEventListener: () => {}, + }), + }) + ), + }); + }); + + setupEach({ + emeEnabled: true, + requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy, + drmSystems: { + 'com.apple.fps': { + serverCertificateUrl: 'https://example.com/certificate.cer', + }, + }, + }); + + emeController.onMediaAttached(Events.MEDIA_ATTACHED, { media }); + emeController.onManifestParsed(Events.MANIFEST_PARSED, { levels: [] }); + + self.setTimeout(() => { + xhr.status = 400; + xhr.readyState = XMLHttpRequest.DONE; + xhr.onreadystatechange(); + }, 0); + + emeController.mediaKeysPromise.finally(() => { + expect(emeController.hls.trigger).to.have.been.calledOnce; + expect(emeController.hls.trigger.args[0][1].details).to.equal( + ErrorDetails.KEY_SYSTEM_SERVER_CERTIFICATE_REQUEST_FAILED + ); + + done(); + }); + }); + it('should close all media key sessions and remove media keys when media is detached', function (done) { const reqMediaKsAccessSpy = sinon.spy(function () { return Promise.resolve({