From b5427b54ec156eae79a295006075f705fb168740 Mon Sep 17 00:00:00 2001 From: Vincent Valot Date: Tue, 28 Dec 2021 14:46:00 +0100 Subject: [PATCH 01/19] 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 6860e1b66d4..b6e1d80a2b3 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) @@ -2236,18 +2245,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 dd74cd6aa26..0898a7fee50 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({ From 554e93e0b8b9fc1d700b4ea717350961175f9928 Mon Sep 17 00:00:00 2001 From: Rob Walch Date: Tue, 9 Aug 2022 11:28:11 -0700 Subject: [PATCH 02/19] Improve support for DRM key-systems and key handling Resolves #2833 #2737 #4318 #4538 --- README.md | 5 +- docs/API.md | 84 +- src/config.ts | 20 +- src/controller/abr-controller.ts | 22 +- src/controller/audio-stream-controller.ts | 15 +- src/controller/base-stream-controller.ts | 36 +- src/controller/eme-controller.ts | 1560 ++++++++++------- src/controller/level-controller.ts | 8 + src/controller/stream-controller.ts | 19 +- src/controller/subtitle-stream-controller.ts | 16 +- src/controller/timeline-controller.ts | 7 +- src/demux/transmuxer.ts | 73 +- src/errors.ts | 2 + src/events.ts | 2 +- src/hls.ts | 55 +- src/loader/fragment.ts | 101 +- src/loader/key-loader.ts | 420 +++-- src/loader/level-key.ts | 255 ++- src/loader/m3u8-parser.ts | 122 +- src/loader/playlist-loader.ts | 5 +- src/remux/passthrough-remuxer.ts | 12 +- src/types/events.ts | 2 + src/types/loader.ts | 6 +- src/types/remuxer.ts | 4 +- src/utils/keysystem-util.ts | 48 + src/utils/mediakeys-helper.ts | 134 +- src/utils/mp4-tools.ts | 134 +- src/utils/numeric-encoding-utils.ts | 26 + .../controller/audio-stream-controller.js | 12 +- tests/unit/controller/eme-controller.js | 355 ---- tests/unit/controller/eme-controller.ts | 591 +++++++ .../controller/subtitle-stream-controller.js | 7 +- tests/unit/loader/fragment-loader.ts | 2 +- .../unit/loader/{fragment.js => fragment.ts} | 87 +- ...{playlist-loader.js => playlist-loader.ts} | 220 ++- 35 files changed, 2911 insertions(+), 1556 deletions(-) create mode 100644 src/utils/keysystem-util.ts create mode 100644 src/utils/numeric-encoding-utils.ts delete mode 100644 tests/unit/controller/eme-controller.js create mode 100644 tests/unit/controller/eme-controller.ts rename tests/unit/loader/{fragment.js => fragment.ts} (60%) rename tests/unit/loader/{playlist-loader.js => playlist-loader.ts} (90%) diff --git a/README.md b/README.md index 888d3afc8ca..ebaae7ed2c4 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ HLS.js is written in [ECMAScript6] (`*.js`) and [TypeScript] (`*.ts`) (strongly - AES-128 decryption - SAMPLE-AES decryption (only supported if using MPEG-2 TS container) - Encrypted media extensions (EME) support for DRM (digital rights management) - - Widevine CDM (only tested with [shaka-packager](https://github.com/google/shaka-packager) test-stream on [the demo page](https://hls-js.netlify.app/demo/?src=https%3A%2F%2Fstorage.googleapis.com%2Fshaka-demo-assets%2Fangel-one-widevine-hls%2Fhls.m3u8&demoConfig=eyJlbmFibGVTdHJlYW1pbmciOnRydWUsImF1dG9SZWNvdmVyRXJyb3IiOnRydWUsInN0b3BPblN0YWxsIjpmYWxzZSwiZHVtcGZNUDQiOmZhbHNlLCJsZXZlbENhcHBpbmciOi0xLCJsaW1pdE1ldHJpY3MiOi0xfQ==)) + - FairPlay, PlayReady, Widevine CDMs with fmp4 segments - CEA-608/708 captions - WebVTT subtitles - Alternate Audio Track Rendition (Master Playlist with Alternative Audio) for VoD and Live playlists @@ -120,8 +120,7 @@ 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)) +- FairPlay, PlayReady, Widevine DRM with MPEG-2 TS segments - 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)) diff --git a/docs/API.md b/docs/API.md index 0898a7fee50..bd60663ebf0 100644 --- a/docs/API.md +++ b/docs/API.md @@ -94,6 +94,7 @@ - [`abrMaxWithRealBitrate`](#abrmaxwithrealbitrate) - [`minAutoBitrate`](#minautobitrate) - [`emeEnabled`](#emeEnabled) + - [`useEmeEncryptedEvent`](#useEmeEncryptedEvent) - [`widevineLicenseUrl`](#widevineLicenseUrl) - [`licenseXhrSetup`](#licenseXhrSetup) - [`licenseResponseCallback`](#licenseResponseCallback) @@ -399,6 +400,7 @@ var config = { maxLoadingDelay: 4, minAutoBitrate: 0, emeEnabled: false, + useEmeEncryptedEvent: false, widevineLicenseUrl: undefined, licenseXhrSetup: undefined, drmSystems: {}, @@ -1192,6 +1194,12 @@ Useful when browser or tab of the browser is not in the focus and bandwidth drop Set to `true` to enable DRM key system access and license retrieval. +### `useEmeEncryptedEvent` + +(default: `false`) + +Set to `true` to use media "encrypted" event initData and ignore manifest DRM keys. + ### `widevineLicenseUrl` (default: `undefined`) @@ -1200,45 +1208,82 @@ The Widevine license server URL. ### `licenseXhrSetup` -(default: `undefined`, type `(xhr: XMLHttpRequest, url: string) => void`) +(default: `undefined`, type `(xhr: XMLHttpRequest, url: string, keyContext: MediaKeySessionContext, licenseChallenge: Uint8Array) => void`) -A pre-processor function for modifying the `XMLHttpRequest` and request url (using `xhr.open`) prior to sending the license request. +A pre-processor function for modifying license requests. The license request URL, request headers, and payload can all be modified prior to sending the license request, based on operating conditions, the current key-session, and key-system. ```js var config = { - licenseXhrSetup: function (xhr, url) { - xhr.withCredentials = true; // do send cookies - if (!xhr.readyState) { - // Call open to change the method (default is POST) or modify the url - xhr.open('GET', url, true); - // Append headers after opening + licenseXhrSetup: function (xhr, url, keyContext, licenseChallenge) { + let payload = licenseChallenge; + + // Send cookies with request + xhr.withCredentials = true; + + // Call open to change the method (default is POST), modify the url, or set request headers + xhr.open('POST', url, true); + + // call xhr.setRequestHeader after xhr.open otherwise licenseXhrSetup will throw and be called a second time after HLS.js call xhr.open + if (keyContext.keySystem === 'com.apple.fps') { + xhr.setRequestHeader('Content-Type', 'application/json'); + payload = JSON.stringify({ + keyData: base64Encode(keyContext.decryptdata?.keyId), + licenseChallenge: base64Encode(licenseChallenge), + }); + } else { xhr.setRequestHeader('Content-Type', 'application/octet-stream'); } + + // Return the desired payload or a Promise + // return Promise.resolve(payload); + return payload; }, }; ``` ### `licenseResponseCallback` -(default: `undefined`, type `(xhr: XMLHttpRequest, url: string) => data: ArrayBuffer`) +(default: `undefined`, type `(xhr: XMLHttpRequest, url: string, keyContext: MediaKeySessionContext) => data: ArrayBuffer`) A post-processor function for modifying the license response before passing it to the key-session (`MediaKeySession.update`). +```js +var config = { + licenseResponseCallback: function (xhr, url, keyContext) { + const keySystem = keyContext.keySystem; + const response = xhr.response; + if (keyContext.keySystem === 'com.apple.fps') { + try { + const responseObject = JSON.parse( + new TextDecoder().decode(response).trim(); + ); + const keyResponse = responseObject['fairplay-streaming-response']['streaming-keys'][0]; + return base64Decode(keyResponse.ckc); + } catch (error) { + console.error(error); + } + } + return response; + } +``` + ### `drmSystems` (default: `{}`) -Set `licenseUrl` and `serverCertificateUrl` for a given keySystem to your own DRM provider. `serverCertificateUrl` is not mandatory. Ex: +Set `licenseUrl` and `serverCertificateUrl` for a given key-system to your own DRM provider. `serverCertificateUrl` is not mandatory. Ex: ```js -{ +drmSystems: { 'com.widevine.alpha': { - licenseUrl: 'https://proxy.uat.widevine.com/proxy', - serverCertificateUrl: 'https://storage.googleapis.com/wvmedia/cert/cert_license_widevine_com_uat.bin' + licenseUrl: 'https://your-widevine-license-server/path', + serverCertificateUrl: 'https://optional-server-certificate/path/cert.bin' } } ``` +Supported key-systems include 'com.apple.fps', 'com.microsoft.playready', 'com.widevine.alpha', and 'org.w3.clearkey'. Mapping to other values in key-system access requests can be done by customizing [`requestMediaKeySystemAccessFunc`](#requestMediaKeySystemAccessFunc). + ### `drmSystemOptions` (default: `{}`) @@ -1258,7 +1303,18 @@ With the default argument, `''` will be specified for each option (_i.e. no spec (default: A function that returns the result of `window.navigator.requestMediaKeySystemAccess.bind(window.navigator)` or `null`) -Allows for the customization of `window.navigator.requestMediaKeySystemAccess`. +Allows for the customization of `window.navigator.requestMediaKeySystemAccess`. This can be used to map key-system access request to from a supported value to a custom one: + +```js +var hls new Hls({ + requestMediaKeySystemAccessFunc: (keySystem, supportedConfigurations) => { + if (keySystem === 'com.microsoft.playready') { + keySystem = 'com.microsoft.playready.recommendation'; + } + return navigator.requestMediaKeySystemAccess(keySystem, supportedConfigurations); + } +}); +``` ### `cmcd` diff --git a/src/config.ts b/src/config.ts index a2a45a70b79..df8f8a1091d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -7,7 +7,9 @@ import BufferController from './controller/buffer-controller'; import { TimelineController } from './controller/timeline-controller'; import CapLevelController from './controller/cap-level-controller'; import FPSController from './controller/fps-controller'; -import EMEController from './controller/eme-controller'; +import EMEController, { + MediaKeySessionContext, +} from './controller/eme-controller'; import CMCDController from './controller/cmcd-controller'; import XhrLoader from './utils/xhr-loader'; import FetchLoader, { fetchSupported } from './utils/fetch-loader'; @@ -15,6 +17,7 @@ import Cues from './utils/cues'; import { requestMediaKeySystemAccess } from './utils/mediakeys-helper'; import { ILogger, logger } from './utils/logger'; +import type Hls from './hls'; import type { CuesInterface } from './utils/cues'; import type { MediaKeyFunc, KeySystems } from './utils/mediakeys-helper'; import type { @@ -57,6 +60,10 @@ export type CMCDControllerConfig = { export type DRMSystemOptions = { audioRobustness?: string; videoRobustness?: string; + persistentState?: MediaKeysRequirement; + distinctiveIdentifier?: MediaKeysRequirement; + sessionTypes?: string[]; + sessionType?: string; }; export type DRMSystemConfiguration = { @@ -70,16 +77,20 @@ export type DRMSystemsConfiguration = Partial< export type EMEControllerConfig = { licenseXhrSetup?: ( + this: Hls, xhr: XMLHttpRequest, url: string, - keySystem: KeySystems - ) => void | Promise; + keyContext: MediaKeySessionContext, + licenseChallenge: Uint8Array + ) => void | Promise; licenseResponseCallback?: ( + this: Hls, xhr: XMLHttpRequest, url: string, - keySystem: KeySystems + keyContext: MediaKeySessionContext ) => ArrayBuffer; emeEnabled: boolean; + useEmeEncryptedEvent: boolean; widevineLicenseUrl?: string; drmSystems: DRMSystemsConfiguration; drmSystemOptions: DRMSystemOptions; @@ -300,6 +311,7 @@ export const hlsDefaultConfig: HlsConfig = { maxLoadingDelay: 4, // used by abr-controller minAutoBitrate: 0, // used by hls emeEnabled: false, // used by eme-controller + useEmeEncryptedEvent: false, // used by eme-controller widevineLicenseUrl: undefined, // used by eme-controller drmSystems: {}, // used by eme-controller drmSystemOptions: {}, // used by eme-controller diff --git a/src/controller/abr-controller.ts b/src/controller/abr-controller.ts index 082ad398292..b7fbd415c5a 100644 --- a/src/controller/abr-controller.ts +++ b/src/controller/abr-controller.ts @@ -1,7 +1,7 @@ import EwmaBandWidthEstimator from '../utils/ewma-bandwidth-estimator'; import { Events } from '../events'; import { BufferHelper } from '../utils/buffer-helper'; -import { ErrorDetails } from '../errors'; +import { ErrorDetails, ErrorTypes } from '../errors'; import { PlaylistLevelType } from '../types/loader'; import { logger } from '../utils/logger'; import type { Bufferable } from '../utils/buffer-helper'; @@ -277,13 +277,21 @@ class AbrController implements ComponentAPI { protected onError(event: Events.ERROR, data: ErrorData) { // stop timer in case of frag loading error - switch (data.details) { - case ErrorDetails.FRAG_LOAD_ERROR: - case ErrorDetails.FRAG_LOAD_TIMEOUT: + if (data.frag?.type === PlaylistLevelType.MAIN) { + if (data.type === ErrorTypes.KEY_SYSTEM_ERROR) { this.clearTimer(); - break; - default: - break; + return; + } + switch (data.details) { + case ErrorDetails.FRAG_LOAD_ERROR: + case ErrorDetails.FRAG_LOAD_TIMEOUT: + case ErrorDetails.KEY_LOAD_ERROR: + case ErrorDetails.KEY_LOAD_TIMEOUT: + this.clearTimer(); + break; + default: + break; + } } } diff --git a/src/controller/audio-stream-controller.ts b/src/controller/audio-stream-controller.ts index bbf1663b8a4..28d332005ed 100644 --- a/src/controller/audio-stream-controller.ts +++ b/src/controller/audio-stream-controller.ts @@ -10,11 +10,12 @@ import TransmuxerInterface from '../demux/transmuxer-interface'; import { ChunkMetadata } from '../types/transmuxer'; import { fragmentWithinToleranceTest } from './fragment-finders'; import { alignMediaPlaylistByPDT } from '../utils/discontinuities'; -import { ErrorDetails } from '../errors'; +import { ErrorDetails, ErrorTypes } from '../errors'; import type { NetworkComponentAPI } from '../types/component-api'; +import type Hls from '../hls'; import type { FragmentTracker } from './fragment-tracker'; +import type KeyLoader from '../loader/key-loader'; import type { TransmuxerResult } from '../types/transmuxer'; -import type Hls from '../hls'; import type { LevelDetails } from '../loader/level-details'; import type { TrackSet } from '../types/track'; import type { @@ -56,8 +57,12 @@ class AudioStreamController private bufferFlushed: boolean = false; private cachedTrackLoadedData: TrackLoadedData | null = null; - constructor(hls: Hls, fragmentTracker: FragmentTracker) { - super(hls, fragmentTracker, '[audio-stream-controller]'); + constructor( + hls: Hls, + fragmentTracker: FragmentTracker, + keyLoader: KeyLoader + ) { + super(hls, fragmentTracker, keyLoader, '[audio-stream-controller]'); this._registerListeners(); } @@ -624,6 +629,8 @@ class AudioStreamController case ErrorDetails.FRAG_LOAD_TIMEOUT: case ErrorDetails.KEY_LOAD_ERROR: case ErrorDetails.KEY_LOAD_TIMEOUT: + case ErrorDetails.KEY_SYSTEM_NO_SESSION: + case ErrorDetails.KEY_SYSTEM_STATUS_OUTPUT_RESTRICTED: // TODO: Skip fragments that do not belong to this.fragCurrent audio-group id this.onFragmentOrKeyLoadError(PlaylistLevelType.AUDIO, data); break; diff --git a/src/controller/base-stream-controller.ts b/src/controller/base-stream-controller.ts index 20ce34b8cc0..b4a95b57b84 100644 --- a/src/controller/base-stream-controller.ts +++ b/src/controller/base-stream-controller.ts @@ -3,7 +3,7 @@ import { FragmentState } from './fragment-tracker'; import { Bufferable, BufferHelper, BufferInfo } from '../utils/buffer-helper'; import { logger } from '../utils/logger'; import { Events } from '../events'; -import { ErrorDetails } from '../errors'; +import { ErrorDetails, ErrorTypes } from '../errors'; import { ChunkMetadata } from '../types/transmuxer'; import { appendUint8Array } from '../utils/mp4-tools'; import { alignStream } from '../utils/discontinuities'; @@ -99,14 +99,19 @@ export default class BaseStreamController protected log: (msg: any) => void; protected warn: (msg: any) => void; - constructor(hls: Hls, fragmentTracker: FragmentTracker, logPrefix: string) { + constructor( + hls: Hls, + fragmentTracker: FragmentTracker, + keyLoader: KeyLoader, + logPrefix: string + ) { super(); this.logPrefix = logPrefix; this.log = logger.log.bind(logger, `${logPrefix}:`); this.warn = logger.warn.bind(logger, `${logPrefix}:`); this.hls = hls; this.fragmentLoader = new FragmentLoader(hls.config); - this.keyLoader = new KeyLoader(hls.config); + this.keyLoader = keyLoader; this.fragmentTracker = fragmentTracker; this.config = hls.config; this.decrypter = new Decrypter(hls.config); @@ -205,6 +210,9 @@ export default class BaseStreamController media.removeEventListener('ended', this.onvended); this.onvseeking = this.onvended = null; } + if (this.keyLoader) { + this.keyLoader.detach(); + } this.media = this.mediaBuffer = null; this.loadedmetadata = false; this.fragmentTracker.removeAllFragments(); @@ -566,7 +574,7 @@ export default class BaseStreamController this.state = State.KEY_LOADING; this.fragCurrent = frag; keyLoadingPromise = this.keyLoader.load(frag).then((keyLoadedData) => { - if (keyLoadedData && !this.fragContextChanged(keyLoadedData.frag)) { + if (!this.fragContextChanged(keyLoadedData.frag)) { this.hls.trigger(Events.KEY_LOADED, keyLoadedData); return keyLoadedData; } @@ -609,7 +617,7 @@ export default class BaseStreamController .then((keyLoadedData) => { if ( !keyLoadedData || - this.fragContextChanged(keyLoadedData?.frag) + this.fragContextChanged(keyLoadedData.frag) ) { return null; } @@ -727,11 +735,21 @@ export default class BaseStreamController ); } - private handleFragLoadError({ data }: LoadError) { - if (data && data.details === ErrorDetails.INTERNAL_ABORTED) { - this.handleFragLoadAborted(data.frag, data.part); + private handleFragLoadError(error: LoadError | Error) { + if ('data' in error) { + const data = error.data; + if (error.data && data.details === ErrorDetails.INTERNAL_ABORTED) { + this.handleFragLoadAborted(data.frag, data.part); + } else { + this.hls.trigger(Events.ERROR, data as ErrorData); + } } else { - this.hls.trigger(Events.ERROR, data as ErrorData); + this.hls.trigger(Events.ERROR, { + type: ErrorTypes.OTHER_ERROR, + details: ErrorDetails.INTERNAL_EXCEPTION, + err: error, + fatal: true, + }); } return null; } diff --git a/src/controller/eme-controller.ts b/src/controller/eme-controller.ts index 4cf5783c079..7683971d7ac 100644 --- a/src/controller/eme-controller.ts +++ b/src/controller/eme-controller.ts @@ -5,108 +5,50 @@ */ import { Events } from '../events'; import { ErrorTypes, ErrorDetails } from '../errors'; +import { logger } from '../utils/logger'; +import { + getKeySystemsForConfig, + getSupportedMediaKeySystemConfigurations, + keySystemDomainToKeySystemFormat as keySystemToKeySystemFormat, + KeySystemFormats, + keySystemFormatToKeySystemDomain, +} from '../utils/mediakeys-helper'; +import { + KeySystems, + requestMediaKeySystemAccess, +} from '../utils/mediakeys-helper'; +import { strToUtf8array } from '../utils/keysystem-util'; +import { utf8ArrayToStr } from '../demux/id3'; +import { base64Decode, base64Encode } from '../utils/numeric-encoding-utils'; +import { LevelKey } from '../loader/level-key'; -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); +import type { + MediaAttachedData, + KeyLoadedData, + ErrorData, +} from '../types/events'; +import type { EMEControllerConfig } from '../config'; +import type { Fragment } from '../loader/fragment'; +import Hex from '../utils/hex'; const MAX_LICENSE_REQUEST_FAILURES = 3; +const LOGGER_PREFIX = '[eme]'; -/** - * @see https://developer.mozilla.org/en-US/docs/Web/API/MediaKeySystemConfiguration - * @param {Array} audioCodecs List of required audio codecs to support - * @param {Array} videoCodecs List of required video codecs to support - * @param {object} drmSystemOptions Optional parameters/requirements for the key-system - * @returns {Array} An array of supported configurations - */ - -const createMediaKeySystemConfigurations = function ( - initDataType: string, - audioCodecs: string[], - videoCodecs: string[], - drmSystemOptions: DRMSystemOptions -): MediaKeySystemConfiguration[] { - /* jshint ignore:line */ - const baseConfig: MediaKeySystemConfiguration = { - initDataTypes: [initDataType], - // 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} audioCodecs List of required audio codecs to support - * @param {Array} videoCodecs List of required video codecs to support - * @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: KeySystems, - audioCodecs: string[], - videoCodecs: string[], - drmSystemOptions: DRMSystemOptions -): MediaKeySystemConfiguration[] { - switch (keySystem) { - case KeySystems.WIDEVINE: - return createMediaKeySystemConfigurations( - 'cenc', - audioCodecs, - videoCodecs, - drmSystemOptions - ); - case KeySystems.FAIRPLAY: - return createMediaKeySystemConfigurations( - 'sinf', - audioCodecs, - videoCodecs, - drmSystemOptions - ); - default: - throw new Error(`Unknown key-system: ${keySystem}`); - } -}; +interface KeySystemAccessPromises { + keySystemAccess: Promise; + mediaKeys?: Promise; + certificate?: Promise; +} -interface MediaKeysListItem { - mediaKeys?: MediaKeys; - mediaKeysSession?: MediaKeySession; - mediaKeysSessionInitialized: boolean; - mediaKeySystemAccess: MediaKeySystemAccess; - mediaKeySystemDomain: KeySystems; +export interface MediaKeySessionContext { + keySystem: KeySystems; + mediaKeys: MediaKeys; + decryptdata: LevelKey; + mediaKeysSession: MediaKeySession; + keyStatus: MediaKeyStatus; + licenseXhr?: XMLHttpRequest; } /** @@ -117,31 +59,29 @@ interface MediaKeysListItem { * @constructor */ class EMEController implements ComponentAPI { - private hls: Hls; - private _widevineLicenseUrl?: string; - private _drmSystems: DRMSystemsConfiguration; - private _licenseXhrSetup?: ( - xhr: XMLHttpRequest, - url: string, - keySystem: KeySystems - ) => void | Promise; - private _licenseResponseCallback?: ( - xhr: XMLHttpRequest, - url: string, - keySystem: KeySystems - ) => 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; + public static CDMCleanupPromise: Promise | void; + + private readonly hls: Hls; + private readonly config: EMEControllerConfig; + private media: HTMLMediaElement | null = null; + private keyFormatPromise: Promise | null = null; + private keySystemAccessPromises: { + [keysystem: string]: KeySystemAccessPromises; + } = {}; private _requestLicenseFailureCount: number = 0; - - private mediaKeysPromise: Promise | null = null; - private _onMediaEncrypted = this.onMediaEncrypted.bind(this); + private mediaKeySessions: MediaKeySessionContext[] = []; + private keyUriToKeySessionPromise: { + [keyuri: string]: Promise; + } = {}; + private setMediaKeysQueue: Promise[] = EMEController.CDMCleanupPromise + ? [EMEController.CDMCleanupPromise] + : []; + private onMediaEncrypted = this._onMediaEncrypted.bind(this); + private onWaitingForKey = this._onWaitingForKey.bind(this); + + private log: (msg: any) => void = logger.log.bind(logger, LOGGER_PREFIX); + private warn: (msg: any) => void = logger.warn.bind(logger, LOGGER_PREFIX); + private error: (msg: any) => void = logger.error.bind(logger, LOGGER_PREFIX); /** * @constructs @@ -149,54 +89,42 @@ class EMEController implements ComponentAPI { */ constructor(hls: Hls) { this.hls = hls; - 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; - this._requestMediaKeySystemAccess = - this._config.requestMediaKeySystemAccessFunc; - this._drmSystemOptions = this._config.drmSystemOptions; - - this._registerListeners(); + this.config = hls.config; + this.registerListeners(); } public destroy() { - this._unregisterListeners(); + this.unregisterListeners(); + this.onMediaDetached(); // @ts-ignore - this.hls = this._onMediaEncrypted = null; - this._requestMediaKeySystemAccess = null; + this.hls = + this.onMediaEncrypted = + this.onWaitingForKey = + this.keyUriToKeySessionPromise = + null as any; } - private _registerListeners() { + 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() { + 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 { - const keySystemConfiguration = this._drmSystems[keySystem]; + private getLicenseServerUrl(keySystem: KeySystems): string | never { + const { drmSystems, widevineLicenseUrl } = this.config; + const keySystemConfiguration = drmSystems[keySystem]; if (keySystemConfiguration) { return keySystemConfiguration.licenseUrl; } // For backward compatibility - if (keySystem === KeySystems.WIDEVINE && this._widevineLicenseUrl) { - return this._widevineLicenseUrl; + if (keySystem === KeySystems.WIDEVINE && widevineLicenseUrl) { + return widevineLicenseUrl; } throw new Error( @@ -204,662 +132,980 @@ class EMEController implements ComponentAPI { ); } - getServerCertificateUrl(keySystem: KeySystems): string | undefined { - const keySystemConfiguration = this._drmSystems[keySystem]; + private getServerCertificateUrl(keySystem: KeySystems): string | void { + const { drmSystems } = this.config; + const keySystemConfiguration = drmSystems[keySystem]; if (keySystemConfiguration) { return keySystemConfiguration.serverCertificateUrl; + } else { + this.log(`No Server Certificate in config.drmSystems["${keySystem}"]`); + } + } + + private attemptKeySystemAccess( + keySystemsToAttempt: KeySystems[] + ): Promise<{ keySystem: KeySystems; mediaKeys: MediaKeys }> { + const levels = this.hls.levels; + const uniqueCodec = (value: string | undefined, i, a): value is string => + !!value && a.indexOf(value) === i; + const audioCodecs = levels + .map((level) => level.audioCodec) + .filter(uniqueCodec); + const videoCodecs = levels + .map((level) => level.videoCodec) + .filter(uniqueCodec); + if (audioCodecs.length + videoCodecs.length === 0) { + videoCodecs.push('avc1.42e01e'); } - return undefined; + return new Promise( + ( + resolve: (result: { + keySystem: KeySystems; + mediaKeys: MediaKeys; + }) => void, + reject: (Error) => void + ) => { + let attempts = 0; + const catchAll = (error) => { + attempts++; + if (attempts === keySystemsToAttempt.length) { + if (error instanceof EMEKeyError) { + reject(error); + } else { + reject( + new EMEKeyError( + { + type: ErrorTypes.KEY_SYSTEM_ERROR, + details: ErrorDetails.KEY_SYSTEM_NO_ACCESS, + error, + fatal: true, + }, + error.message + ) + ); + } + } + }; + keySystemsToAttempt.forEach((keySystem) => { + this.getMediaKeysPromise(keySystem, audioCodecs, videoCodecs) + .then((mediaKeys) => resolve({ keySystem, mediaKeys })) + .catch(catchAll); + }); + } + ); } - /** - * Requests access object and adds it to our list upon success - * @private - * @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 - */ - private _attemptKeySystemAccess( + private requestMediaKeySystemAccess( + keySystem: KeySystems, + supportedConfigurations: MediaKeySystemConfiguration[] + ): Promise { + const { requestMediaKeySystemAccessFunc } = this.config; + if (!(typeof requestMediaKeySystemAccessFunc === 'function')) { + let errMessage = `Configured requestMediaKeySystemAccess is not a function ${requestMediaKeySystemAccessFunc}`; + if ( + requestMediaKeySystemAccess === null && + self.location.protocol === 'http:' + ) { + errMessage = `navigator.requestMediaKeySystemAccess is not available over insecure protocol ${location.protocol}`; + } + return Promise.reject(new Error(errMessage)); + } + + return requestMediaKeySystemAccessFunc(keySystem, supportedConfigurations); + } + + private getMediaKeysPromise( keySystem: KeySystems, audioCodecs: string[], videoCodecs: string[] - ) { + ): Promise { // 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.config.drmSystemOptions ); + const keySystemAccessPromises: KeySystemAccessPromises = + this.keySystemAccessPromises[keySystem]; + let keySystemAccess = keySystemAccessPromises?.keySystemAccess; + if (!keySystemAccess) { + this.log( + `Requesting encrypted media "${keySystem}" key-system access with config: ${JSON.stringify( + mediaKeySystemConfigs + )}` + ); + keySystemAccess = this.requestMediaKeySystemAccess( + keySystem, + mediaKeySystemConfigs + ); + const keySystemAccessPromises: KeySystemAccessPromises = + (this.keySystemAccessPromises[keySystem] = { + keySystemAccess, + }); + keySystemAccess.catch((error) => { + this.log( + `Failed to obtain access to key-system "${keySystem}": ${error}` + ); + }); + return keySystemAccess.then((mediaKeySystemAccess) => { + this.log( + `Access for key-system "${mediaKeySystemAccess.keySystem}" obtained` + ); - this.mediaKeysPromise = keySystemAccessPromise.then( - (mediaKeySystemAccess) => - this._onMediaKeySystemAccessObtained(keySystem, mediaKeySystemAccess) - ); + const certificateRequest = this.fetchServerCertificate(keySystem); + + this.log(`Create media-keys for "${keySystem}"`); + keySystemAccessPromises.mediaKeys = mediaKeySystemAccess + .createMediaKeys() + .then((mediaKeys) => { + this.log(`Media-keys created for "${keySystem}"`); + return certificateRequest.then((certificate) => { + if (certificate) { + return this.setMediaKeysServerCertificate( + mediaKeys, + keySystem, + certificate + ); + } + return mediaKeys; + }); + }); - keySystemAccessPromise.catch((err) => { - logger.error(`Failed to obtain key-system "${keySystem}" access:`, err); - }); - } + keySystemAccessPromises.mediaKeys.catch((error) => { + this.error( + `Failed to create media-keys for "${keySystem}"}: ${error}` + ); + }); - get requestMediaKeySystemAccess() { - if (!this._requestMediaKeySystemAccess) { - throw new Error('No requestMediaKeySystemAccess function configured'); + return keySystemAccessPromises.mediaKeys; + }); } - - return this._requestMediaKeySystemAccess; + return keySystemAccess.then(() => keySystemAccessPromises.mediaKeys!); } - /** - * 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 { - logger.log(`Access for key-system "${keySystem}" obtained`); + private createMediaKeySessionContext({ + decryptdata, + keySystem, + mediaKeys, + }: { + decryptdata: LevelKey; + keySystem: KeySystems; + mediaKeys: MediaKeys; + }): MediaKeySessionContext { + console.assert(!!mediaKeys, 'mediaKeys is defined'); + + this.log( + `Creating key-system session "${keySystem}" uri: ${decryptdata.uri}` + ); + + const mediaKeysSession = mediaKeys.createSession(); - const mediaKeysListItem: MediaKeysListItem = { - mediaKeysSessionInitialized: false, - mediaKeySystemAccess: mediaKeySystemAccess, - mediaKeySystemDomain: keySystem, + const mediaKeySessionContext: MediaKeySessionContext = { + decryptdata, + keySystem, + mediaKeys, + mediaKeysSession, + keyStatus: 'status-pending', }; - this._mediaKeysList.push(mediaKeysListItem); + this.mediaKeySessions.push(mediaKeySessionContext); - const mediaKeysPromise = Promise.resolve() - .then(() => mediaKeySystemAccess.createMediaKeys()) - .then((mediaKeys) => { - mediaKeysListItem.mediaKeys = mediaKeys; + return mediaKeySessionContext; + } - logger.log(`Media-keys created for key-system "${keySystem}"`); + private renewKeySession(mediaKeySessionContext: MediaKeySessionContext) { + const decryptdata = mediaKeySessionContext.decryptdata; + this.keyUriToKeySessionPromise[decryptdata.uri] = + this.generateRequestWithPreferredKeySession( + mediaKeySessionContext, + 'cenc', + decryptdata.pssh + ); + } - return this._fetchAndSetServerCertificate(mediaKeysListItem).then( - () => { - this._onMediaKeysCreated(); + private handleParsedKeyResponse( + mediaKeySessionContext: MediaKeySessionContext, + licenseResponse: ArrayBuffer + ): Uint8Array { + switch (mediaKeySessionContext.keySystem) { + case KeySystems.FAIRPLAY: { + const responseStr = JSON.stringify([ + { + keyID: base64Encode( + mediaKeySessionContext.decryptdata?.keyId as Uint8Array + ), + payload: base64Encode(new Uint8Array(licenseResponse)), + }, + ]); + this.log(`processLicense msg=${responseStr}`); + return strToUtf8array(responseStr); + } + } + return new Uint8Array(licenseResponse); + } + + private updateKeySession( + mediaKeySessionContext: MediaKeySessionContext, + data: Uint8Array + ): Promise { + const keySession = mediaKeySessionContext.mediaKeysSession; + this.log( + `Updating key-session "${keySession.sessionId}" for ${ + mediaKeySessionContext.decryptdata?.uri + } (data length: ${data ? data.byteLength : data})` + ); + return keySession.update(data); + } - return mediaKeys; + public selectKeySystemFormat(frag: Fragment): Promise { + const keyFormats = Object.keys(frag.levelkeys || {}); + if (!this.keyFormatPromise) { + this.log( + `Selecting key-system from fragment (sn: ${frag.sn} ${frag.type}: ${ + frag.level + }) key formats ${keyFormats.join(', ')}` + ); + this.keyFormatPromise = new Promise((resolve, reject) => { + const keySystemsToAttempt = keyFormats + .map(keySystemFormatToKeySystemDomain) + .filter((value) => !!value) as any as KeySystems[]; + return this.getKeySystemSelectionPromise(keySystemsToAttempt).then( + ({ keySystem }) => { + const keySystemFormat = keySystemToKeySystemFormat(keySystem); + if (keySystemFormat) { + resolve(keySystemFormat); + } else { + reject( + new Error(`Unable to find format for key-system "${keySystem}"`) + ); + } } ); }); - - mediaKeysPromise.catch((err) => { - logger.error('Failed to create media-keys:', err); - }); - - return mediaKeysPromise; + } + return this.keyFormatPromise; } - /** - * 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); - } - }); - } + public loadKey(data: KeyLoadedData): Promise { + const decryptdata = data.keyInfo.decryptdata; - /** - * @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 + this.log( + `Starting session for key ${decryptdata.keyFormat} ${decryptdata.uri}` ); - } - /** - * @private - * @param {MediaKeySession} keySession - * @param {ArrayBuffer} message - */ - private _onKeySessionMessage( - keySession: MediaKeySession, - message: ArrayBuffer - ) { - logger.log('Got EME message event, creating license request'); + if (this.media && !this.config.useEmeEncryptedEvent) { + this.media.removeEventListener('encrypted', this.onMediaEncrypted); + } + + let keySessionContextPromise = + this.keyUriToKeySessionPromise[decryptdata.uri]; + if (!keySessionContextPromise) { + keySessionContextPromise = this.keyUriToKeySessionPromise[ + decryptdata.uri + ] = this.getKeySystemForKeyPromise(decryptdata).then( + ({ keySystem, mediaKeys }) => { + this.throwIfDestroyed(); + this.log( + `Handle encrypted media sn: ${data.frag.sn} ${data.frag.type}: ${data.frag.level} using key (method: ${decryptdata.method} format: "${decryptdata.keyFormat}" uri: ${decryptdata.uri})` + ); - this._requestLicense(message, (data: ArrayBuffer) => { - logger.log( - `Received license data (length: ${ - data ? data.byteLength : data - }), updating key-session` + return this.attemptSetMediaKeys(keySystem, mediaKeys).then(() => { + this.throwIfDestroyed(); + const keySessionContext = this.createMediaKeySessionContext({ + keySystem, + mediaKeys, + decryptdata, + }); + if (this.config.useEmeEncryptedEvent) { + // Use 'encrypted' event initData and type rather than 'cenc' pssh from level-key + return keySessionContext; + } + return this.generateRequestWithPreferredKeySession( + keySessionContext, + 'cenc', + decryptdata.pssh + ); + }); + } ); - keySession.update(data).catch((error) => { - logger.error('Fatal: KeySession rejected data update', error); + keySessionContextPromise.catch((error) => this.handleError(error)); + } - this.hls.trigger(Events.ERROR, { - type: ErrorTypes.KEY_SYSTEM_ERROR, - details: ErrorDetails.KEY_SYSTEM_SESSION_UPDATE_FAILED, - fatal: true, - error, - }); - }); - }); + return keySessionContextPromise; } - /** - * @private - * @param e {MediaEncryptedEvent} - */ - private onMediaEncrypted(e: MediaEncryptedEvent) { - logger.log(`Media is encrypted using "${e.initDataType}" init data type`); + private throwIfDestroyed(message = 'Invalid state'): void | never { + if (!this.hls) { + throw new Error('invalid state'); + } + } - if (!this.mediaKeysPromise) { - logger.error( - 'Fatal: Media is encrypted but no CDM access or no keys have been requested' - ); + private handleError(error: EMEKeyError | Error) { + if (!this.hls) { + return; + } + this.error(error.message); + if (error instanceof EMEKeyError) { + this.hls.trigger(Events.ERROR, error.data); + } else { this.hls.trigger(Events.ERROR, { type: ErrorTypes.KEY_SYSTEM_ERROR, details: ErrorDetails.KEY_SYSTEM_NO_KEYS, + error, 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' + private getKeySystemForKeyPromise( + decryptdata: LevelKey + ): Promise<{ keySystem: KeySystems; mediaKeys: MediaKeys }> { + const mediaKeySessionContext = + this.keyUriToKeySessionPromise[decryptdata.uri]; + if (!mediaKeySessionContext) { + const keySystem = keySystemFormatToKeySystemDomain( + decryptdata.keyFormat as KeySystemFormats ); + const keySystemsToAttempt = keySystem + ? [keySystem] + : getKeySystemsForConfig(this.config); + return this.attemptKeySystemAccess(keySystemsToAttempt); } + return mediaKeySessionContext; + } - 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 getKeySystemSelectionPromise( + keySystemsToAttempt?: KeySystems[] + ): Promise<{ keySystem: KeySystems; mediaKeys: MediaKeys }> { + if (!keySystemsToAttempt || !keySystemsToAttempt.length) { + keySystemsToAttempt = getKeySystemsForConfig(this.config); } + return this.attemptKeySystemAccess(keySystemsToAttempt); } - /** - * @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; - } + private _onMediaEncrypted(event: MediaEncryptedEvent) { + this.log(`"${event.type}" event: init data type: "${event.initDataType}"`); - if (keysListItem.mediaKeysSessionInitialized) { - logger.warn('Key-Session already initialized but requested again'); + if (!this.config.useEmeEncryptedEvent) { 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; + let keySessionContextPromise = this.keyUriToKeySessionPromise.encrypted; + if (!keySessionContextPromise) { + keySessionContextPromise = this.keyUriToKeySessionPromise.encrypted = + this.getKeySystemSelectionPromise().then(({ keySystem, mediaKeys }) => { + this.throwIfDestroyed(); + const sessionParameters = { + decryptdata: new LevelKey( + 'UNKNOWN', + 'encrypted', + keySystemToKeySystemFormat(keySystem) ?? '' + ), + keySystem, + mediaKeys, + }; + return this.attemptSetMediaKeys(keySystem, mediaKeys).then(() => { + this.throwIfDestroyed(); + const keySessionContext = + this.createMediaKeySessionContext(sessionParameters); + return this.generateRequestWithPreferredKeySession( + keySessionContext, + event.initDataType, + event.initData + ); + }); + }); } + keySessionContextPromise.catch((error) => this.handleError(error)); + } + + private _onWaitingForKey(event: Event) { + this.log(`"${event.type}" event`); + } + + private attemptSetMediaKeys( + keySystem: KeySystems, + mediaKeys: MediaKeys + ): Promise { + const queue = this.setMediaKeysQueue.slice(); + + this.log(`Setting media-keys for "${keySystem}"`); + // Only one setMediaKeys() can run at one time, and multiple setMediaKeys() operations + // can be queued for execution for multiple key sessions. + const setMediaKeysPromise = Promise.all(queue).then(() => { + if (!this.media) { + throw new Error( + 'Attempted to set mediaKeys without media element attached' + ); + } + return this.media.setMediaKeys(mediaKeys); + }); + this.setMediaKeysQueue.push(setMediaKeysPromise); + return setMediaKeysPromise.then(() => { + this.log(`Media-keys set for "${keySystem}"`); + queue.push(setMediaKeysPromise!); + this.setMediaKeysQueue = this.setMediaKeysQueue.filter( + (p) => queue.indexOf(p) === -1 + ); + }); + } - // initData is null if the media is not CORS-same-origin + private generateRequestWithPreferredKeySession( + context: MediaKeySessionContext, + initDataType: string, + initData: ArrayBuffer | null + ): Promise | never { if (!initData) { - logger.warn( + throw new EMEKeyError( + { + type: ErrorTypes.KEY_SYSTEM_ERROR, + details: ErrorDetails.KEY_SYSTEM_NO_INIT_DATA, + fatal: true, + }, '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` + this.log( + `Generating key-session request for ${ + context.decryptdata?.uri + } (init data type: ${initDataType} length: ${ + initData ? initData.byteLength : null + })` ); - keysListItem.mediaKeysSessionInitialized = true; - keySession + const licensedPromise = new Promise((resolve, reject) => { + context.mediaKeysSession.onmessage = (event: MediaKeyMessageEvent) => { + if (!context.mediaKeysSession) { + return reject(new Error('invalid state')); + } + const { messageType, message } = event; + this.log( + `"${messageType}" message event for session "${context.mediaKeysSession.sessionId}" message size: ${message.byteLength}` + ); + if ( + messageType === 'license-request' || + messageType === 'license-renewal' + ) { + this.renewLicense(context, message).then(resolve).catch(reject); + } else { + this.warn(`unhandled media key message type "${messageType}"`); + } + }; + }); + + const keyUsablePromise = new Promise( + (resolve: (value: MediaKeySessionContext) => void, reject) => { + context.mediaKeysSession.onkeystatuseschange = ( + event: MediaKeyMessageEvent + ) => { + if (!context.mediaKeysSession) { + return reject(new Error('invalid state')); + } + this.onKeyStatusChange(context); + const keyStatus = context.keyStatus; + if (keyStatus.startsWith('usable')) { + resolve(context); + } else if (keyStatus === 'output-restricted') { + reject( + new EMEKeyError( + { + type: ErrorTypes.KEY_SYSTEM_ERROR, + details: ErrorDetails.KEY_SYSTEM_STATUS_OUTPUT_RESTRICTED, + fatal: false, + }, + 'HDCP level output restricted' + ) + ); + } else if (keyStatus === 'internal-error') { + reject( + new EMEKeyError( + { + type: ErrorTypes.KEY_SYSTEM_ERROR, + details: ErrorDetails.KEY_SYSTEM_STATUS_INTERNAL_ERROR, + fatal: true, + }, + `key status changed to "${keyStatus}"` + ) + ); + } else if (keyStatus === 'expired') { + reject(new Error('key expired while generating request')); + } else { + this.warn(`unhandled key status change "${keyStatus}"`); + } + }; + } + ); + + return context.mediaKeysSession .generateRequest(initDataType, initData) .then(() => { - logger.debug('Key-session generation succeeded'); + this.log( + `Key-session generation succeeded for "${context.mediaKeysSession?.sessionId}" ${context.decryptdata?.uri}` + ); + return context; }) - .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, - }); + .catch((error) => { + throw new EMEKeyError( + { + type: ErrorTypes.KEY_SYSTEM_ERROR, + details: ErrorDetails.KEY_SYSTEM_NO_SESSION, + error, + fatal: false, + }, + `Error generating key-session request: ${error}` + ); + }) + .then(() => licensedPromise) + .then(() => keyUsablePromise) + .catch((error) => { + this.removeSession(context); + throw error; + }) + .then((mediaKeySessionContext) => { + context.mediaKeysSession.onmessage = (event: MediaKeyMessageEvent) => { + const keySession = mediaKeySessionContext.mediaKeysSession; + if (keySession) { + const { messageType, message } = event; + this.log( + `"${messageType}" message event for session "${mediaKeySessionContext.mediaKeysSession.sessionId}" message size: ${message.byteLength}` + ); + if ( + messageType === 'license-request' || + messageType === 'license-renewal' + ) { + this.renewLicense(context, message).catch((error) => { + if ('data' in error) { + // We can fail to retrieve a new license and still continue, future key requests may succeed. + error.data.fatal = false; + } + this.handleError(error); + }); + } else if (messageType === 'license-release') { + if (mediaKeySessionContext.keySystem === KeySystems.FAIRPLAY) { + this.updateKeySession( + mediaKeySessionContext, + strToUtf8array('acknowledged') + ); + this.removeSession(mediaKeySessionContext); + } + } else { + this.warn(`unhandled media key message type "${messageType}"`); + } + } + }; + mediaKeySessionContext.mediaKeysSession.onkeystatuseschange = ( + event: MediaKeyMessageEvent + ) => { + const keySession = mediaKeySessionContext.mediaKeysSession; + if (keySession) { + this.onKeyStatusChange(mediaKeySessionContext); + const keyStatus = mediaKeySessionContext.keyStatus; + if (keyStatus === 'expired') { + this.warn( + `${mediaKeySessionContext.keySystem} expired for key ${mediaKeySessionContext.decryptdata.uri}` + ); + this.renewKeySession(mediaKeySessionContext); + } + } + }; + return mediaKeySessionContext; }); } - /** - * @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` + private onKeyStatusChange(mediaKeySessionContext: MediaKeySessionContext) { + mediaKeySessionContext.mediaKeysSession.keyStatuses.forEach( + (status: MediaKeyStatus, keyId: BufferSource) => { + this.log( + `key status change "${status}" for keyStatuses keyId: ${Hex.hexDump( + keyId + )} session keyId: ${Hex.hexDump( + mediaKeySessionContext.decryptdata.keyId + )} uri: ${mediaKeySessionContext.decryptdata.uri}` + ); + mediaKeySessionContext.keyStatus = status; + } ); + } + private fetchServerCertificate( + keySystem: KeySystems + ): Promise { return new Promise((resolve, reject) => { + const url = this.getServerCertificateUrl(keySystem); + if (!url) { + return resolve(); + } + this.log(`Fetching serverCertificate for "${keySystem}"`); 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( + if (xhr.readyState === XMLHttpRequest.DONE) { + if (xhr.status === 200) { + resolve(xhr.response); + } else { + reject( + new EMEKeyError( + { + type: ErrorTypes.KEY_SYSTEM_ERROR, + details: + ErrorDetails.KEY_SYSTEM_SERVER_CERTIFICATE_REQUEST_FAILED, + fatal: true, + networkDetails: xhr, + }, `HTTP error ${xhr.status} happened while fetching server certificate` - ); + ) + ); + } + } + }; + xhr.send(); + }); + } - this.hls.trigger(Events.ERROR, { + private setMediaKeysServerCertificate( + mediaKeys: MediaKeys, + keySystem: KeySystems, + cert: BufferSource + ): Promise { + return new Promise((resolve, reject) => { + mediaKeys + .setServerCertificate(cert) + .then((success) => { + this.log( + `setServerCertificate ${ + success ? 'success' : 'not supported by CDM' + } (${cert?.byteLength}) on "${keySystem}"` + ); + resolve(mediaKeys); + }) + .catch((error) => { + reject( + new EMEKeyError( + { type: ErrorTypes.KEY_SYSTEM_ERROR, details: - ErrorDetails.KEY_SYSTEM_SERVER_CERTIFICATE_REQUEST_FAILED, + ErrorDetails.KEY_SYSTEM_SERVER_CERTIFICATE_UPDATE_FAILED, + error, fatal: true, - }); - - reject(new Error(xhr.response)); - } - break; - } - }; - - xhr.send(); + }, + error.message + ) + ); + }); }); } - /** - * @private - * @param {XMLHttpRequest} xhr - * @param {string} url - * @returns Promise - */ - private _setupLicenseXHR = ( + private renewLicense( + context: MediaKeySessionContext, + keyMessage: ArrayBuffer + ): Promise { + const licenseChallenge = this.generateLicenseRequestChallenge( + context, + keyMessage + ); + return this.requestLicense(context, licenseChallenge).then( + (data: ArrayBuffer) => { + const licenseResponse: Uint8Array = this.handleParsedKeyResponse( + context, + data + ); + return this.updateKeySession(context, licenseResponse).catch( + (error) => { + throw new EMEKeyError( + { + type: ErrorTypes.KEY_SYSTEM_ERROR, + details: ErrorDetails.KEY_SYSTEM_SESSION_UPDATE_FAILED, + error, + fatal: true, + }, + error.message + ); + } + ); + } + ); + } + + private setupLicenseXHR( xhr: XMLHttpRequest, url: string, - keysListItem: MediaKeysListItem - ): Promise => { - const licenseXhrSetup = this._licenseXhrSetup; + keysListItem: MediaKeySessionContext, + licenseChallenge: Uint8Array + ): Promise<{ xhr: XMLHttpRequest; licenseChallenge: Uint8Array }> { + const licenseXhrSetup = this.config.licenseXhrSetup; if (!licenseXhrSetup) { xhr.open('POST', url, true); - return Promise.resolve(); + return Promise.resolve({ xhr, licenseChallenge }); } - return Promise.resolve( - licenseXhrSetup(xhr, url, keysListItem.mediaKeySystemDomain) - ) + return Promise.resolve() + .then(() => { + return licenseXhrSetup.call( + this.hls, + xhr, + url, + keysListItem, + licenseChallenge + ); + }) .catch(() => { // let's try to open before running setup xhr.open('POST', url, true); - return licenseXhrSetup(xhr, url, keysListItem.mediaKeySystemDomain); + return licenseXhrSetup.call( + this.hls, + xhr, + url, + keysListItem, + licenseChallenge + ); }) - .then(() => { + .then((licenseXhrSetupResult) => { // 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}`) - ); + const finalLicenseChallenge = licenseXhrSetupResult + ? licenseXhrSetupResult + : licenseChallenge; + return { xhr, licenseChallenge: finalLicenseChallenge }; }); - }; - - /** - * @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( - keysListItem: MediaKeysListItem, - keyMessage: ArrayBuffer, - callback: (data: ArrayBuffer) => void - ): 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 - ); - - return this._setupLicenseXHR(xhr, url, keysListItem).then(() => 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, - keysListItem: MediaKeysListItem, - 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, - keysListItem.mediaKeySystemDomain + private requestLicense( + keySessionContext: MediaKeySessionContext, + licenseChallenge: Uint8Array + ): Promise { + return new Promise((resolve, reject) => { + const url = this.getLicenseServerUrl(keySessionContext.keySystem); + this.log(`Sending license request to URL: ${url}`); + const xhr = new XMLHttpRequest(); + xhr.responseType = 'arraybuffer'; + xhr.onreadystatechange = () => { + if (!this.hls || !keySessionContext.mediaKeysSession) { + return reject(new Error('invalid state')); + } + if (xhr.readyState === 4) { + if (xhr.status === 200) { + this._requestLicenseFailureCount = 0; + let data = xhr.response; + this.log( + `License received ${ + data instanceof ArrayBuffer ? data.byteLength : data + }` + ); + const licenseResponseCallback = this.config.licenseResponseCallback; + if (licenseResponseCallback) { + try { + data = licenseResponseCallback.call( + this.hls, + xhr, + url, + keySessionContext + ); + } catch (error) { + this.error(error); + } + } + resolve(data); + } else { + const error = new Error( + `License Request XHR failed (${url}). Status: ${xhr.status} (${xhr.statusText})` + ); + this._requestLicenseFailureCount++; + if ( + this._requestLicenseFailureCount > MAX_LICENSE_REQUEST_FAILURES + ) { + reject( + new EMEKeyError( + { + type: ErrorTypes.KEY_SYSTEM_ERROR, + details: ErrorDetails.KEY_SYSTEM_LICENSE_REQUEST_FAILED, + error, + fatal: true, + networkDetails: xhr, + }, + error.message + ) + ); + } else { + const attemptsLeft = + MAX_LICENSE_REQUEST_FAILURES - + this._requestLicenseFailureCount + + 1; + this.warn( + `Retrying license request, ${attemptsLeft} attempts left` + ); + this.requestLicense(keySessionContext, licenseChallenge).then( + resolve, + reject ); - } 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; - } + } + }; + if ( + keySessionContext.licenseXhr && + keySessionContext.licenseXhr.readyState !== XMLHttpRequest.DONE + ) { + keySessionContext.licenseXhr.abort(); + } + keySessionContext.licenseXhr = xhr; - const attemptsLeft = - MAX_LICENSE_REQUEST_FAILURES - this._requestLicenseFailureCount + 1; - logger.warn( - `Retrying license request, ${attemptsLeft} attempts left` - ); - this._requestLicense(keyMessage, callback); + this.setupLicenseXHR(xhr, url, keySessionContext, licenseChallenge).then( + ({ xhr, licenseChallenge }) => { + xhr.send(licenseChallenge); } - 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, + private generateLicenseRequestChallenge( + keySessionContext: MediaKeySessionContext, 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'; - } - for (var i = 0; i < headerNames.length; i++) { - xhr.setRequestHeader(headerNames[i].childNodes[0].nodeValue, headerValues[i].childNodes[0].nodeValue); + ): Uint8Array | never { + const message = new Uint8Array(keyMessage); + switch (keySessionContext.keySystem) { + case KeySystems.FAIRPLAY: { + if (keySessionContext.decryptdata?.keyId) { + const messageJson = utf8ArrayToStr(message); + try { + const spcArray = JSON.parse(messageJson); + const keyID = base64Encode(keySessionContext.decryptdata.keyId); + // this.log(`License challenge message with key IDs: ${spcArray.map(p => p.keyID).join(', ')}`); + for (let i = 0; i < spcArray.length; i++) { + const payload = spcArray[i]; + if (payload.keyID === keyID) { + this.log( + `Generateing license challenge with ID ${payload.keyID}` + ); + const spc = base64Decode(payload.payload); + return spc; + } + } + } catch (error) { + this.warn('got unexpected license-request format'); + } } + return message; } - break; - */ - // For Widevine and Fairplay CDMs, the challenge is the keyMessage. - case KeySystems.FAIRPLAY: case KeySystems.WIDEVINE: - return keyMessage; + case KeySystems.PLAYREADY: + case KeySystems.CLEARKEY: + return message; + default: + throw new Error( + `unsupported key-system: ${keySessionContext.keySystem}` + ); } - - throw new Error( - `unsupported key-system: ${keysListItem.mediaKeySystemDomain}` - ); } - 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 - * @param callback - */ - private _requestLicense( - keyMessage: ArrayBuffer, - callback: (data: ArrayBuffer) => void + private onMediaAttached( + event: Events.MEDIA_ATTACHED, + data: MediaAttachedData ) { - 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 { - this._createLicenseXhr(keysListItem, keyMessage, callback) - .then((xhr) => { - const challenge = this._generateLicenseRequestChallenge( - keysListItem, - keyMessage - ); - xhr.send(challenge); - }) - .catch((error) => { - this._onLicenseRequestError(error); - }); - } catch (e) { - this._onLicenseRequestError(e); - } - } - - onMediaAttached(event: Events.MEDIA_ATTACHED, data: MediaAttachedData) { - if (!this._emeEnabled) { + if (!this.config.emeEnabled) { return; } const media = data.media; // keep reference of media - this._media = media; + this.media = media; - media.addEventListener('encrypted', this._onMediaEncrypted); + if (this.config.useEmeEncryptedEvent) { + media.addEventListener('encrypted', this.onMediaEncrypted); + } + media.addEventListener('waitingforkey', this.onWaitingForKey); } - onMediaDetached() { - const media = this._media; - const mediaKeysList = this._mediaKeysList; - if (!media) { - return; + private onMediaDetached() { + const media = this.media; + const mediaKeysList = this.mediaKeySessions; + if (media) { + media.removeEventListener('encrypted', this.onMediaEncrypted); + media.removeEventListener('waitingforkey', this.onWaitingForKey); + this.media = null; } - media.removeEventListener('encrypted', this._onMediaEncrypted); - this._media = null; - this._mediaKeysList = []; + + this._requestLicenseFailureCount = 0; + this.setMediaKeysQueue = []; + this.mediaKeySessions = []; + this.keyUriToKeySessionPromise = {}; + LevelKey.clearKeyUriToKeyIdMap(); + // 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. - }); - } - }) + EMEController.CDMCleanupPromise = Promise.all( + mediaKeysList + .map((mediaKeySessionContext) => + this.removeSession(mediaKeySessionContext) + ) + .concat( + media?.setMediaKeys(null).catch((error) => { + this.log( + `Could not clear media keys: ${error}. media.src: ${media?.src}` + ); + }) + ) ) .then(() => { - return media.setMediaKeys(null); + if (mediaKeysList.length) { + this.log('finished closing key sessions and clearing media keys'); + mediaKeysList.length = 0; + } }) - .catch(() => { - // Ignore any failures while removing media keys from the video element. + .catch((error) => { + this.log( + `Could not close sessions and clear media keys: ${error}. media.src: ${media?.src}` + ); }); } - 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 + removeSession( + mediaKeySessionContext: MediaKeySessionContext + ): Promise | void { + const { mediaKeysSession, licenseXhr } = mediaKeySessionContext; + if (mediaKeysSession) { + this.log( + `Remove licenses and keys and close session ${mediaKeysSession.sessionId}` ); + mediaKeysSession.onmessage = null; + mediaKeysSession.onkeystatuseschange = null; + if (licenseXhr && licenseXhr.readyState !== XMLHttpRequest.DONE) { + licenseXhr.abort(); + } + mediaKeySessionContext.mediaKeysSession = + mediaKeySessionContext.decryptdata = + mediaKeySessionContext.licenseXhr = + undefined!; + return mediaKeysSession + .remove() + .catch((error) => { + this.log(`Could not remove session: ${error}`); + }) + .then(() => { + return mediaKeysSession.close(); + }) + .catch((error) => { + this.log(`Could not close session: ${error}`); + }); + } + } +} - // TBD We should try a keySystem based on manifest information - this._attemptKeySystemAccess(KeySystems.FAIRPLAY, audioCodecs, videoCodecs); +class EMEKeyError extends Error { + public readonly data: ErrorData; + constructor(data: ErrorData, message: string) { + super(message); + this.data = data; } } diff --git a/src/controller/level-controller.ts b/src/controller/level-controller.ts index f392696f443..c9e884ad978 100644 --- a/src/controller/level-controller.ts +++ b/src/controller/level-controller.ts @@ -362,6 +362,14 @@ export default class LevelController extends BasePlaylistController { } } break; + case ErrorDetails.KEY_SYSTEM_NO_SESSION: + case ErrorDetails.KEY_SYSTEM_STATUS_OUTPUT_RESTRICTED: + levelIndex = + data.frag?.type === PlaylistLevelType.MAIN + ? data.frag.level + : this.currentLevelIndex; + levelError = true; + break; case ErrorDetails.LEVEL_LOAD_ERROR: case ErrorDetails.LEVEL_LOAD_TIMEOUT: // Do not perform level switch if an error occurred using delivery directives diff --git a/src/controller/stream-controller.ts b/src/controller/stream-controller.ts index 958cb297793..1bdc31e851e 100644 --- a/src/controller/stream-controller.ts +++ b/src/controller/stream-controller.ts @@ -1,20 +1,21 @@ import BaseStreamController, { State } from './base-stream-controller'; import { changeTypeSupported } from '../is-supported'; -import type { NetworkComponentAPI } from '../types/component-api'; import { Events } from '../events'; import { BufferHelper, BufferInfo } from '../utils/buffer-helper'; -import type { FragmentTracker } from './fragment-tracker'; import { FragmentState } from './fragment-tracker'; -import type { Level } from '../types/level'; import { PlaylistLevelType } from '../types/loader'; import { ElementaryStreamTypes, Fragment } from '../loader/fragment'; import TransmuxerInterface from '../demux/transmuxer-interface'; -import type { TransmuxerResult } from '../types/transmuxer'; import { ChunkMetadata } from '../types/transmuxer'; import GapController from './gap-controller'; import { ErrorDetails } from '../errors'; +import type { NetworkComponentAPI } from '../types/component-api'; import type Hls from '../hls'; +import type { Level } from '../types/level'; import type { LevelDetails } from '../loader/level-details'; +import type { FragmentTracker } from './fragment-tracker'; +import type KeyLoader from '../loader/key-loader'; +import type { TransmuxerResult } from '../types/transmuxer'; import type { TrackSet } from '../types/track'; import type { SourceBufferName } from '../types/buffer'; import type { @@ -56,8 +57,12 @@ export default class StreamController private audioCodecSwitch: boolean = false; private videoBuffer: any | null = null; - constructor(hls: Hls, fragmentTracker: FragmentTracker) { - super(hls, fragmentTracker, '[stream-controller]'); + constructor( + hls: Hls, + fragmentTracker: FragmentTracker, + keyLoader: KeyLoader + ) { + super(hls, fragmentTracker, keyLoader, '[stream-controller]'); this._registerListeners(); } @@ -849,6 +854,8 @@ export default class StreamController case ErrorDetails.FRAG_LOAD_TIMEOUT: case ErrorDetails.KEY_LOAD_ERROR: case ErrorDetails.KEY_LOAD_TIMEOUT: + case ErrorDetails.KEY_SYSTEM_NO_SESSION: + case ErrorDetails.KEY_SYSTEM_STATUS_OUTPUT_RESTRICTED: this.onFragmentOrKeyLoadError(PlaylistLevelType.MAIN, data); break; case ErrorDetails.LEVEL_LOAD_ERROR: diff --git a/src/controller/subtitle-stream-controller.ts b/src/controller/subtitle-stream-controller.ts index 8f3ff60bd3c..7f079d368a4 100644 --- a/src/controller/subtitle-stream-controller.ts +++ b/src/controller/subtitle-stream-controller.ts @@ -7,9 +7,10 @@ import { FragmentState } from './fragment-tracker'; import BaseStreamController, { State } from './base-stream-controller'; import { PlaylistLevelType } from '../types/loader'; import { Level } from '../types/level'; -import type { FragmentTracker } from './fragment-tracker'; import type { NetworkComponentAPI } from '../types/component-api'; import type Hls from '../hls'; +import type { FragmentTracker } from './fragment-tracker'; +import type KeyLoader from '../loader/key-loader'; import type { LevelDetails } from '../loader/level-details'; import type { Fragment } from '../loader/fragment'; import type { @@ -41,8 +42,12 @@ export class SubtitleStreamController private tracksBuffered: Array = []; private mainDetails: LevelDetails | null = null; - constructor(hls: Hls, fragmentTracker: FragmentTracker) { - super(hls, fragmentTracker, '[subtitle-stream-controller]'); + constructor( + hls: Hls, + fragmentTracker: FragmentTracker, + keyLoader: KeyLoader + ) { + super(hls, fragmentTracker, keyLoader, '[subtitle-stream-controller]'); this._registerListeners(); } @@ -406,7 +411,7 @@ export class SubtitleStreamController const fragLen = fragments.length; const end = trackDetails.edge; - let foundFrag: Fragment | null; + let foundFrag: Fragment | null = null; const fragPrevious = this.fragPrevious; if (targetBufferTime < end) { const { maxFragLookUpTolerance } = config; @@ -426,12 +431,11 @@ export class SubtitleStreamController } else { foundFrag = fragments[fragLen - 1]; } - - foundFrag = this.mapToInitFragWhenRequired(foundFrag); if (!foundFrag) { return; } + foundFrag = this.mapToInitFragWhenRequired(foundFrag) as Fragment; if ( this.fragmentTracker.getState(foundFrag) === FragmentState.NOT_LOADED ) { diff --git a/src/controller/timeline-controller.ts b/src/controller/timeline-controller.ts index c560a7aa844..dcade334439 100644 --- a/src/controller/timeline-controller.ts +++ b/src/controller/timeline-controller.ts @@ -497,12 +497,7 @@ export class TimelineController implements ComponentAPI { // fragment after decryption has a stats object const decrypted = 'stats' in data; // If the subtitles are not encrypted, parse VTTs now. Otherwise, we need to wait. - if ( - decryptData == null || - decryptData.key == null || - decryptData.method !== 'AES-128' || - decrypted - ) { + if (decryptData == null || !decryptData.encrypted || decrypted) { const trackPlaylistMedia = this.tracks[frag.level]; const vttCCs = this.vttCCs; if (!vttCCs[frag.cc]) { diff --git a/src/demux/transmuxer.ts b/src/demux/transmuxer.ts index 49411f068d2..d1443baf485 100644 --- a/src/demux/transmuxer.ts +++ b/src/demux/transmuxer.ts @@ -13,7 +13,7 @@ import type { Demuxer, DemuxerResult, KeyData } from '../types/demuxer'; import type { Remuxer } from '../types/remuxer'; import type { TransmuxerResult, ChunkMetadata } from '../types/transmuxer'; import type { HlsConfig } from '../config'; -import type { LevelKey } from '../loader/level-key'; +import type { DecryptData } from '../loader/level-key'; import type { PlaylistLevelType } from '../types/loader'; let now; @@ -75,7 +75,7 @@ export default class Transmuxer { push( data: ArrayBuffer, - decryptdata: LevelKey | null, + decryptdata: DecryptData | null, chunkMeta: ChunkMetadata, state?: TransmuxState ): TransmuxerResult | Promise { @@ -104,19 +104,6 @@ export default class Transmuxer { initSegmentData, } = transmuxConfig; - // Reset muxers before probing to ensure that their state is clean, even if flushing occurs before a successful probe - if (discontinuity || trackSwitch || initSegmentChange) { - this.resetInitSegment(initSegmentData, audioCodec, videoCodec, duration); - } - - if (discontinuity || initSegmentChange) { - this.resetInitialTimestamp(defaultInitPts); - } - - if (!contiguous) { - this.resetContiguity(); - } - const keyData = getEncryptionType(uintData, decryptdata); if (keyData && keyData.method === 'AES-128') { const decrypter = this.getDecrypter(); @@ -152,8 +139,27 @@ export default class Transmuxer { } } - if (this.needsProbing(uintData, discontinuity, trackSwitch)) { - this.configureTransmuxer(uintData, transmuxConfig); + const resetMuxers = this.needsProbing(discontinuity, trackSwitch); + if (resetMuxers) { + this.configureTransmuxer(uintData); + } + + if (discontinuity || trackSwitch || initSegmentChange || resetMuxers) { + this.resetInitSegment( + initSegmentData, + audioCodec, + videoCodec, + duration, + decryptdata + ); + } + + if (discontinuity || initSegmentChange || resetMuxers) { + this.resetInitialTimestamp(defaultInitPts); + } + + if (!contiguous) { + this.resetContiguity(); } const result = this.transmux( @@ -283,7 +289,8 @@ export default class Transmuxer { initSegmentData: Uint8Array | undefined, audioCodec: string | undefined, videoCodec: string | undefined, - trackDuration: number + trackDuration: number, + decryptdata: DecryptData | null ) { const { demuxer, remuxer } = this; if (!demuxer || !remuxer) { @@ -295,7 +302,12 @@ export default class Transmuxer { videoCodec, trackDuration ); - remuxer.resetInitSegment(initSegmentData, audioCodec, videoCodec); + remuxer.resetInitSegment( + initSegmentData, + audioCodec, + videoCodec, + decryptdata + ); } destroy(): void { @@ -388,18 +400,8 @@ export default class Transmuxer { }); } - private configureTransmuxer( - data: Uint8Array, - transmuxConfig: TransmuxConfig - ) { + private configureTransmuxer(data: Uint8Array) { const { config, observer, typeSupported, vendor } = this; - const { - audioCodec, - defaultInitPts, - duration, - initSegmentData, - videoCodec, - } = transmuxConfig; // probe for content type let mux; for (let i = 0, len = muxConfig.length; i < len; i++) { @@ -427,16 +429,9 @@ export default class Transmuxer { this.demuxer = new Demuxer(observer, config, typeSupported); this.probe = Demuxer.probe; } - // Ensure that muxers are always initialized with an initSegment - this.resetInitSegment(initSegmentData, audioCodec, videoCodec, duration); - this.resetInitialTimestamp(defaultInitPts); } - private needsProbing( - data: Uint8Array, - discontinuity: boolean, - trackSwitch: boolean - ): boolean { + private needsProbing(discontinuity: boolean, trackSwitch: boolean): boolean { // in case of continuity change, or track switch // we might switch from content type (AAC container to TS container, or TS to fmp4 for example) return !this.demuxer || !this.remuxer || discontinuity || trackSwitch; @@ -453,7 +448,7 @@ export default class Transmuxer { function getEncryptionType( data: Uint8Array, - decryptData: LevelKey | null + decryptData: DecryptData | null ): KeyData | null { let encryptionType: KeyData | null = null; if ( diff --git a/src/errors.ts b/src/errors.ts index be459182628..3271d8e3836 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -24,6 +24,8 @@ export enum ErrorDetails { KEY_SYSTEM_SERVER_CERTIFICATE_UPDATE_FAILED = 'keySystemServerCertificateUpdateFailed', KEY_SYSTEM_NO_INIT_DATA = 'keySystemNoInitData', KEY_SYSTEM_SESSION_UPDATE_FAILED = 'keySystemSessionUpdateFailed', + KEY_SYSTEM_STATUS_OUTPUT_RESTRICTED = 'keySystemStatusOutputRestricted', + KEY_SYSTEM_STATUS_INTERNAL_ERROR = 'keySystemStatusInternalError', // 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/events.ts b/src/events.ts index 41da5f0783b..c341414d67e 100644 --- a/src/events.ts +++ b/src/events.ts @@ -160,7 +160,7 @@ export enum Events { DESTROYING = 'hlsDestroying', // fired when a decrypt key loading starts - data: { frag : fragment object } KEY_LOADING = 'hlsKeyLoading', - // fired when a decrypt key loading is completed - data: { frag : fragment object, payload : key payload, stats : LoaderStats } + // fired when a decrypt key loading is completed - data: { frag : fragment object, keyInfo : KeyLoaderInfo } KEY_LOADED = 'hlsKeyLoaded', // deprecated; please use BACK_BUFFER_REACHED - data : { bufferEnd: number } LIVE_BACK_BUFFER_REACHED = 'hlsLiveBackBufferReached', diff --git a/src/hls.ts b/src/hls.ts index 9c6b7b140db..4ba39ccf0fa 100644 --- a/src/hls.ts +++ b/src/hls.ts @@ -4,6 +4,7 @@ import ID3TrackController from './controller/id3-track-controller'; import LatencyController from './controller/latency-controller'; import LevelController from './controller/level-controller'; import { FragmentTracker } from './controller/fragment-tracker'; +import KeyLoader from './loader/key-loader'; import StreamController from './controller/stream-controller'; import { isSupported } from './is-supported'; import { logger, enableLogs } from './utils/logger'; @@ -129,9 +130,11 @@ export default class Hls implements HlsEventEmitter { const levelController = (this.levelController = new LevelController(this)); // FragmentTracker must be defined before StreamController because the order of event handling is important const fragmentTracker = new FragmentTracker(this); + const keyLoader = new KeyLoader(this.config); const streamController = (this.streamController = new StreamController( this, - fragmentTracker + fragmentTracker, + keyLoader )); // Cap level controller uses streamController to flush the buffer @@ -139,14 +142,14 @@ export default class Hls implements HlsEventEmitter { // fpsController uses streamController to switch when frames are being dropped fpsController.setStreamController(streamController); - const networkControllers = [ + const networkControllers: NetworkComponentAPI[] = [ playListLoader, levelController, streamController, ]; this.networkControllers = networkControllers; - const coreComponents = [ + const coreComponents: ComponentAPI[] = [ abrController, bufferController, capLevelController, @@ -157,50 +160,45 @@ export default class Hls implements HlsEventEmitter { this.audioTrackController = this.createController( config.audioTrackController, - null, networkControllers ); - this.createController( - config.audioStreamController, - fragmentTracker, - networkControllers - ); - // subtitleTrackController must be defined before because the order of event handling is important + const AudioStreamControllerClass = config.audioStreamController; + if (AudioStreamControllerClass) { + networkControllers.push( + new AudioStreamControllerClass(this, fragmentTracker, keyLoader) + ); + } + // subtitleTrackController must be defined before subtitleStreamController because the order of event handling is important this.subtitleTrackController = this.createController( config.subtitleTrackController, - null, networkControllers ); - this.createController( - config.subtitleStreamController, - fragmentTracker, - networkControllers - ); - this.createController(config.timelineController, null, coreComponents); - this.emeController = this.createController( + const SubtitleStreamControllerClass = config.subtitleStreamController; + if (SubtitleStreamControllerClass) { + networkControllers.push( + new SubtitleStreamControllerClass(this, fragmentTracker, keyLoader) + ); + } + this.createController(config.timelineController, coreComponents); + keyLoader.emeController = this.emeController = this.createController( config.emeController, - null, coreComponents ); this.cmcdController = this.createController( config.cmcdController, - null, coreComponents ); this.latencyController = this.createController( LatencyController, - null, coreComponents ); this.coreComponents = coreComponents; } - createController(ControllerClass, fragmentTracker, components) { + createController(ControllerClass, components) { if (ControllerClass) { - const controllerInstance = fragmentTracker - ? new ControllerClass(this, fragmentTracker) - : new ControllerClass(this); + const controllerInstance = new ControllerClass(this); if (components) { components.push(controllerInstance); } @@ -869,7 +867,11 @@ export type { TSDemuxerConfig, } from './config'; export type { CuesInterface } from './utils/cues'; -export type { MediaKeyFunc, KeySystems } from './utils/mediakeys-helper'; +export type { + MediaKeyFunc, + KeySystems, + KeySystemFormats, +} from './utils/mediakeys-helper'; export type { DateRange } from './loader/date-range'; export type { LoadStats } from './loader/load-stats'; export type { LevelKey } from './loader/level-key'; @@ -893,7 +895,6 @@ export type { PlaylistContextType, PlaylistLoaderContext, FragmentLoaderContext, - KeyLoaderContext, Loader, LoaderStats, LoaderContext, diff --git a/src/loader/fragment.ts b/src/loader/fragment.ts index 71674b58bf1..8e7d65681f5 100644 --- a/src/loader/fragment.ts +++ b/src/loader/fragment.ts @@ -1,14 +1,14 @@ import { buildAbsoluteURL } from 'url-toolkit'; -import { logger } from '../utils/logger'; import { LevelKey } from './level-key'; import { LoadStats } from './load-stats'; import { AttrList } from '../utils/attr-list'; import type { - KeyLoaderContext, FragmentLoaderContext, + KeyLoaderContext, Loader, PlaylistLevelType, } from '../types/loader'; +import type { KeySystemFormats } from '../utils/mediakeys-helper'; export enum ElementaryStreamTypes { AUDIO = 'audio', @@ -102,10 +102,10 @@ export class Fragment extends BaseSegment { public duration: number = 0; // sn notates the sequence number for a segment, and if set to a string can be 'initSegment' public sn: number | 'initSegment' = 0; - // levelkey is the EXT-X-KEY that applies to this segment for decryption + // levelkeys are the EXT-X-KEY tags that apply to this segment for decryption // core difference from the private field _decryptdata is the lack of the initialized IV // _decryptdata will set the IV for this segment based on the segment number in the fragment - public levelkey?: LevelKey; + public levelkeys?: { [key: string]: LevelKey }; // A string representing the fragment type public readonly type: PlaylistLevelType; // A reference to the loader. Set while the fragment is loading, and removed afterwards. Used to abort fragment loading @@ -151,36 +151,25 @@ export class Fragment extends BaseSegment { } get decryptdata(): LevelKey | null { - if (!this.levelkey && !this._decryptdata) { + const { levelkeys } = this; + if (!levelkeys && !this._decryptdata) { return null; } - if (!this._decryptdata && this.levelkey) { - let sn = this.sn; - if (typeof sn !== 'number') { - // We are fetching decryption data for a initialization segment - // If the segment was encrypted with AES-128 - // It must have an IV defined. We cannot substitute the Segment Number in. - if ( - this.levelkey && - this.levelkey.method === 'AES-128' && - !this.levelkey.iv - ) { - logger.warn( - `missing IV for initialization segment with method="${this.levelkey.method}" - compliance issue` - ); + if (!this._decryptdata && this.levelkeys && !this.levelkeys.NONE) { + const key = this.levelkeys.identity; + if (key) { + this._decryptdata = key.getDecryptData(this.sn); + } else { + const keyFormats = Object.keys(this.levelkeys); + if (keyFormats.length === 1) { + return (this._decryptdata = this.levelkeys[ + keyFormats[0] + ].getDecryptData(this.sn)); + } else { + // Multiple keys. key-loader to call Fragment.setKeyFormat based on selected key-system. } - - /* - Be converted to a Number. - 'initSegment' will become NaN. - NaN, which when converted through ToInt32() -> +0. - --- - Explicitly set sn to resulting value from implicit conversions 'initSegment' values for IV generation. - */ - sn = 0; } - this._decryptdata = this.setDecryptDataFromLevelKey(this.levelkey, sn); } return this._decryptdata; @@ -208,53 +197,31 @@ export class Fragment extends BaseSegment { // At the m3u8-parser level we need to add support for manifest signalled keyformats // when we want the fragment to start reporting that it is encrypted. // Currently, keyFormat will only be set for identity keys - if (this.decryptdata?.keyFormat && this.decryptdata.uri) { + if (this._decryptdata?.encrypted) { return true; + } else if (this.levelkeys) { + const keyFormats = Object.keys(this.levelkeys); + const len = keyFormats.length; + if (len > 1 || (len === 1 && this.levelkeys[keyFormats[0]].encrypted)) { + return true; + } } return false; } - abortRequests(): void { - this.loader?.abort(); - this.keyLoader?.abort(); - } - - /** - * Utility method for parseLevelPlaylist to create an initialization vector for a given segment - * @param {number} segmentNumber - segment number to generate IV with - * @returns {Uint8Array} - */ - createInitializationVector(segmentNumber: number): Uint8Array { - const uint8View = new Uint8Array(16); - - for (let i = 12; i < 16; i++) { - uint8View[i] = (segmentNumber >> (8 * (15 - i))) & 0xff; + setKeyFormat(keyFormat: KeySystemFormats) { + if (this.levelkeys) { + const key = this.levelkeys[keyFormat]; + if (key) { + this._decryptdata = key.getDecryptData(this.sn); + } } - - return uint8View; } - /** - * Utility method for parseLevelPlaylist to get a fragment's decryption data from the currently parsed encryption key data - * @param levelkey - a playlist's encryption info - * @param segmentNumber - the fragment's segment number - * @returns {LevelKey} - an object to be applied as a fragment's decryptdata - */ - setDecryptDataFromLevelKey( - levelkey: LevelKey, - segmentNumber: number - ): LevelKey { - let decryptdata = levelkey; - - if (levelkey?.method === 'AES-128' && levelkey.uri && !levelkey.iv) { - decryptdata = LevelKey.fromURI(levelkey.uri); - decryptdata.method = levelkey.method; - decryptdata.iv = this.createInitializationVector(segmentNumber); - decryptdata.keyFormat = 'identity'; - } - - return decryptdata; + abortRequests(): void { + this.loader?.abort(); + this.keyLoader?.abort(); } setElementaryStreamInfo( diff --git a/src/loader/key-loader.ts b/src/loader/key-loader.ts index 64273a344a4..f4ceeed888a 100644 --- a/src/loader/key-loader.ts +++ b/src/loader/key-loader.ts @@ -1,8 +1,4 @@ -/* - * Decrypt key Loader - */ import { ErrorTypes, ErrorDetails } from '../errors'; -import { logger } from '../utils/logger'; import { LoaderStats, LoaderResponse, @@ -16,169 +12,305 @@ import type { HlsConfig } from '../hls'; import type { Fragment } from '../loader/fragment'; import type { ComponentAPI } from '../types/component-api'; import type { KeyLoadedData } from '../types/events'; +import type { LevelKey } from './level-key'; +import type EMEController from '../controller/eme-controller'; +import type { MediaKeySessionContext } from '../controller/eme-controller'; +import type { KeySystemFormats } from '../utils/mediakeys-helper'; +export interface KeyLoaderInfo { + decryptdata: LevelKey; + keyLoadPromise: Promise | null; + loader: Loader | null; + mediaKeySessionContext: MediaKeySessionContext | null; +} export default class KeyLoader implements ComponentAPI { private readonly config: HlsConfig; - public loader: Loader | null = null; - public decryptkey: Uint8Array | null = null; - public decrypturl: string | null = null; + public keyUriToKeyInfo: { [keyuri: string]: KeyLoaderInfo } = {}; + public emeController: EMEController | null = null; constructor(config: HlsConfig) { this.config = config; } - abort(): void { - this.loader?.abort(); + abort() { + for (const uri in this.keyUriToKeyInfo) { + const loader = this.keyUriToKeyInfo[uri].loader; + if (loader) { + loader.abort(); + } + } } - destroy(): void { - if (this.loader) { - this.loader.destroy(); - this.loader = null; + detach() { + for (const uri in this.keyUriToKeyInfo) { + const keyInfo = this.keyUriToKeyInfo[uri]; + // Remove cached EME keys on detach + if ( + keyInfo.mediaKeySessionContext || + keyInfo.decryptdata.isCommonEncryption + ) { + delete this.keyUriToKeyInfo[uri]; + } } } - load(frag: Fragment): Promise | never { - const type = frag.type; - const loader = this.loader; - if (!frag.decryptdata) { - throw new Error('Missing decryption data on fragment in onKeyLoading'); + destroy() { + this.detach(); + for (const uri in this.keyUriToKeyInfo) { + const loader = this.keyUriToKeyInfo[uri].loader; + if (loader) { + loader.destroy(); + } } + this.keyUriToKeyInfo = {}; + } - // Load the key if the uri is different from previous one, or if the decrypt key has not yet been retrieved - const uri = frag.decryptdata.uri; - if (uri !== this.decrypturl || this.decryptkey === null) { - const config = this.config; - if (loader) { - logger.warn(`abort previous key loader for type:${type}`); - loader.abort(); + createKeyLoadError( + frag: Fragment, + details: ErrorDetails = ErrorDetails.KEY_LOAD_ERROR, + networkDetails?: any, + message?: string + ): LoadError { + return new LoadError({ + type: ErrorTypes.NETWORK_ERROR, + details, + fatal: false, + frag, + networkDetails, + }); + } + + load(frag: Fragment): Promise { + if (!frag.decryptdata && frag.encrypted && this.emeController) { + // Multiple keys, but none selected, resolve in eme-controller + return this.emeController + .selectKeySystemFormat(frag) + .then((keySystemFormat) => { + return this.loadInternal(frag, keySystemFormat); + }); + } + + return this.loadInternal(frag); + } + + loadInternal( + frag: Fragment, + keySystemFormat?: KeySystemFormats + ): Promise { + if (keySystemFormat) { + frag.setKeyFormat(keySystemFormat); + } + const decryptdata = frag.decryptdata; + if (!decryptdata) { + const errorMessage = keySystemFormat + ? `Expected frag.decryptdata to be defined after setting format ${keySystemFormat}` + : 'Missing decryption data on fragment in onKeyLoading'; + return Promise.reject( + this.createKeyLoadError( + frag, + ErrorDetails.KEY_LOAD_ERROR, + null, + errorMessage + ) + ); + } + const uri = decryptdata.uri; + if (!uri) { + return Promise.reject( + this.createKeyLoadError( + frag, + ErrorDetails.KEY_LOAD_ERROR, + null, + `Invalid key URI: "${uri}"` + ) + ); + } + let keyInfo = this.keyUriToKeyInfo[uri]; + + if (keyInfo?.decryptdata.key) { + decryptdata.key = keyInfo.decryptdata.key; + return Promise.resolve({ frag, keyInfo }); + } + // Return key load promise as long as it does not have a mediakey session with an unusable key status + if (keyInfo?.keyLoadPromise) { + switch (keyInfo.mediaKeySessionContext?.keyStatus) { + case undefined: + case 'status-pending': + case 'usable': + case 'usable-in-future': + return keyInfo.keyLoadPromise; } - if (!uri) { - throw new Error('key uri is falsy'); + // If we have a key session and status and it is not pending or usable, continue + // This will go back to the eme-controller for expired keys to get a new keyLoadPromise + } + + // Load the key or return the loading promise + keyInfo = this.keyUriToKeyInfo[uri] = { + decryptdata, + keyLoadPromise: null, + loader: null, + mediaKeySessionContext: null, + }; + + switch (decryptdata.method) { + case 'ISO-23001-7': + case 'SAMPLE-AES': + case 'SAMPLE-AES-CENC': + case 'SAMPLE-AES-CTR': + if (decryptdata.keyFormat === 'identity') { + // loadKeyHTTP handles data URLs + return this.loadKeyHTTP(keyInfo, frag); + } + return this.loadKeyEME(keyInfo, frag); + case 'AES-128': + return this.loadKeyHTTP(keyInfo, frag); + default: + return Promise.reject( + this.createKeyLoadError( + frag, + ErrorDetails.KEY_LOAD_ERROR, + null, + `Key supplied with unsupported METHOD: "${decryptdata.method}"` + ) + ); + } + } + + loadKeyEME(keyInfo: KeyLoaderInfo, frag: Fragment): Promise { + const keyLoadedData: KeyLoadedData = { frag, keyInfo }; + if (this.emeController && this.config.emeEnabled) { + const keySessionContextPromise = + this.emeController.loadKey(keyLoadedData); + if (keySessionContextPromise) { + return (keyInfo.keyLoadPromise = keySessionContextPromise.then( + (keySessionContext) => { + keyInfo.mediaKeySessionContext = keySessionContext; + return keyLoadedData; + } + )).catch((error) => { + // Remove promise for license renewal or retry + keyInfo.keyLoadPromise = null; + throw error; + }); } - const Loader = config.loader; - const keyLoader = - (frag.keyLoader = - this.loader = - new Loader(config) as Loader); - this.decrypturl = uri; - this.decryptkey = null; - - return new Promise((resolve, reject) => { - const loaderContext: KeyLoaderContext = { - url: uri, - frag: frag, - part: null, - responseType: 'arraybuffer', - }; - - // maxRetry is 0 so that instead of retrying the same key on the same variant multiple times, - // key-loader will trigger an error and rely on stream-controller to handle retry logic. - // this will also align retry logic with fragment-loader - const loaderConfig: LoaderConfiguration = { - timeout: config.fragLoadingTimeOut, - maxRetry: 0, - retryDelay: config.fragLoadingRetryDelay, - maxRetryDelay: config.fragLoadingMaxRetryTimeout, - highWaterMark: 0, - }; - - const loaderCallbacks: LoaderCallbacks = { - onSuccess: ( - response: LoaderResponse, - stats: LoaderStats, - context: KeyLoaderContext, - networkDetails: any - ) => { - const frag = context.frag; - if (!frag.decryptdata) { - logger.error('after key load, decryptdata unset'); - return reject( - new LoadError({ - type: ErrorTypes.NETWORK_ERROR, - details: ErrorDetails.KEY_LOAD_ERROR, - fatal: false, - frag, - networkDetails, - }) - ); - } - this.decryptkey = frag.decryptdata.key = new Uint8Array( - response.data as ArrayBuffer - ); + } + return Promise.resolve(keyLoadedData); + } - // detach fragment key loader on load success - frag.keyLoader = null; - this.loader = null; - resolve({ frag }); - }, - - onError: ( - error: { code: number; text: string }, - context: KeyLoaderContext, - networkDetails: any - ) => { - this.resetLoader(context.frag, keyLoader); - reject( - new LoadError({ - type: ErrorTypes.NETWORK_ERROR, - details: ErrorDetails.KEY_LOAD_ERROR, - fatal: false, - frag, - networkDetails, - }) - ); - }, - - onTimeout: ( - stats: LoaderStats, - context: KeyLoaderContext, - networkDetails: any - ) => { - this.resetLoader(context.frag, keyLoader); - reject( - new LoadError({ - type: ErrorTypes.NETWORK_ERROR, - details: ErrorDetails.KEY_LOAD_TIMEOUT, - fatal: false, - frag, - networkDetails, - }) - ); - }, - - onAbort: ( - stats: LoaderStats, - context: KeyLoaderContext, - networkDetails: any - ) => { - this.resetLoader(context.frag, keyLoader); - reject( - new LoadError({ - type: ErrorTypes.NETWORK_ERROR, - details: ErrorDetails.INTERNAL_ABORTED, - fatal: false, + loadKeyHTTP(keyInfo: KeyLoaderInfo, frag: Fragment): Promise { + const config = this.config; + const Loader = config.loader; + const keyLoader = new Loader(config) as Loader; + frag.keyLoader = keyInfo.loader = keyLoader; + + return (keyInfo.keyLoadPromise = new Promise((resolve, reject) => { + const loaderContext: KeyLoaderContext = { + keyInfo, + frag, + responseType: 'arraybuffer', + url: keyInfo.decryptdata.uri, + }; + + // maxRetry is 0 so that instead of retrying the same key on the same variant multiple times, + // key-loader will trigger an error and rely on stream-controller to handle retry logic. + // this will also align retry logic with fragment-loader + const loaderConfig: LoaderConfiguration = { + timeout: config.fragLoadingTimeOut, + maxRetry: 0, + retryDelay: config.fragLoadingRetryDelay, + maxRetryDelay: config.fragLoadingMaxRetryTimeout, + highWaterMark: 0, + }; + + const loaderCallbacks: LoaderCallbacks = { + onSuccess: ( + response: LoaderResponse, + stats: LoaderStats, + context: KeyLoaderContext, + networkDetails: any + ) => { + const { frag, keyInfo, url: uri } = context; + if (!frag.decryptdata || keyInfo !== this.keyUriToKeyInfo[uri]) { + return reject( + this.createKeyLoadError( frag, + ErrorDetails.KEY_LOAD_ERROR, networkDetails, - }) + 'after key load, decryptdata unset or changed' + ) ); - }, - }; - - keyLoader.load(loaderContext, loaderConfig, loaderCallbacks); - }); - } else if (this.decryptkey) { - // Return the key if it's already been loaded - frag.decryptdata.key = this.decryptkey; - return Promise.resolve({ frag }); - } - return Promise.resolve(); + } + + keyInfo.decryptdata.key = frag.decryptdata.key = new Uint8Array( + response.data as ArrayBuffer + ); + + // detach fragment key loader on load success + frag.keyLoader = null; + keyInfo.loader = null; + resolve({ frag, keyInfo }); + }, + + onError: ( + error: { code: number; text: string }, + context: KeyLoaderContext, + networkDetails: any + ) => { + this.resetLoader(context); + reject( + this.createKeyLoadError( + frag, + ErrorDetails.KEY_LOAD_ERROR, + networkDetails + ) + ); + }, + + onTimeout: ( + stats: LoaderStats, + context: KeyLoaderContext, + networkDetails: any + ) => { + this.resetLoader(context); + reject( + this.createKeyLoadError( + frag, + ErrorDetails.KEY_LOAD_TIMEOUT, + networkDetails + ) + ); + }, + + onAbort: ( + stats: LoaderStats, + context: KeyLoaderContext, + networkDetails: any + ) => { + this.resetLoader(context); + reject( + this.createKeyLoadError( + frag, + ErrorDetails.INTERNAL_ABORTED, + networkDetails + ) + ); + }, + }; + + keyLoader.load(loaderContext, loaderConfig, loaderCallbacks); + })); } - private resetLoader(frag: Fragment, loader: Loader) { - if (this.loader === loader) { - this.loader = null; + private resetLoader(context: KeyLoaderContext) { + const { frag, keyInfo, url: uri } = context; + const loader = keyInfo.loader; + if (frag.keyLoader === loader) { + frag.keyLoader = null; + keyInfo.loader = null; + } + delete this.keyUriToKeyInfo[uri]; + if (loader) { + loader.destroy(); } - loader.destroy(); } } diff --git a/src/loader/level-key.ts b/src/loader/level-key.ts index 90fcf253b1b..2011a36f52b 100644 --- a/src/loader/level-key.ts +++ b/src/loader/level-key.ts @@ -1,33 +1,244 @@ -import { buildAbsoluteURL } from 'url-toolkit'; - -export class LevelKey { - private _uri: string | null = null; - public method: string | null = null; - public keyFormat: string | null = null; - public keyFormatVersions: string | null = null; - public keyID: string | null = null; - public key: Uint8Array | null = null; +import { + changeEndianness, + convertDataUriToArrayBytes, + strToUtf8array, +} from '../utils/keysystem-util'; +import { KeySystemFormats } from '../utils/mediakeys-helper'; +import { mp4Box, mp4pssh, writeUint32 } from '../utils/mp4-tools'; +import { logger } from '../utils/logger'; +import { base64Decode } from '../utils/numeric-encoding-utils'; + +let keyUriToKeyIdMap: { [uri: string]: Uint8Array } = {}; + +export interface DecryptData { + uri: string; + method: string; + keyFormat: string; + keyFormatVersions: number[]; + iv: Uint8Array | null; + key: Uint8Array | null; + keyId: Uint8Array | null; + pssh: Uint8Array | null; + encrypted: boolean; + isCommonEncryption: boolean; +} + +export class LevelKey implements DecryptData { + public readonly uri: string; + public readonly method: string; + public readonly keyFormat: string; + public readonly keyFormatVersions: number[]; + public readonly encrypted: boolean; + public readonly isCommonEncryption: boolean; public iv: Uint8Array | null = null; + public key: Uint8Array | null = null; + public keyId: Uint8Array | null = null; + public pssh: Uint8Array | null = null; - static fromURL(baseUrl: string, relativeUrl: string): LevelKey { - return new LevelKey(baseUrl, relativeUrl); + static clearKeyUriToKeyIdMap() { + keyUriToKeyIdMap = {}; } - static fromURI(uri: string): LevelKey { - return new LevelKey(uri); + constructor( + method: string, + uri: string, + format: string, + formatversions: number[] = [1], + iv: Uint8Array | null = null + ) { + this.method = method; + this.uri = uri; + this.keyFormat = format; + this.keyFormatVersions = formatversions; + this.iv = iv; + this.encrypted = method ? method !== 'NONE' : false; + this.isCommonEncryption = this.encrypted && method !== 'AES-128'; } - private constructor(absoluteOrBaseURI: string, relativeURL?: string) { - if (relativeURL) { - this._uri = buildAbsoluteURL(absoluteOrBaseURI, relativeURL, { - alwaysNormalize: true, - }); - } else { - this._uri = absoluteOrBaseURI; + public getDecryptData(sn: number | 'initSegment'): LevelKey | null { + if (!this.encrypted || !this.uri) { + return null; + } + + if (this.method === 'AES-128' && this.uri && !this.iv) { + if (typeof sn !== 'number') { + // We are fetching decryption data for a initialization segment + // If the segment was encrypted with AES-128 + // It must have an IV defined. We cannot substitute the Segment Number in. + if (this.method === 'AES-128' && !this.iv) { + logger.warn( + `missing IV for initialization segment with method="${this.method}" - compliance issue` + ); + } + // Explicitly set sn to resulting value from implicit conversions 'initSegment' values for IV generation. + sn = 0; + } + const iv = createInitializationVector(sn); + const decryptdata = new LevelKey( + this.method, + this.uri, + 'identity', + this.keyFormatVersions, + iv + ); + return decryptdata; + } + + // Initialize keyId if possible + const keyBytes = convertDataUriToArrayBytes(this.uri); + if (keyBytes) { + switch (this.keyFormat) { + case KeySystemFormats.WIDEVINE: + this.pssh = keyBytes; + // In case of widevine keyID is embedded in PSSH box. Read Key ID. + if (keyBytes.length >= 22) { + this.keyId = keyBytes.subarray( + keyBytes.length - 22, + keyBytes.length - 6 + ); + } + break; + case KeySystemFormats.FAIRPLAY: { + let keydata = keyBytes.subarray(0, 16); + if (keydata.length !== 16) { + const padded = new Uint8Array(16); + padded.set(keydata, 16 - keydata.length); + keydata = padded; + } + this.keyId = keydata; + break; + } + case KeySystemFormats.PLAYREADY: { + const PlayReadyKeySystemUUID = new Uint8Array([ + 0x9a, 0x04, 0xf0, 0x79, 0x98, 0x40, 0x42, 0x86, 0xab, 0x92, 0xe6, + 0x5b, 0xe0, 0x88, 0x5f, 0x95, + ]); + + this.pssh = mp4pssh(PlayReadyKeySystemUUID, null, keyBytes); + + const keyBytesUtf16 = new Uint16Array( + keyBytes.buffer, + keyBytes.byteOffset, + keyBytes.byteLength / 2 + ); + const keyByteStr = String.fromCharCode.apply( + null, + Array.from(keyBytesUtf16) + ); + + // Parse Playready WRMHeader XML + const xmlKeyBytes = keyByteStr.substring( + keyByteStr.indexOf('<'), + keyByteStr.length + ); + const parser = new DOMParser(); + const xmlDoc = parser.parseFromString(xmlKeyBytes, 'text/xml'); + const keyData = xmlDoc.getElementsByTagName('KID')[0]; + if (keyData) { + const keyId = keyData.childNodes[0] + ? keyData.childNodes[0].nodeValue + : keyData.getAttribute('VALUE'); + if (keyId) { + const keyIdArray = base64Decode(keyId).subarray(0, 16); + // KID value in PRO is a base64-encoded little endian GUID interpretation of UUID + // KID value in ‘tenc’ is a big endian UUID GUID interpretation of UUID + changeEndianness(keyIdArray); + this.keyId = keyIdArray; + } + } + break; + } + } + } + + // Default behavior: assign a new keyId for each uri + if (!this.keyId || this.keyId.byteLength !== 16) { + let keyId = keyUriToKeyIdMap[this.uri]; + if (!keyId) { + const val = + Object.keys(keyUriToKeyIdMap).length % Number.MAX_SAFE_INTEGER; + keyId = new Uint8Array(16); + const dv = new DataView(keyId.buffer, 12, 4); // Just set the last 4 bytes + dv.setUint32(0, val); + keyUriToKeyIdMap[this.uri] = keyId; + } + this.keyId = keyId; } + + if (this.keyFormat === KeySystemFormats.FAIRPLAY) { + this.pssh = getFairPlayV3Pssh( + this.keyId, + this.method, + this.keyFormatVersions + ); + } + + return this; } +} + +function createInitializationVector(segmentNumber: number): Uint8Array { + const uint8View = new Uint8Array(16); + for (let i = 12; i < 16; i++) { + uint8View[i] = (segmentNumber >> (8 * (15 - i))) & 0xff; + } + return uint8View; +} - get uri() { - return this._uri; +function getFairPlayV3Pssh( + keyId: Uint8Array, + method: string, + keyFormatVersions: number[] +): Uint8Array { + enum SchemeFourCC { + CENC = 0x63656e63, + CBCS = 0x63626373, } + const scheme = + method === 'ISO-23001-7' ? SchemeFourCC.CENC : SchemeFourCC.CBCS; + const FpsBoxTypes = { + fpsd: strToUtf8array('fpsd'), // Parent box containing all info + fpsi: strToUtf8array('fpsi'), // Common info + fpsk: strToUtf8array('fpsk'), // key request + fkri: strToUtf8array('fkri'), // key request info + fkvl: strToUtf8array('fkvl'), // version list + }; + const makeFpsKeySystemInfoBox = (scheme: SchemeFourCC): Uint8Array => { + const schemeArray = new Uint8Array(4); + writeUint32(schemeArray, 0, scheme); + return mp4Box(FpsBoxTypes.fpsi, new Uint8Array([0, 0, 0, 0]), schemeArray); + }; + const makeFpsKeyRequestBox = ( + keyId: Uint8Array, + versionList: Array + ): Uint8Array => { + const args = [ + FpsBoxTypes.fpsk, + mp4Box(FpsBoxTypes.fkri, new Uint8Array([0x00, 0x00, 0x00, 0x00]), keyId), + ]; + if (versionList.length) { + // List of integers + const versionListBuffer = new Uint8Array(4 * versionList.length); + let pos = 0; + for (const version of versionList) { + writeUint32(versionListBuffer, pos, version); + pos += 4; + } + args.push(mp4Box(FpsBoxTypes.fkvl, versionListBuffer)); + } + + const fpsk = mp4Box.apply(null, args as [ArrayLike, Uint8Array]); + return fpsk; + }; + const args = [ + FpsBoxTypes.fpsd, + makeFpsKeySystemInfoBox(scheme), + makeFpsKeyRequestBox(keyId, keyFormatVersions), + ]; + const data = mp4Box.apply(null, args as [ArrayLike, Uint8Array]); + const kFairPlayStreamingKeySystemUUID = new Uint8Array([ + 0x94, 0xce, 0x86, 0xfb, 0x07, 0xff, 0x4f, 0x43, 0xad, 0xb8, 0x93, 0xd2, + 0xfa, 0x96, 0x8c, 0xa2, + ]); + return mp4pssh(kFairPlayStreamingKeySystemUUID, null, data); } diff --git a/src/loader/m3u8-parser.ts b/src/loader/m3u8-parser.ts index df6c4fb7632..76b0466d356 100644 --- a/src/loader/m3u8-parser.ts +++ b/src/loader/m3u8-parser.ts @@ -4,6 +4,7 @@ import { DateRange } from './date-range'; import { Fragment, Part } from './fragment'; import { LevelDetails } from './level-details'; import { LevelKey } from './level-key'; +import { KeySystemFormats } from '../utils/mediakeys-helper'; import { AttrList } from '../utils/attr-list'; import { logger } from '../utils/logger'; @@ -207,7 +208,7 @@ export default class M3U8Parser { let frag: Fragment = new Fragment(type, baseurl); let result: RegExpExecArray | RegExpMatchArray | null; let i: number; - let levelkey: LevelKey | undefined; + let levelkeys: { [key: string]: LevelKey } | undefined; let firstPdtIndex = -1; let createNextFrag = false; @@ -242,8 +243,8 @@ export default class M3U8Parser { // url if (Number.isFinite(frag.duration)) { frag.start = totalduration; - if (levelkey) { - frag.levelkey = levelkey; + if (levelkeys) { + frag.levelkeys = levelkeys; } frag.sn = currentSN; frag.level = id; @@ -372,60 +373,52 @@ export default class M3U8Parser { const decryptiv = keyAttrs.hexadecimalInteger('IV'); const decryptkeyformatversions = keyAttrs.enumeratedString('KEYFORMATVERSIONS'); - const decryptkeyid = keyAttrs.enumeratedString('KEYID'); // From RFC: This attribute is OPTIONAL; its absence indicates an implicit value of "identity". 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.microsoft.playready', - 'urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed', // widevine (v2) - 'com.widevine', // earlier widevine (v1) - ]; - if ( - unsupportedKnownKeyformatsInManifest.indexOf(decryptkeyformat) > - -1 + !decryptmethod || + [ + 'NONE', + 'AES-128', + 'ISO-23001-7', + 'SAMPLE-AES', + 'SAMPLE-AES-CENC', + 'SAMPLE-AES-CTR', + ].indexOf(decryptmethod) === -1 ) { - logger.warn( - `Keyformat ${decryptkeyformat} is not supported from the manifest` - ); - continue; - } else if (decryptkeyformat !== 'identity') { - // We are supposed to skip keys we don't understand. - // As we currently only officially support identity keys - // from the manifest we shouldn't save any other key. - continue; - } - - // TODO: multiple keys can be defined on a fragment, and we need to support this - // for clients that support both playready and widevine - if (decryptmethod) { - // TODO: need to determine if the level key is actually a relative URL - // if it isn't, then we should instead construct the LevelKey using fromURI. - levelkey = LevelKey.fromURL(baseurl, decrypturi); - if ( - decrypturi && - ['AES-128', 'SAMPLE-AES', 'SAMPLE-AES-CENC'].indexOf( - decryptmethod - ) >= 0 - ) { - levelkey.method = decryptmethod; - levelkey.keyFormat = decryptkeyformat; - - if (decryptkeyid) { - levelkey.keyID = decryptkeyid; + logger.warn(`[Keys] Ignoring invalid EXT-X-KEY tag: "${value1}"`); + } else { + if (decrypturi && keyAttrs.IV && !decryptiv) { + logger.error(`Invalid IV: ${keyAttrs.IV}`); + } + // If decrypturi is a URI with a scheme, then baseurl will be ignored + // No uri is allowed when METHOD is NONE + const resolvedUri = decrypturi + ? M3U8Parser.resolve(decrypturi, baseurl) + : ''; + const keyFormatVersions = ( + decryptkeyformatversions ? decryptkeyformatversions : '1' + ) + .split('/') + .map(Number) + .filter(Number.isFinite); + + if (isKeyTagSupported(decryptkeyformat, decryptmethod)) { + if (decryptmethod === 'NONE' || !levelkeys) { + levelkeys = {}; } - - if (decryptkeyformatversions) { - levelkey.keyFormatVersions = decryptkeyformatversions; + if (levelkeys[decryptkeyformat]) { + levelkeys = Object.assign({}, levelkeys); } - - // Initialization Vector (IV) - levelkey.iv = decryptiv; + levelkeys[decryptkeyformat] = new LevelKey( + decryptmethod, + resolvedUri, + decryptkeyformat, + keyFormatVersions, + decryptiv + ); } } break; @@ -447,7 +440,7 @@ export default class M3U8Parser { // #EXTINF: 6.0 // #EXT-X-MAP:URI="init.mp4 const init = new Fragment(type, baseurl); - setInitSegment(init, mapAttrs, id, levelkey); + setInitSegment(init, mapAttrs, id, levelkeys); currentInitSegment = init; frag.initSegment = currentInitSegment; if ( @@ -458,7 +451,7 @@ export default class M3U8Parser { } } else { // Initial segment tag is before segment duration tag - setInitSegment(frag, mapAttrs, id, levelkey); + setInitSegment(frag, mapAttrs, id, levelkeys); currentInitSegment = frag; createNextFrag = true; } @@ -591,6 +584,27 @@ export default class M3U8Parser { } } +function isKeyTagSupported( + decryptformat: string, + decryptmethod: string +): boolean { + // If it's Segment encryption or No encryption, just select that key system + if ('AES-128' === decryptmethod || 'NONE' === decryptmethod) { + return true; + } + switch (decryptformat) { + case 'identity': + // maintain support for clear SAMPLE-AES with MPEG-3 TS + return true; + case KeySystemFormats.FAIRPLAY: + case KeySystemFormats.WIDEVINE: + case KeySystemFormats.PLAYREADY: + case KeySystemFormats.CLEARKEY: + return true; + } + return false; +} + function setCodecs(codecs: Array, level: LevelParsed) { ['video', 'audio', 'text'].forEach((type: CodecType) => { const filtered = codecs.filter((codec) => isCodecType(codec, type)); @@ -652,7 +666,7 @@ function setInitSegment( frag: Fragment, mapAttrs: AttrList, id: number, - levelkey: LevelKey | undefined + levelkeys: { [key: string]: LevelKey } | undefined ) { frag.relurl = mapAttrs.URI; if (mapAttrs.BYTERANGE) { @@ -660,8 +674,8 @@ function setInitSegment( } frag.level = id; frag.sn = 'initSegment'; - if (levelkey) { - frag.levelkey = levelkey; + if (levelkeys) { + frag.levelkeys = levelkeys; } frag.initSegment = null; } diff --git a/src/loader/playlist-loader.ts b/src/loader/playlist-loader.ts index ed496050557..406e829a2e9 100644 --- a/src/loader/playlist-loader.ts +++ b/src/loader/playlist-loader.ts @@ -565,7 +565,10 @@ class PlaylistLoader implements NetworkComponentAPI { sidxReferences.forEach((segmentRef, index) => { const segRefInfo = segmentRef.info; const frag = levelDetails.fragments[index]; - + if (!frag) { + logger.error(`no fragment for sidx index ${index}`); + return; + } if (frag.byteRange.length === 0) { frag.setByteRange( String(1 + segRefInfo.end - segRefInfo.start) + diff --git a/src/remux/passthrough-remuxer.ts b/src/remux/passthrough-remuxer.ts index 4b8f276ae9d..ee28c7cb257 100644 --- a/src/remux/passthrough-remuxer.ts +++ b/src/remux/passthrough-remuxer.ts @@ -2,7 +2,11 @@ import { flushTextTrackMetadataCueSamples, flushTextTrackUserdataCueSamples, } from './mp4-remuxer'; -import type { InitData, InitDataTrack } from '../utils/mp4-tools'; +import { + InitData, + InitDataTrack, + patchEncyptionData, +} from '../utils/mp4-tools'; import { getDuration, getStartDTS, @@ -24,6 +28,7 @@ import type { DemuxedUserdataTrack, PassthroughTrack, } from '../types/demuxer'; +import type { DecryptData } from '../loader/level-key'; class PassThroughRemuxer implements Remuxer { private emitInitSegment: boolean = false; @@ -48,11 +53,12 @@ class PassThroughRemuxer implements Remuxer { public resetInitSegment( initSegment: Uint8Array | undefined, audioCodec: string | undefined, - videoCodec: string | undefined + videoCodec: string | undefined, + decryptdata: DecryptData | null ) { this.audioCodec = audioCodec; this.videoCodec = videoCodec; - this.generateInitSegment(initSegment); + this.generateInitSegment(patchEncyptionData(initSegment, decryptdata)); this.emitInitSegment = true; } diff --git a/src/types/events.ts b/src/types/events.ts index a59acda90bd..6c1d0d70845 100644 --- a/src/types/events.ts +++ b/src/types/events.ts @@ -21,6 +21,7 @@ import type { ErrorDetails, ErrorTypes } from '../errors'; import type { MetadataSample, UserdataSample } from './demuxer'; import type { AttrList } from '../utils/attr-list'; import type { HlsListeners } from '../events'; +import { KeyLoaderInfo } from '../loader/key-loader'; export interface MediaAttachingData { media: HTMLMediaElement; @@ -338,6 +339,7 @@ export interface KeyLoadingData { export interface KeyLoadedData { frag: Fragment; + keyInfo: KeyLoaderInfo; } export interface BackBufferData { diff --git a/src/types/loader.ts b/src/types/loader.ts index 5770ae29197..670a7148ae8 100644 --- a/src/types/loader.ts +++ b/src/types/loader.ts @@ -1,5 +1,6 @@ import type { Fragment } from '../loader/fragment'; import type { Part } from '../loader/fragment'; +import type { KeyLoaderInfo } from '../loader/key-loader'; import type { LevelDetails } from '../loader/level-details'; import type { HlsUrlParameters } from './level'; @@ -24,7 +25,10 @@ export interface FragmentLoaderContext extends LoaderContext { resetIV?: boolean; } -export interface KeyLoaderContext extends FragmentLoaderContext {} +export interface KeyLoaderContext extends LoaderContext { + keyInfo: KeyLoaderInfo; + frag: Fragment; +} export interface LoaderConfiguration { // Max number of load retries diff --git a/src/types/remuxer.ts b/src/types/remuxer.ts index 63f944e984e..d5e78c70227 100644 --- a/src/types/remuxer.ts +++ b/src/types/remuxer.ts @@ -9,6 +9,7 @@ import { } from './demuxer'; import type { SourceBufferName } from './buffer'; import type { PlaylistLevelType } from './loader'; +import type { DecryptData } from '../loader/level-key'; export interface Remuxer { remux( @@ -24,7 +25,8 @@ export interface Remuxer { resetInitSegment( initSegment: Uint8Array | undefined, audioCodec: string | undefined, - videoCodec: string | undefined + videoCodec: string | undefined, + decryptdata: DecryptData | null ): void; resetTimeStamp(defaultInitPTS): void; resetNextTimestamp(): void; diff --git a/src/utils/keysystem-util.ts b/src/utils/keysystem-util.ts new file mode 100644 index 00000000000..fc7f33fc888 --- /dev/null +++ b/src/utils/keysystem-util.ts @@ -0,0 +1,48 @@ +import { base64Decode } from './numeric-encoding-utils'; + +function getKeyIdBytes(str: string): Uint8Array { + const keyIdbytes = strToUtf8array(str).subarray(0, 16); + const paddedkeyIdbytes = new Uint8Array(16); + paddedkeyIdbytes.set(keyIdbytes, 16 - keyIdbytes.length); + return paddedkeyIdbytes; +} + +export function changeEndianness(keyId: Uint8Array) { + const swap = function (array: Uint8Array, from: number, to: number) { + const cur = array[from]; + array[from] = array[to]; + array[to] = cur; + }; + + swap(keyId, 0, 3); + swap(keyId, 1, 2); + swap(keyId, 4, 5); + swap(keyId, 6, 7); +} + +export function convertDataUriToArrayBytes(uri: string): Uint8Array | null { + // data:[ + const colonsplit = uri.split(':'); + let keydata: Uint8Array | null = null; + if (colonsplit[0] === 'data' && colonsplit.length === 2) { + const semicolonsplit = colonsplit[1].split(';'); + const commasplit = semicolonsplit[semicolonsplit.length - 1].split(','); + if (commasplit.length === 2) { + const isbase64 = commasplit[0] === 'base64'; + const data = commasplit[1]; + if (isbase64) { + semicolonsplit.splice(-1, 1); // remove from processing + keydata = base64Decode(data); + } else { + keydata = getKeyIdBytes(data); + } + } + } + return keydata; +} + +export function strToUtf8array(str: string): Uint8Array { + return Uint8Array.from(unescape(encodeURIComponent(str)), (c) => + c.charCodeAt(0) + ); +} diff --git a/src/utils/mediakeys-helper.ts b/src/utils/mediakeys-helper.ts index fbad2414b3c..ca18c3f5736 100644 --- a/src/utils/mediakeys-helper.ts +++ b/src/utils/mediakeys-helper.ts @@ -1,17 +1,81 @@ +import type { DRMSystemOptions, EMEControllerConfig } from '../config'; + /** * @see https://developer.mozilla.org/en-US/docs/Web/API/Navigator/requestMediaKeySystemAccess */ export enum KeySystems { + CLEARKEY = 'org.w3.clearkey', + FAIRPLAY = 'com.apple.fps', + PLAYREADY = 'com.microsoft.playready', WIDEVINE = 'com.widevine.alpha', +} + +// Playlist parser +export enum KeySystemFormats { + CLEARKEY = 'org.w3.clearkey', + FAIRPLAY = 'com.apple.streamingkeydelivery', PLAYREADY = 'com.microsoft.playready', - FAIRPLAY = 'com.apple.fps', + WIDEVINE = 'urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed', +} + +export function keySystemFormatToKeySystemDomain( + format: KeySystemFormats +): KeySystems | undefined { + if (format === KeySystemFormats.FAIRPLAY) { + return KeySystems.FAIRPLAY; + } else if (format === KeySystemFormats.PLAYREADY) { + return KeySystems.PLAYREADY; + } else if (format === KeySystemFormats.WIDEVINE) { + return KeySystems.WIDEVINE; + } else if (format === KeySystemFormats.CLEARKEY) { + return KeySystems.CLEARKEY; + } +} + +export function keySystemDomainToKeySystemFormat( + keySystem: KeySystems +): KeySystemFormats | undefined { + if (keySystem === KeySystems.FAIRPLAY) { + return KeySystemFormats.FAIRPLAY; + } else if (keySystem === KeySystems.PLAYREADY) { + return KeySystemFormats.PLAYREADY; + } else if (keySystem === KeySystems.WIDEVINE) { + return KeySystemFormats.WIDEVINE; + } else if (keySystem === KeySystems.CLEARKEY) { + return KeySystemFormats.CLEARKEY; + } +} + +export function getKeySystemsForConfig( + config: EMEControllerConfig +): KeySystems[] { + const { drmSystems, widevineLicenseUrl } = config; + const keySystemsToAttempt: KeySystems[] = []; + [KeySystems.FAIRPLAY, KeySystems.PLAYREADY, KeySystems.CLEARKEY].forEach( + (keySystem) => { + if (drmSystems?.[keySystem]) { + keySystemsToAttempt.push(keySystem); + } + } + ); + if (widevineLicenseUrl || drmSystems?.[KeySystems.WIDEVINE]) { + keySystemsToAttempt.push(KeySystems.WIDEVINE); + } else if (keySystemsToAttempt.length === 0) { + keySystemsToAttempt.push( + KeySystems.WIDEVINE, + KeySystems.FAIRPLAY, + KeySystems.PLAYREADY + ); + } + return keySystemsToAttempt; } export type MediaKeyFunc = ( keySystem: KeySystems, supportedConfigurations: MediaKeySystemConfiguration[] ) => Promise; -const requestMediaKeySystemAccess = (function (): MediaKeyFunc | null { + +export const requestMediaKeySystemAccess = (function (): MediaKeyFunc | null { if ( typeof self !== 'undefined' && self.navigator && @@ -23,4 +87,68 @@ const requestMediaKeySystemAccess = (function (): MediaKeyFunc | null { } })(); -export { requestMediaKeySystemAccess }; +/** + * @see https://developer.mozilla.org/en-US/docs/Web/API/MediaKeySystemConfiguration + */ +export function getSupportedMediaKeySystemConfigurations( + keySystem: KeySystems, + audioCodecs: string[], + videoCodecs: string[], + drmSystemOptions: DRMSystemOptions +): MediaKeySystemConfiguration[] { + let initDataTypes: string[]; + switch (keySystem) { + case KeySystems.FAIRPLAY: + initDataTypes = ['cenc', 'sinf', 'skd']; + break; + case KeySystems.WIDEVINE: + case KeySystems.CLEARKEY: + initDataTypes = ['cenc', 'keyids']; + break; + case KeySystems.PLAYREADY: + initDataTypes = ['cenc']; + break; + default: + throw new Error(`Unknown key-system: ${keySystem}`); + } + return createMediaKeySystemConfigurations( + initDataTypes, + audioCodecs, + videoCodecs, + drmSystemOptions + ); +} + +function createMediaKeySystemConfigurations( + initDataTypes: string[], + audioCodecs: string[], + videoCodecs: string[], + drmSystemOptions: DRMSystemOptions +): MediaKeySystemConfiguration[] { + const baseConfig: MediaKeySystemConfiguration = { + initDataTypes: initDataTypes, + persistentState: drmSystemOptions.persistentState || 'not-allowed', + distinctiveIdentifier: + drmSystemOptions.distinctiveIdentifier || 'not-allowed', + sessionTypes: drmSystemOptions.sessionTypes || [ + drmSystemOptions.sessionType || 'temporary', + ], + audioCapabilities: [], + videoCapabilities: [], + }; + + 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]; +} diff --git a/src/utils/mp4-tools.ts b/src/utils/mp4-tools.ts index 66ef21cdc70..34ccb6af692 100644 --- a/src/utils/mp4-tools.ts +++ b/src/utils/mp4-tools.ts @@ -1,7 +1,10 @@ -import { sliceUint8 } from './typed-array'; import { ElementaryStreamTypes } from '../loader/fragment'; -import { PassthroughTrack, UserdataSample } from '../types/demuxer'; +import { sliceUint8 } from './typed-array'; import { utf8ArrayToStr } from '../demux/id3'; +import { logger } from '../utils/logger'; +import Hex from './hex'; +import type { PassthroughTrack, UserdataSample } from '../types/demuxer'; +import type { DecryptData } from '../loader/level-key'; const UINT32_MAX = Math.pow(2, 32) - 1; const push = [].push; @@ -272,6 +275,59 @@ export function parseInitSegment(initSegment: Uint8Array): InitData { return result; } +export function patchEncyptionData( + initSegment: Uint8Array | undefined, + decryptdata: DecryptData | null +): Uint8Array | undefined { + if (!initSegment || !decryptdata) { + return initSegment; + } + const keyId = decryptdata.keyId; + if (keyId && decryptdata.isCommonEncryption) { + const traks = findBox(initSegment, ['moov', 'trak']); + traks.forEach((trak) => { + const stsd = findBox(trak, ['mdia', 'minf', 'stbl', 'stsd'])[0]; + + // skip the sample entry count + const sampleEntries = stsd.subarray(8); + let encBoxes = findBox(sampleEntries, ['enca']); + const isAudio = encBoxes.length > 0; + if (!isAudio) { + encBoxes = findBox(sampleEntries, ['encv']); + } + encBoxes.forEach((enc) => { + const encBoxChildren = isAudio ? enc.subarray(28) : enc.subarray(78); + const sinfBoxes = findBox(encBoxChildren, ['sinf']); + sinfBoxes.forEach((sinf) => { + const schm = findBox(sinf, ['schm'])[0]; + if (!schm) { + logger.error(`[eme] missing 'schm' box`); + return; + } + const scheme = bin2str(schm.subarray(4, 8)); + if (scheme === 'cbcs' || scheme === 'cenc') { + const tenc = findBox(sinf, ['schi', 'tenc'])[0]; + if (tenc) { + // Look for default key id (keyID offset is always 8 within the tenc box): + const tencKeyId = tenc.subarray(8, 24); + if (!tencKeyId.some((b) => b !== 0)) { + logger.log( + `[eme] found 'tenc' patching map with keyId default: ${Hex.hexDump( + tencKeyId + )} -> ${Hex.hexDump(keyId)}` + ); + tenc.set(keyId, 8); + } + } + } + }); + }); + }); + } + + return initSegment; +} + /** * Determine the base media decode start time, in seconds, for an MP4 * fragment. If multiple fragments are specified, the earliest time is @@ -969,3 +1025,77 @@ export function parseEmsg(data: Uint8Array): IEmsgParsingData { payload, }; } + +export function mp4Box(type: ArrayLike, ...params: Uint8Array[]) { + const payload = Array.prototype.slice.call(arguments, 1); + const len = payload.length; + let size = 8; + let i = len; + while (i--) { + size += payload[i].byteLength; + } + const result = new Uint8Array(size); + result[0] = (size >> 24) & 0xff; + result[1] = (size >> 16) & 0xff; + result[2] = (size >> 8) & 0xff; + result[3] = size & 0xff; + result.set(type, 4); + for (i = 0, size = 8; i < len; i++) { + result.set(payload[i], size); + size += payload[i].byteLength; + } + return result; +} + +export function mp4pssh( + systemId: Uint8Array, + keyids: Array | null, + data: Uint8Array +) { + if (systemId.byteLength !== 16) { + throw new RangeError('Invalid system id'); + } + let version; + let kids; + if (keyids) { + version = 1; + kids = new Uint8Array(keyids.length * 16); + for (let ix = 0; ix < keyids.length; ix++) { + const k = keyids[ix]; // uint8array + if (k.byteLength !== 16) { + throw new RangeError('Invalid key'); + } + kids.set(k, ix * 16); + } + } else { + version = 0; + kids = new Uint8Array(); + } + let kidCount; + if (version > 0) { + kidCount = new Uint8Array(4); + if (keyids!.length > 0) { + new DataView(kidCount.buffer).setUint32(0, keyids!.length, false); + } + } else { + kidCount = new Uint8Array(); + } + const dataSize = new Uint8Array(4); + if (data && data.byteLength > 0) { + new DataView(dataSize.buffer).setUint32(0, data.byteLength, false); + } + return mp4Box( + [112, 115, 115, 104], + new Uint8Array([ + version, + 0x00, + 0x00, + 0x00, // Flags + ]), + systemId, // 16 bytes + kidCount, + kids, + dataSize, + data || new Uint8Array() + ); +} diff --git a/src/utils/numeric-encoding-utils.ts b/src/utils/numeric-encoding-utils.ts new file mode 100644 index 00000000000..f3318e190da --- /dev/null +++ b/src/utils/numeric-encoding-utils.ts @@ -0,0 +1,26 @@ +export function base64ToBase64Url(base64encodedStr: string): string { + return base64encodedStr + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +export function strToBase64Encode(str: string): string { + return btoa(str); +} + +export function base64DecodeToStr(str: string): string { + return atob(str); +} + +export function base64Encode(input: Uint8Array): string { + return btoa(String.fromCharCode(...input)); +} + +export function base64UrlEncode(input: Uint8Array): string { + return base64ToBase64Url(base64Encode(input)); +} + +export function base64Decode(base64encodedStr: string): Uint8Array { + return Uint8Array.from(atob(base64encodedStr), (c) => c.charCodeAt(0)); +} diff --git a/tests/unit/controller/audio-stream-controller.js b/tests/unit/controller/audio-stream-controller.js index ddc10858ca5..26f349b321a 100644 --- a/tests/unit/controller/audio-stream-controller.js +++ b/tests/unit/controller/audio-stream-controller.js @@ -1,6 +1,8 @@ import AudioStreamController from '../../../src/controller/audio-stream-controller'; import Hls from '../../../src/hls'; import { Events } from '../../../src/events'; +import { FragmentTracker } from '../../../src/controller/fragment-tracker'; +import KeyLoader from '../../../src/loader/key-loader'; describe('AudioStreamController', function () { const tracks = [ @@ -41,11 +43,19 @@ describe('AudioStreamController', function () { ]; let hls; + let fragmentTracker; + let keyLoader; let audioStreamController; beforeEach(function () { hls = new Hls(); - audioStreamController = new AudioStreamController(hls); + fragmentTracker = new FragmentTracker(hls); + keyLoader = new KeyLoader({}); + audioStreamController = new AudioStreamController( + hls, + fragmentTracker, + keyLoader + ); }); afterEach(function () { diff --git a/tests/unit/controller/eme-controller.js b/tests/unit/controller/eme-controller.js deleted file mode 100644 index 2abbc189362..00000000000 --- a/tests/unit/controller/eme-controller.js +++ /dev/null @@ -1,355 +0,0 @@ -import EMEController from '../../../src/controller/eme-controller'; -import HlsMock from '../../mocks/hls.mock'; -import { EventEmitter } from 'eventemitter3'; -import { ErrorDetails } from '../../../src/errors'; -import { Events } from '../../../src/events'; - -const sinon = require('sinon'); - -const MediaMock = function () { - const media = new EventEmitter(); - media.setMediaKeys = sinon.spy(); - media.addEventListener = media.addListener.bind(media); - media.removeEventListener = media.removeListener.bind(media); - return media; -}; - -const fakeLevels = [ - { - audioCodec: 'audio/foo', - }, - { - videoCodec: 'video/foo', - }, -]; - -let emeController; -let media; - -const setupEach = function (config) { - media = new MediaMock(); - - 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(); - }); - - it('should not do anything when `emeEnabled` is false (default)', function () { - const reqMediaKsAccessSpy = sinon.spy(); - - setupEach({ - requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy, - }); - - emeController.onMediaAttached(Events.MEDIA_ATTACHED, { media }); - emeController.onManifestParsed(Events.MANIFEST_PARSED, { media }); - - expect(media.setMediaKeys.callCount).to.equal(0); - expect(reqMediaKsAccessSpy.callCount).to.equal(0); - }); - - it('should request keys when `emeEnabled` is true (but not set them)', function (done) { - const reqMediaKsAccessSpy = sinon.spy(function () { - return Promise.resolve({ - // Media-keys mock - }); - }); - - setupEach({ - emeEnabled: true, - requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy, - }); - - emeController.onMediaAttached(Events.MEDIA_ATTACHED, { media }); - - expect(media.setMediaKeys.callCount).to.equal(0); - expect(reqMediaKsAccessSpy.callCount).to.equal(0); - - emeController.onManifestParsed(Events.MANIFEST_PARSED, { - levels: fakeLevels, - }); - - self.setTimeout(function () { - expect(media.setMediaKeys.callCount).to.equal(0); - expect(reqMediaKsAccessSpy.callCount).to.equal(1); - done(); - }, 0); - }); - - it('should request keys with specified robustness options when `emeEnabled` is true', function (done) { - const reqMediaKsAccessSpy = sinon.spy(function () { - return Promise.resolve({ - // Media-keys mock - }); - }); - - setupEach({ - emeEnabled: true, - drmSystemOptions: { - audioRobustness: 'HW_SECURE_ALL', - videoRobustness: 'HW_SECURE_ALL', - }, - requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy, - }); - - emeController.onMediaAttached(Events.MEDIA_ATTACHED, { media }); - - expect(media.setMediaKeys.callCount).to.equal(0); - expect(reqMediaKsAccessSpy.callCount).to.equal(0); - - emeController.onManifestParsed(Events.MANIFEST_PARSED, { - levels: fakeLevels, - }); - - self.setTimeout(function () { - expect(reqMediaKsAccessSpy.callCount).to.equal(1); - const baseConfig = reqMediaKsAccessSpy.getCall(0).args[1][0]; - expect(baseConfig.audioCapabilities[0]).to.have.property( - 'robustness', - 'HW_SECURE_ALL' - ); - expect(baseConfig.videoCapabilities[0]).to.have.property( - 'robustness', - 'HW_SECURE_ALL' - ); - done(); - }, 0); - }); - - it('should trigger key system error(s) when bad encrypted data is received', function (done) { - const reqMediaKsAccessSpy = sinon.spy(function () { - return Promise.resolve({ - // Media-keys mock - }); - }); - - setupEach({ - emeEnabled: true, - requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy, - }); - - const badData = { - initDataType: 'cenc', - initData: 'bad data', - }; - - emeController.onMediaAttached(Events.MEDIA_ATTACHED, { media }); - emeController.onManifestParsed(Events.MANIFEST_PARSED, { - levels: fakeLevels, - }); - - media.emit('encrypted', badData); - - self.setTimeout(function () { - expect(emeController.hls.trigger).to.have.been.calledTwice; - expect(emeController.hls.trigger.args[0][1].details).to.equal( - ErrorDetails.KEY_SYSTEM_NO_KEYS - ); - expect(emeController.hls.trigger.args[1][1].details).to.equal( - ErrorDetails.KEY_SYSTEM_NO_SESSION - ); - done(); - }, 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({ - // Media-keys mock - }); - }); - const keySessionCloseSpy = sinon.spy(() => Promise.resolve()); - - setupEach({ - emeEnabled: true, - requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy, - }); - - emeController.onMediaAttached(Events.MEDIA_ATTACHED, { media }); - emeController._mediaKeysList = [ - { - mediaKeysSession: { - close: keySessionCloseSpy, - }, - }, - ]; - emeController.onMediaDetached(Events.MEDIA_DETACHED); - - self.setTimeout(function () { - expect(keySessionCloseSpy.callCount).to.equal(1); - expect(emeController._mediaKeysList.length).to.equal(0); - expect(media.setMediaKeys.calledWith(null)).to.be.true; - done(); - }, 0); - }); -}); diff --git a/tests/unit/controller/eme-controller.ts b/tests/unit/controller/eme-controller.ts new file mode 100644 index 00000000000..18b0441d607 --- /dev/null +++ b/tests/unit/controller/eme-controller.ts @@ -0,0 +1,591 @@ +import EMEController, { + MediaKeySessionContext, +} from '../../../src/controller/eme-controller'; +import HlsMock from '../../mocks/hls.mock'; +import { EventEmitter } from 'eventemitter3'; +import { ErrorDetails } from '../../../src/errors'; +import { Events } from '../../../src/events'; + +import * as sinon from 'sinon'; +import * as chai from 'chai'; +import * as sinonChai from 'sinon-chai'; +import { MediaAttachedData } from '../../../src/types/events'; + +chai.use(sinonChai); +const expect = chai.expect; + +type EMEControllerTestable = Omit< + EMEController, + 'hls' | 'keyUriToKeySessionPromise' | 'mediaKeySessions' +> & { + hls: HlsMock; + keyUriToKeySessionPromise: { + [keyuri: string]: Promise; + }; + mediaKeySessions: MediaKeySessionContext[]; + onMediaAttached: ( + event: Events.MEDIA_ATTACHED, + data: MediaAttachedData + ) => void; + onMediaDetached: () => void; +}; + +class MediaMock extends EventEmitter { + setMediaKeys: sinon.SinonSpy<[mediaKeys: MediaKeys | null], Promise>; + addEventListener: any; + removeEventListener: any; + constructor() { + super(); + this.setMediaKeys = sinon.spy((mediaKeys: MediaKeys | null) => + Promise.resolve() + ); + this.addEventListener = this.addListener.bind(this); + this.removeEventListener = this.removeListener.bind(this); + } +} + +let emeController: EMEControllerTestable; +let media: MediaMock; +let sinonFakeXMLHttpRequestStatic: sinon.SinonFakeXMLHttpRequestStatic; + +const setupEach = function (config) { + const hls = new HlsMock(config); + hls.levels = [ + { + audioCodec: 'audio/foo', + }, + { + videoCodec: 'video/foo', + }, + ]; + media = new MediaMock(); + emeController = new EMEController(hls) as any as EMEControllerTestable; + sinonFakeXMLHttpRequestStatic = sinon.useFakeXMLHttpRequest(); +}; + +describe('EMEController', function () { + beforeEach(function () { + setupEach({}); + }); + + afterEach(function () { + sinonFakeXMLHttpRequestStatic.restore(); + }); + + it('should request keysystem access based on key format when `emeEnabled` is true', function () { + const reqMediaKsAccessSpy = sinon.spy(function () { + return Promise.resolve({ + // Media-keys mock + keySystem: 'com.apple.fps', + createMediaKeys: sinon.spy(() => + Promise.resolve({ + setServerCertificate: () => Promise.resolve(), + createSession: (): Partial => ({ + addEventListener: () => {}, + onmessage: null, + onkeystatuseschange: null, + generateRequest() { + return Promise.resolve().then(() => { + this.onmessage({ + messageType: 'license-request', + message: new Uint8Array(0), + }); + this.keyStatuses.set(new Uint8Array(0), 'usable'); + this.onkeystatuseschange({}); + }); + }, + remove: () => Promise.resolve(), + update: () => Promise.resolve(), + keyStatuses: new Map(), + }), + }) + ), + }); + }); + + setupEach({ + emeEnabled: true, + drmSystems: { + 'com.apple.fps': { + licenseUrl: 'http://noop', + }, + }, + requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy, + // useEmeEncryptedEvent: true, // skip generate request, license exchange, and key status "usable" + }); + + sinonFakeXMLHttpRequestStatic.onCreate = ( + xhr: sinon.SinonFakeXMLHttpRequest + ) => { + self.setTimeout(() => { + (xhr as any).response = new Uint8Array(); + xhr.respond(200, {}, ''); + }, 0); + }; + + emeController.onMediaAttached(Events.MEDIA_ATTACHED, { + media: media as any as HTMLMediaElement, + }); + + expect(media.setMediaKeys).callCount(0); + expect(reqMediaKsAccessSpy).callCount(0); + + const emePromise = emeController.loadKey({ + frag: {}, + keyInfo: { + decryptdata: { + encrypted: true, + method: 'SAMPLE-AES', + keyFormat: 'com.apple.streamingkeydelivery', + uri: 'data://key-uri', + keyId: new Uint8Array(16), + pssh: new Uint8Array(16), + }, + }, + } as any); + + expect(emePromise).to.be.a('Promise'); + if (!emePromise) { + return; + } + return emePromise.finally(() => { + expect(media.setMediaKeys).callCount(1); + expect(reqMediaKsAccessSpy).callCount(1); + }); + }); + + it('should request keys with specified robustness options when `emeEnabled` is true', function () { + const reqMediaKsAccessSpy = sinon.spy(function () { + return Promise.resolve({ + // Media-keys mock + keySystem: 'com.apple.fps', + createMediaKeys: sinon.spy(() => + Promise.resolve({ + setServerCertificate: () => Promise.resolve(), + createSession: (): Partial => ({ + addEventListener: () => {}, + onmessage: null, + onkeystatuseschange: null, + generateRequest() { + return Promise.resolve().then(() => { + this.onmessage({ + messageType: 'license-request', + message: new Uint8Array(0), + }); + this.keyStatuses.set(new Uint8Array(0), 'usable'); + this.onkeystatuseschange({}); + }); + }, + remove: () => Promise.resolve(), + update: () => Promise.resolve(), + keyStatuses: new Map(), + }), + }) + ), + }); + }); + + setupEach({ + emeEnabled: true, + drmSystems: { + 'com.apple.fps': { + licenseUrl: 'http://noop', + }, + }, + drmSystemOptions: { + audioRobustness: 'HW_SECURE_ALL', + videoRobustness: 'HW_SECURE_ALL', + }, + requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy, + }); + + sinonFakeXMLHttpRequestStatic.onCreate = ( + xhr: sinon.SinonFakeXMLHttpRequest + ) => { + self.setTimeout(() => { + (xhr as any).response = new Uint8Array(); + xhr.respond(200, {}, ''); + }, 0); + }; + + emeController.onMediaAttached(Events.MEDIA_ATTACHED, { + media: media as any as HTMLMediaElement, + }); + + expect(media.setMediaKeys).callCount(0); + expect(reqMediaKsAccessSpy).callCount(0); + + const emePromise = emeController.loadKey({ + frag: {}, + keyInfo: { + decryptdata: { + encrypted: true, + method: 'SAMPLE-AES', + keyFormat: 'com.apple.streamingkeydelivery', + uri: 'data://key-uri', + keyId: new Uint8Array(16), + pssh: new Uint8Array(16), + }, + }, + } as any); + + expect(emePromise).to.be.a('Promise'); + if (!emePromise) { + return; + } + return emePromise.finally(() => { + expect(reqMediaKsAccessSpy).callCount(1); + const args = reqMediaKsAccessSpy.getCall(0) + .args as MediaKeySystemConfiguration[][]; + const baseConfig: MediaKeySystemConfiguration = args[1][0]; + expect(baseConfig.audioCapabilities) + .to.be.an('array') + .with.property('0') + .with.property('robustness', 'HW_SECURE_ALL'); + expect(baseConfig.videoCapabilities) + .to.be.an('array') + .with.property('0') + .with.property('robustness', 'HW_SECURE_ALL'); + }); + }); + + it('should trigger key system error(s) when bad encrypted data is received', function () { + const reqMediaKsAccessSpy = sinon.spy(function () { + return Promise.resolve({ + // Media-keys mock + keySystem: 'com.apple.fps', + createMediaKeys: sinon.spy(() => + Promise.resolve({ + setServerCertificate: () => Promise.resolve(), + createSession: () => ({ + addEventListener: () => {}, + generateRequest: () => Promise.reject(new Error('bad data')), + remove: () => Promise.resolve(), + update: () => Promise.resolve(), + keyStatuses: new Map(), + }), + }) + ), + }); + }); + + setupEach({ + emeEnabled: true, + useEmeEncryptedEvent: true, + requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy, + }); + + const badData = { + initDataType: 'cenc', + initData: 'bad data', + }; + + emeController.onMediaAttached(Events.MEDIA_ATTACHED, { + media: media as any as HTMLMediaElement, + }); + + media.emit('encrypted', badData); + + expect(emeController.keyUriToKeySessionPromise.encrypted).to.be.a( + 'Promise' + ); + if (!emeController.keyUriToKeySessionPromise.encrypted) { + return; + } + return emeController.keyUriToKeySessionPromise.encrypted + .catch(() => {}) + .finally(() => { + expect(emeController.hls.trigger).callCount(1); + expect(emeController.hls.trigger.args[0][1].details).to.equal( + ErrorDetails.KEY_SYSTEM_NO_SESSION + ); + }); + }); + + it('should fetch the server certificate and set it into the session', function () { + const mediaKeysSetServerCertificateSpy = sinon.spy(() => Promise.resolve()); + + const reqMediaKsAccessSpy = sinon.spy(function () { + return Promise.resolve({ + // Media-keys mock + keySystem: 'com.apple.fps', + createMediaKeys: sinon.spy(() => + Promise.resolve({ + setServerCertificate: mediaKeysSetServerCertificateSpy, + createSession: () => ({ + addEventListener: () => {}, + onmessage: null, + onkeystatuseschange: null, + generateRequest() { + return Promise.resolve().then(() => { + this.onmessage({ + messageType: 'license-request', + message: new Uint8Array(0), + }); + this.keyStatuses.set(new Uint8Array(0), 'usable'); + this.onkeystatuseschange({}); + }); + }, + remove: () => Promise.resolve(), + update: () => Promise.resolve(), + keyStatuses: new Map(), + }), + }) + ), + }); + }); + + setupEach({ + emeEnabled: true, + requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy, + drmSystems: { + 'com.apple.fps': { + serverCertificateUrl: 'https://example.com/certificate.cer', + }, + }, + }); + + let xhrInstance; + sinonFakeXMLHttpRequestStatic.onCreate = ( + xhr: sinon.SinonFakeXMLHttpRequest + ) => { + xhrInstance = xhr; + self.setTimeout(() => { + (xhr as any).response = new Uint8Array(); + xhr.respond(200, {}, ''); + }, 0); + }; + + emeController.onMediaAttached(Events.MEDIA_ATTACHED, { + media: media as any as HTMLMediaElement, + }); + emeController.loadKey({ + frag: {}, + keyInfo: { + decryptdata: { + encrypted: true, + method: 'SAMPLE-AES', + uri: 'data://key-uri', + keyFormatVersions: [1], + keyId: new Uint8Array(16), + pssh: new Uint8Array(16), + }, + }, + } as any); + + expect(emeController.keyUriToKeySessionPromise['data://key-uri']).to.be.a( + 'Promise' + ); + if (!emeController.keyUriToKeySessionPromise['data://key-uri']) { + return; + } + return emeController.keyUriToKeySessionPromise['data://key-uri'].finally( + () => { + expect(mediaKeysSetServerCertificateSpy).to.have.been.calledOnce; + expect(mediaKeysSetServerCertificateSpy).to.have.been.calledWith( + xhrInstance.response + ); + } + ); + }); + + it('should fetch the server certificate and trigger update failed error', function () { + const mediaKeysSetServerCertificateSpy = sinon.spy(() => + Promise.reject(new Error('Failed')) + ); + + const reqMediaKsAccessSpy = sinon.spy(function () { + return Promise.resolve({ + // Media-keys mock + keySystem: 'com.apple.fps', + createMediaKeys: sinon.spy(() => + Promise.resolve({ + setServerCertificate: mediaKeysSetServerCertificateSpy, + createSession: () => ({ + addEventListener: () => {}, + generateRequest: () => Promise.resolve(), + remove: () => Promise.resolve(), + update: () => Promise.resolve(), + keyStatuses: new Map(), + }), + }) + ), + }); + }); + + setupEach({ + emeEnabled: true, + requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy, + drmSystems: { + 'com.apple.fps': { + serverCertificateUrl: 'https://example.com/certificate.cer', + }, + }, + }); + + let xhrInstance; + sinonFakeXMLHttpRequestStatic.onCreate = ( + xhr: sinon.SinonFakeXMLHttpRequest + ) => { + xhrInstance = xhr; + self.setTimeout(() => { + (xhr as any).response = new Uint8Array(); + xhr.respond(200, {}, ''); + }, 0); + }; + + emeController.onMediaAttached(Events.MEDIA_ATTACHED, { + media: media as any as HTMLMediaElement, + }); + emeController.loadKey({ + frag: {}, + keyInfo: { + decryptdata: { + encrypted: true, + method: 'SAMPLE-AES', + uri: 'data://key-uri', + }, + }, + } as any); + + expect( + emeController.keyUriToKeySessionPromise['data://key-uri'] + ).to.not.equal(null); + if (!emeController.keyUriToKeySessionPromise['data://key-uri']) { + return; + } + return emeController.keyUriToKeySessionPromise['data://key-uri'] + .catch(() => {}) + .finally(() => { + expect(mediaKeysSetServerCertificateSpy).to.have.been.calledOnce; + expect((mediaKeysSetServerCertificateSpy.args[0] as any)[0]).to.equal( + xhrInstance.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 + ); + }); + }); + + it('should fetch the server certificate and trigger request failed error', function () { + const reqMediaKsAccessSpy = sinon.spy(function () { + return Promise.resolve({ + // Media-keys mock + keySystem: 'com.apple.fps', + createMediaKeys: sinon.spy(() => + Promise.resolve({ + createSession: () => ({ + addEventListener: () => {}, + generateRequest: () => Promise.resolve(), + remove: () => Promise.resolve(), + update: () => Promise.resolve(), + keyStatuses: new Map(), + }), + }) + ), + }); + }); + + setupEach({ + emeEnabled: true, + requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy, + drmSystems: { + 'com.apple.fps': { + serverCertificateUrl: 'https://example.com/certificate.cer', + }, + }, + }); + + sinonFakeXMLHttpRequestStatic.onCreate = ( + xhr: sinon.SinonFakeXMLHttpRequest + ) => { + self.setTimeout(() => { + xhr.status = 400; + xhr.error(); + }, 0); + }; + + emeController.onMediaAttached(Events.MEDIA_ATTACHED, { + media: media as any as HTMLMediaElement, + }); + emeController.loadKey({ + frag: {}, + keyInfo: { + decryptdata: { + encrypted: true, + method: 'SAMPLE-AES', + uri: 'data://key-uri', + }, + }, + } as any); + + expect( + emeController.keyUriToKeySessionPromise['data://key-uri'] + ).to.not.equal(null); + if (!emeController.keyUriToKeySessionPromise['data://key-uri']) { + return; + } + return emeController.keyUriToKeySessionPromise['data://key-uri'] + .catch(() => {}) + .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 + ); + }); + }); + + it('should close all media key sessions and remove media keys when media is detached', function () { + const reqMediaKsAccessSpy = sinon.spy(function () { + return Promise.resolve({ + // Media-keys mock + keySystem: 'com.apple.fps', + createMediaKeys: sinon.spy(() => + Promise.resolve({ + setServerCertificate: () => Promise.resolve(), + createSession: () => ({ + addEventListener: () => {}, + generateRequest: () => Promise.resolve(), + remove: () => Promise.resolve(), + update: () => Promise.resolve(), + keyStatuses: new Map(), + }), + }) + ), + }); + }); + const keySessionRemoveSpy = sinon.spy(() => Promise.resolve()); + const keySessionCloseSpy = sinon.spy(() => Promise.resolve()); + + setupEach({ + emeEnabled: true, + requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy, + }); + + emeController.onMediaAttached(Events.MEDIA_ATTACHED, { + media: media as any as HTMLMediaElement, + }); + emeController.mediaKeySessions = [ + { + mediaKeysSession: { + remove: keySessionRemoveSpy, + close: keySessionCloseSpy, + }, + } as any, + ]; + emeController.onMediaDetached(); + + expect(EMEController.CDMCleanupPromise).to.be.a('Promise'); + if (!EMEController.CDMCleanupPromise) { + return; + } + return EMEController.CDMCleanupPromise.then(() => { + expect(keySessionRemoveSpy).callCount(1); + expect(keySessionCloseSpy).callCount(1); + expect(emeController.mediaKeySessions.length).to.equal(0); + expect(media.setMediaKeys).calledWith(null); + }); + }); +}); diff --git a/tests/unit/controller/subtitle-stream-controller.js b/tests/unit/controller/subtitle-stream-controller.js index 44a2964dffe..55c026c742a 100644 --- a/tests/unit/controller/subtitle-stream-controller.js +++ b/tests/unit/controller/subtitle-stream-controller.js @@ -3,6 +3,7 @@ import sinon from 'sinon'; import Hls from '../../../src/hls'; import { Events } from '../../../src/events'; import { FragmentTracker } from '../../../src/controller/fragment-tracker'; +import KeyLoader from '../../../src/loader/key-loader'; import { SubtitleStreamController } from '../../../src/controller/subtitle-stream-controller'; const mediaMock = { @@ -16,15 +17,19 @@ const tracksMock = [{ id: 0, details: { url: '' } }, { id: 1 }]; describe('SubtitleStreamController', function () { let hls; let fragmentTracker; + let keyLoader; let subtitleStreamController; beforeEach(function () { hls = new Hls({}); mediaMock.currentTime = 0; fragmentTracker = new FragmentTracker(hls); + keyLoader = new KeyLoader({}); + subtitleStreamController = new SubtitleStreamController( hls, - fragmentTracker + fragmentTracker, + keyLoader ); subtitleStreamController.onMediaAttached(Events.MEDIA_ATTACHED, { diff --git a/tests/unit/loader/fragment-loader.ts b/tests/unit/loader/fragment-loader.ts index 3f7d9ab1b49..bee9c8071df 100644 --- a/tests/unit/loader/fragment-loader.ts +++ b/tests/unit/loader/fragment-loader.ts @@ -1,5 +1,6 @@ import FragmentLoader, { LoadError } from '../../../src/loader/fragment-loader'; import { Fragment } from '../../../src/loader/fragment'; +import { LevelDetails } from '../../../src/loader/level-details'; import { ErrorDetails, ErrorTypes } from '../../../src/errors'; import { LoadStats } from '../../../src/loader/load-stats'; import { @@ -15,7 +16,6 @@ import type { HlsConfig } from '../../../src/config'; import * as sinon from 'sinon'; import * as chai from 'chai'; import * as sinonChai from 'sinon-chai'; -import { LevelDetails } from '../../../src/loader/level-details'; chai.use(sinonChai); const expect = chai.expect; diff --git a/tests/unit/loader/fragment.js b/tests/unit/loader/fragment.ts similarity index 60% rename from tests/unit/loader/fragment.js rename to tests/unit/loader/fragment.ts index ab222fe62bd..54566ce448a 100644 --- a/tests/unit/loader/fragment.js +++ b/tests/unit/loader/fragment.ts @@ -2,11 +2,17 @@ import { Fragment } from '../../../src/loader/fragment'; import { LevelKey } from '../../../src/loader/level-key'; import { PlaylistLevelType } from '../../../src/types/loader'; +import * as chai from 'chai'; +import * as sinonChai from 'sinon-chai'; + +chai.use(sinonChai); +const expect = chai.expect; + describe('Fragment class tests', function () { /** * @type {Fragment} */ - let frag; + let frag: Fragment; beforeEach(function () { frag = new Fragment(PlaylistLevelType.MAIN, ''); }); @@ -15,51 +21,37 @@ describe('Fragment class tests', function () { it('returns true if an EXT-X-KEY is associated with the fragment', function () { // From https://docs.microsoft.com/en-us/azure/media-services/previous/media-services-protect-with-aes128 - const key = LevelKey.fromURL( - 'https://wamsbayclus001kd-hs.cloudapp.net', - './HlsHandler.ashx?kid=da3813af-55e6-48e7-aa9f-a4d6031f7b4d' - ); - key.method = 'AES-128'; - key.iv = '0XD7D7D7D7D7D7D7D7D7D7D7D7D7D7D7D7'; - key.keyFormat = 'identity'; - frag.levelkey = key; - expect(frag.decryptdata.uri).to.equal( - 'https://wamsbayclus001kd-hs.cloudapp.net/HlsHandler.ashx?kid=da3813af-55e6-48e7-aa9f-a4d6031f7b4d' + const key = new LevelKey( + 'AES-128', + 'https://wamsbayclus001kd-hs.cloudapp.net/HlsHandler.ashx?kid=da3813af-55e6-48e7-aa9f-a4d6031f7b4d', + 'identity' ); + frag.levelkeys = { identity: key }; expect(frag.encrypted).to.equal(true); }); - it('returns true for widevine v2 manifest signalled encryption', function () { - // #EXT-X-KEY:METHOD=SAMPLE-AES,URI=”data:text/plain;base64,AAAAPXBzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAAB0aDXdpZGV2aW5lX3Rlc3QiDHRlc3QgY29udGVudA==”,KEYID=0x112233445566778899001122334455,KEYFORMAT=”urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed”,KEYFORMATVERSION=”1” - // From https://www.academia.edu/36030972/Widevine_DRM_for_HLS - - const key = LevelKey.fromURI( - 'data:text/plain;base64,AAAAPXBzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAAB0aDXdpZGV2aW5lX3Rlc3QiDHRlc3QgY29udGVudA==' - ); - key.method = 'SAMPLE-AES'; - key.keyFormat = 'urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed'; - key.keyFormatVersions = '1'; - frag.levelkey = key; - expect(frag.decryptdata.uri).to.equal( - 'data:text/plain;base64,AAAAPXBzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAAB0aDXdpZGV2aW5lX3Rlc3QiDHRlc3QgY29udGVudA==' + it('returns true for fairplay manifest signalled encryption', function () { + const key = new LevelKey( + 'SAMPLE-AES', + 'skd://one', + 'com.apple.streamingkeydelivery', + [1] ); + frag.levelkeys = { 'com.apple.streamingkeydelivery': key }; expect(frag.encrypted).to.equal(true); }); - it('returns true for widevine v1 manifest signalled encryption', function () { - // #EXT-X-KEY:METHOD=SAMPLE-AES,URI=”data:text/plain;base64,eyAKICAgInByb3ZpZGVyIjoibWxiYW1oYm8iLAogICAiY29udGVudF9pZCI6Ik1qQXhOVjlVWldGeWN3PT0iLAogICAia2V5X2lkcyI6CiAgIFsKICAgICAgIjM3MWUxMzVlMWE5ODVkNzVkMTk4YTdmNDEwMjBkYzIzIgogICBdCn0=",IV=0x6df49213a781e338628d0e9c812d328e,KEYFORMAT=”com.widevine”,KEYFORMATVERSIONS=”1” + it('returns true for widevine v2 manifest signalled encryption', function () { + // #EXT-X-KEY:METHOD=SAMPLE-AES,URI=”data:text/plain;base64,AAAAPXBzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAAB0aDXdpZGV2aW5lX3Rlc3QiDHRlc3QgY29udGVudA==”,KEYID=0x112233445566778899001122334455,KEYFORMAT=”urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed”,KEYFORMATVERSION=”1” // From https://www.academia.edu/36030972/Widevine_DRM_for_HLS - const key = LevelKey.fromURI( - 'data:text/plain;base64,eyAKICAgInByb3ZpZGVyIjoibWxiYW1oYm8iLAogICAiY29udGVudF9pZCI6Ik1qQXhOVjlVWldGeWN3PT0iLAogICAia2V5X2lkcyI6CiAgIFsKICAgICAgIjM3MWUxMzVlMWE5ODVkNzVkMTk4YTdmNDEwMjBkYzIzIgogICBdCn0=' - ); - key.method = 'SAMPLE-AES'; - key.keyFormat = 'com.widevine'; - key.keyFormatVersions = '1'; - frag.levelkey = key; - expect(frag.decryptdata.uri).to.equal( - 'data:text/plain;base64,eyAKICAgInByb3ZpZGVyIjoibWxiYW1oYm8iLAogICAiY29udGVudF9pZCI6Ik1qQXhOVjlVWldGeWN3PT0iLAogICAia2V5X2lkcyI6CiAgIFsKICAgICAgIjM3MWUxMzVlMWE5ODVkNzVkMTk4YTdmNDEwMjBkYzIzIgogICBdCn0=' + const key = new LevelKey( + 'SAMPLE-AES', + 'data:text/plain;base64,AAAAPXBzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAAB0aDXdpZGV2aW5lX3Rlc3QiDHRlc3QgY29udGVudA==', + 'urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed', + [1] ); + frag.levelkeys = { 'urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed': key }; expect(frag.encrypted).to.equal(true); }); @@ -67,18 +59,21 @@ describe('Fragment class tests', function () { // #EXT-X-KEY:METHOD=SAMPLE-AES,KEYFORMAT="com.microsoft.playready",KEYFORMATVERSIONS="1",URI="data:text/plain;charset=UTF-16;base64,xAEAAAEAAQC6ATwAVwBSAE0ASABFAEEARABFAFIAIAB4AG0AbABuAHMAPQAiAGgAdAB0AHAAOgAvAC8AcwBjAGgAZQBtAGEAcwAuAG0AaQBjAHIAbwBzAG8AZgB0AC4AYwBvAG0ALwBEAFIATQAvADIAMAAwADcALwAwADMALwBQAGwAYQB5AFIAZQBhAGQAeQBIAGUAYQBkAGUAcgAiACAAdgBlAHIAcwBpAG8AbgA9ACIANAAuADAALgAwAC4AMAAiAD4APABEAEEAVABBAD4APABQAFIATwBUAEUAQwBUAEkATgBGAE8APgA8AEsARQBZAEwARQBOAD4AMQA2ADwALwBLAEUAWQBMAEUATgA+ADwAQQBMAEcASQBEAD4AQQBFAFMAQwBUAFIAPAAvAEEATABHAEkARAA+ADwALwBQAFIATwBUAEUAQwBUAEkATgBGAE8APgA8AEsASQBEAD4AdgBHAFYAagBOAEsAZwBZAE0ARQBxAHAATwBMAGgAMQBWAGQAUgBUADAAQQA9AD0APAAvAEsASQBEAD4APAAvAEQAQQBUAEEAPgA8AC8AVwBSAE0ASABFAEEARABFAFIAPgA=" // From https://docs.microsoft.com/en-us/playready/packaging/mp4-based-formats-supported-by-playready-clients?tabs=case4 - const key = LevelKey.fromURI( - 'data:text/plain;charset=UTF-16;base64,xAEAAAEAAQC6ATwAVwBSAE0ASABFAEEARABFAFIAIAB4AG0AbABuAHMAPQAiAGgAdAB0AHAAOgAvAC8AcwBjAGgAZQBtAGEAcwAuAG0AaQBjAHIAbwBzAG8AZgB0AC4AYwBvAG0ALwBEAFIATQAvADIAMAAwADcALwAwADMALwBQAGwAYQB5AFIAZQBhAGQAeQBIAGUAYQBkAGUAcgAiACAAdgBlAHIAcwBpAG8AbgA9ACIANAAuADAALgAwAC4AMAAiAD4APABEAEEAVABBAD4APABQAFIATwBUAEUAQwBUAEkATgBGAE8APgA8AEsARQBZAEwARQBOAD4AMQA2ADwALwBLAEUAWQBMAEUATgA+ADwAQQBMAEcASQBEAD4AQQBFAFMAQwBUAFIAPAAvAEEATABHAEkARAA+ADwALwBQAFIATwBUAEUAQwBUAEkATgBGAE8APgA8AEsASQBEAD4AdgBHAFYAagBOAEsAZwBZAE0ARQBxAHAATwBMAGgAMQBWAGQAUgBUADAAQQA9AD0APAAvAEsASQBEAD4APAAvAEQAQQBUAEEAPgA8AC8AVwBSAE0ASABFAEEARABFAFIAPgA=' - ); - key.method = 'SAMPLE-AES'; - key.keyFormat = 'com.microsoft.playready'; - key.keyFormatVersions = '1'; - frag.levelkey = key; - expect(frag.decryptdata.uri).to.equal( - 'data:text/plain;charset=UTF-16;base64,xAEAAAEAAQC6ATwAVwBSAE0ASABFAEEARABFAFIAIAB4AG0AbABuAHMAPQAiAGgAdAB0AHAAOgAvAC8AcwBjAGgAZQBtAGEAcwAuAG0AaQBjAHIAbwBzAG8AZgB0AC4AYwBvAG0ALwBEAFIATQAvADIAMAAwADcALwAwADMALwBQAGwAYQB5AFIAZQBhAGQAeQBIAGUAYQBkAGUAcgAiACAAdgBlAHIAcwBpAG8AbgA9ACIANAAuADAALgAwAC4AMAAiAD4APABEAEEAVABBAD4APABQAFIATwBUAEUAQwBUAEkATgBGAE8APgA8AEsARQBZAEwARQBOAD4AMQA2ADwALwBLAEUAWQBMAEUATgA+ADwAQQBMAEcASQBEAD4AQQBFAFMAQwBUAFIAPAAvAEEATABHAEkARAA+ADwALwBQAFIATwBUAEUAQwBUAEkATgBGAE8APgA8AEsASQBEAD4AdgBHAFYAagBOAEsAZwBZAE0ARQBxAHAATwBMAGgAMQBWAGQAUgBUADAAQQA9AD0APAAvAEsASQBEAD4APAAvAEQAQQBUAEEAPgA8AC8AVwBSAE0ASABFAEEARABFAFIAPgA=' + const key = new LevelKey( + 'SAMPLE-AES', + 'data:text/plain;charset=UTF-16;base64,xAEAAAEAAQC6ATwAVwBSAE0ASABFAEEARABFAFIAIAB4AG0AbABuAHMAPQAiAGgAdAB0AHAAOgAvAC8AcwBjAGgAZQBtAGEAcwAuAG0AaQBjAHIAbwBzAG8AZgB0AC4AYwBvAG0ALwBEAFIATQAvADIAMAAwADcALwAwADMALwBQAGwAYQB5AFIAZQBhAGQAeQBIAGUAYQBkAGUAcgAiACAAdgBlAHIAcwBpAG8AbgA9ACIANAAuADAALgAwAC4AMAAiAD4APABEAEEAVABBAD4APABQAFIATwBUAEUAQwBUAEkATgBGAE8APgA8AEsARQBZAEwARQBOAD4AMQA2ADwALwBLAEUAWQBMAEUATgA+ADwAQQBMAEcASQBEAD4AQQBFAFMAQwBUAFIAPAAvAEEATABHAEkARAA+ADwALwBQAFIATwBUAEUAQwBUAEkATgBGAE8APgA8AEsASQBEAD4AdgBHAFYAagBOAEsAZwBZAE0ARQBxAHAATwBMAGgAMQBWAGQAUgBUADAAQQA9AD0APAAvAEsASQBEAD4APAAvAEQAQQBUAEEAPgA8AC8AVwBSAE0ASABFAEEARABFAFIAPgA=', + 'com.microsoft.playready', + [1] ); + frag.levelkeys = { 'com.microsoft.playready': key }; expect(frag.encrypted).to.equal(true); }); + + it('returns false when key method is NONE', function () { + const key = new LevelKey('NONE', 'plain-text', 'org.w3.clearkey', [1]); + frag.levelkeys = { NONE: key }; + expect(frag.encrypted).to.equal(false); + }); }); describe('setByteRange', function () { @@ -117,14 +112,14 @@ describe('Fragment class tests', function () { }); it('returns null if pdt is NaN', function () { - frag.programDateTime = 'foo'; + frag.programDateTime = NaN; frag.duration = 1; expect(frag.endProgramDateTime).to.equal(null); }); it('defaults duration to 0 if duration is NaN', function () { frag.programDateTime = 1000; - frag.duration = 'foo'; + frag.duration = NaN; expect(frag.endProgramDateTime).to.equal(1000); }); }); diff --git a/tests/unit/loader/playlist-loader.js b/tests/unit/loader/playlist-loader.ts similarity index 90% rename from tests/unit/loader/playlist-loader.js rename to tests/unit/loader/playlist-loader.ts index 4adccc0afa1..6d7367e435f 100644 --- a/tests/unit/loader/playlist-loader.js +++ b/tests/unit/loader/playlist-loader.ts @@ -2,6 +2,14 @@ import M3U8Parser from '../../../src/loader/m3u8-parser'; import { AttrList } from '../../../src/utils/attr-list'; import { PlaylistLevelType } from '../../../src/types/loader'; +import * as chai from 'chai'; +import * as sinonChai from 'sinon-chai'; +import { LevelKey } from '../../../src/loader/level-key'; +import { Fragment, Part } from '../../../src/loader/fragment'; + +chai.use(sinonChai); +const expect = chai.expect; + describe('PlaylistLoader', function () { it('parses empty manifest returns empty array', function () { const result = M3U8Parser.parseMasterPlaylist( @@ -70,7 +78,7 @@ http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/ manifest, 'http://www.dailymotion.com' ); - expect(result.levels.length, 1); + expect(result.levels.length).to.equal(1); expect(result.levels[0].bitrate).to.equal(836280); expect(result.levels[0].audioCodec).to.not.exist; expect(result.levels[0].videoCodec).to.not.exist; @@ -92,13 +100,13 @@ http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/ manifest, 'http://www.dailymotion.com' ); - expect(result.levels.length, 1); - expect(result.levels[0].bitrate, 836280); - expect(result.levels[0].audioCodec, 'mp4a.40.2'); - expect(result.levels[0].videoCodec, 'avc1.64001f'); - expect(result.levels[0].width, 848); - expect(result.levels[0].height, 360); - expect(result.levels[0].name, '480'); + expect(result.levels.length).to.equal(1); + expect(result.levels[0].bitrate).to.equal(836280); + expect(result.levels[0].audioCodec).to.equal('mp4a.40.2'); + expect(result.levels[0].videoCodec).to.equal('avc1.64001f'); + expect(result.levels[0].width).to.equal(848); + expect(result.levels[0].height).to.equal(360); + expect(result.levels[0].name).to.equal('480'); expect( result.levels[0].url, 'http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/282/158282701_mp4_h264_aac_hq.m3u8#cell=core' @@ -113,7 +121,7 @@ http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/ manifest, 'http://www.dailymotion.com' ); - expect(result.levels.length, 1); + expect(result.levels.length).to.equal(1); expect(result.levels[0].bitrate).to.equal(836280); expect(result.levels[0].audioCodec).to.equal('mp4a.40.2'); expect(result.levels[0].videoCodec).to.equal('avc1.64001f'); @@ -173,7 +181,7 @@ http://proxy-21.dailymotion.com/sec(2a991e17f08fcd94f95637a6dd718ddd)/video/107/ manifest, 'http://www.dailymotion.com' ); - expect(result.levels.length, 10); + expect(result.levels.length).to.equal(10); expect(result.levels[0].bitrate).to.equal(836280); expect(result.levels[1].bitrate).to.equal(836280); expect(result.levels[2].bitrate).to.equal(246440); @@ -204,7 +212,7 @@ http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/ }), }; expect(result.sessionData).to.deep.equal(expected); - expect(result.levels.length, 1); + expect(result.levels.length).to.equal(1); }); it('parses manifest with EXT-X-SESSION-DATA and 10 levels', function () { @@ -242,7 +250,7 @@ http://proxy-21.dailymotion.com/sec(2a991e17f08fcd94f95637a6dd718ddd)/video/107/ }), }; expect(result.sessionData).to.deep.equal(expected); - expect(result.levels.length, 10); + expect(result.levels.length).to.equal(10); }); it('parses manifest with multiple EXT-X-SESSION-DATA', function () { @@ -280,6 +288,8 @@ http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/ const result = M3U8Parser.parseLevelPlaylist( level, 'http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/282/158282701_mp4_h264_aac_hq.m3u8#cell=core', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.fragments).to.have.lengthOf(0); @@ -294,6 +304,8 @@ http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/ const result = M3U8Parser.parseLevelPlaylist( level, 'http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/282/158282701_mp4_h264_aac_hq.m3u8#cell=core', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.fragments).to.have.lengthOf(0); @@ -320,6 +332,8 @@ http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/ const result = M3U8Parser.parseLevelPlaylist( level, 'http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/282/158282701_mp4_h264_aac_hq.m3u8#cell=core', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.totalduration).to.equal(51.24); @@ -361,7 +375,7 @@ http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/ ); const initSegment = result.fragments[0].initSegment; expect(initSegment).to.be.ok; - expect(initSegment.relurl).to.equal('/something.mp4?abc'); + expect(initSegment?.relurl).to.equal('/something.mp4?abc'); }); it('parse level with single char fragment URI', function () { @@ -376,6 +390,8 @@ http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/ const result = M3U8Parser.parseLevelPlaylist( level, 'http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/frag(5)/video/107/282/158282701_mp4_h264_aac_hq.m3u8#cell=core', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.totalduration).to.equal(4); @@ -414,6 +430,8 @@ chop/segment-5.ts const result = M3U8Parser.parseLevelPlaylist( level, 'http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/frag(5)/video/107/282/158282701_mp4_h264_aac_hq.m3u8#cell=core', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.totalduration).to.equal(30); @@ -455,6 +473,8 @@ chop/segment-5.ts const result = M3U8Parser.parseLevelPlaylist( level, 'http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/282/158282701_mp4_h264_aac_hq.m3u8#cell=core', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.totalduration).to.equal(51.24); @@ -482,6 +502,8 @@ oceans_aes-audio=65000-video=236000-3.ts const result = M3U8Parser.parseLevelPlaylist( level, 'http://foo.com/adaptive/oceans_aes/oceans_aes.m3u8', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.totalduration).to.equal(25); @@ -496,7 +518,18 @@ oceans_aes-audio=65000-video=236000-3.ts expect(result.fragments[0].url).to.equal( 'http://foo.com/adaptive/oceans_aes/oceans_aes-audio=65000-video=236000-1.ts' ); - expect(result.fragments[0].decryptdata).to.be.null; + expectWithJSONMessage( + result.fragments[0].levelkeys?.['com.apple.streamingkeydelivery'], + 'levelkeys' + ).to.deep.include({ + uri: 'skd://assetid?keyId=1234', + method: 'AES-128', + keyFormat: 'com.apple.streamingkeydelivery', + keyFormatVersions: [1], + iv: null, + key: null, + keyId: null, + }); }); it('parse AES encrypted URLs, with implicit IV', function () { @@ -517,6 +550,8 @@ oceans_aes-audio=65000-video=236000-3.ts const result = M3U8Parser.parseLevelPlaylist( level, 'http://foo.com/adaptive/oceans_aes/oceans_aes.m3u8', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.totalduration).to.equal(25); @@ -531,17 +566,17 @@ oceans_aes-audio=65000-video=236000-3.ts expect(result.fragments[0].url).to.equal( 'http://foo.com/adaptive/oceans_aes/oceans_aes-audio=65000-video=236000-1.ts' ); - expect(result.fragments[0].decryptdata.uri).to.equal( + expect(result.fragments[0].decryptdata?.uri).to.equal( 'http://foo.com/adaptive/oceans_aes/oceans.key' ); - expect(result.fragments[0].decryptdata.method).to.equal('AES-128'); + expect(result.fragments[0].decryptdata?.method).to.equal('AES-128'); let sn = 1; let uint8View = new Uint8Array(16); for (let i = 12; i < 16; i++) { uint8View[i] = (sn >> (8 * (15 - i))) & 0xff; } - expect(result.fragments[0].decryptdata.iv.buffer).to.deep.equal( + expect(result.fragments[0].decryptdata?.iv?.buffer).to.deep.equal( uint8View.buffer ); @@ -551,7 +586,7 @@ oceans_aes-audio=65000-video=236000-3.ts uint8View[i] = (sn >> (8 * (15 - i))) & 0xff; } - expect(result.fragments[2].decryptdata.iv.buffer).to.deep.equal( + expect(result.fragments[2].decryptdata?.iv?.buffer).to.deep.equal( uint8View.buffer ); }); @@ -596,9 +631,11 @@ lo008ts`; const result = M3U8Parser.parseLevelPlaylist( level, 'http://dummy.com/playlist.m3u8', + 0, + PlaylistLevelType.MAIN, 0 ); - expect(result.fragments.length, 10); + expect(result.fragments.length).to.equal(10); expect(result.fragments[0].url).to.equal('http://dummy.com/lo007ts'); expect(result.fragments[0].byteRangeStartOffset).to.equal(803136); expect(result.fragments[0].byteRangeEndOffset).to.equal(943196); @@ -649,6 +686,8 @@ lo008ts`; const result = M3U8Parser.parseLevelPlaylist( level, 'http://dummy.com/playlist.m3u8', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.fragments).to.have.lengthOf(10); @@ -681,9 +720,11 @@ lo007ts`; const result = M3U8Parser.parseLevelPlaylist( level, 'http://dummy.com/playlist.m3u8', + 0, + PlaylistLevelType.MAIN, 0 ); - expect(result.fragments.length, 3); + expect(result.fragments.length).to.equal(3); expect(result.fragments[0].url).to.equal('http://dummy.com/lo007ts'); expect(result.fragments[0].byteRangeStartOffset).to.equal(803136); expect(result.fragments[0].byteRangeEndOffset).to.equal(943196); @@ -715,6 +756,8 @@ lo007ts`; const result = M3U8Parser.parseLevelPlaylist( level, 'http://video.example.com/disc.m3u8', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.fragments).to.have.lengthOf(5); @@ -746,6 +789,8 @@ lo007ts`; const result = M3U8Parser.parseLevelPlaylist( level, 'http://video.example.com/disc.m3u8', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.fragments).to.have.lengthOf(5); @@ -763,7 +808,7 @@ lo007ts`; 'https://hls.ted.com/', 'AUDIO' ); - expect(result.length, 1); + expect(result.length).to.equal(1); expect(result[0].autoselect).to.be.true; expect(result[0].default).to.be.true; expect(result[0].forced).to.be.false; @@ -801,22 +846,26 @@ lo007ts`; const result = M3U8Parser.parseLevelPlaylist( level, 'http://dummy.com/playlist.m3u8', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.fragments).to.have.lengthOf(8); expect(result.totalduration).to.equal(80); - let fragdecryptdata, - decryptdata = result.fragments[0].decryptdata, - sn = 0; + let fragdecryptdata; + let decryptdata: LevelKey = result.fragments[0].decryptdata as LevelKey; + let sn = 0; result.fragments.forEach(function (fragment, idx) { sn = idx + 1; - expect(fragment.url, 'http://dummy.com/000' + sn + '.ts'); + expect(fragment.url).to.equal('http://dummy.com/000' + sn + '.ts'); // decryptdata should persist across all fragments fragdecryptdata = fragment.decryptdata; + + expect(decryptdata).to.not.equal(null); expect(fragdecryptdata.method).to.equal(decryptdata.method); expect(fragdecryptdata.uri).to.equal(decryptdata.uri); expect(fragdecryptdata.key).to.equal(decryptdata.key); @@ -826,7 +875,7 @@ lo007ts`; expect(iv[15]).to.equal(idx); // hold this decrypt data to compare to the next fragment's decrypt data - decryptdata = fragment.decryptdata; + decryptdata = fragment.decryptdata as LevelKey; }); }); @@ -861,6 +910,8 @@ http://dummy.url.com/hls/live/segment/segment_022916_164500865_719935.ts`; const result = M3U8Parser.parseLevelPlaylist( level, 'http://dummy.url.com/playlist.m3u8', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.fragments).to.have.lengthOf(10); @@ -901,6 +952,8 @@ Rollover38803/20160525T064049-01-69844069.ts const result = M3U8Parser.parseLevelPlaylist( level, 'http://video.example.com/disc.m3u8', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.fragments).to.have.lengthOf(3); @@ -931,6 +984,8 @@ Rollover38803/20160525T064049-01-69844069.ts const result = M3U8Parser.parseLevelPlaylist( level, 'http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/282/158282701_mp4_h264_aac_hq.m3u8#cell=core', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.fragments).to.have.lengthOf(1); @@ -951,15 +1006,17 @@ main.mp4`; const result = M3U8Parser.parseLevelPlaylist( level, 'http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/282/158282701_mp4_h264_aac_hq.m3u8#cell=core', + 0, + PlaylistLevelType.MAIN, 0 ); const initSegment = result.fragments[0].initSegment; - expect(initSegment.url).to.equal( + expect(initSegment?.url).to.equal( 'http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/282/main.mp4' ); - expect(initSegment.byteRangeStartOffset).to.equal(0); - expect(initSegment.byteRangeEndOffset).to.equal(718); - expect(initSegment.sn).to.equal('initSegment'); + expect(initSegment?.byteRangeStartOffset).to.equal(0); + expect(initSegment?.byteRangeEndOffset).to.equal(718); + expect(initSegment?.sn).to.equal('initSegment'); }); it('parses multiple #EXT-X-MAP URI', function () { @@ -980,16 +1037,18 @@ frag2.mp4 const result = M3U8Parser.parseLevelPlaylist( level, 'http://video.example.com/disc.m3u8', + 0, + PlaylistLevelType.MAIN, 0 ); - expect(result.fragments[0].initSegment.url).to.equal( + expect(result.fragments[0].initSegment?.url).to.equal( 'http://video.example.com/main.mp4' ); - expect(result.fragments[0].initSegment.sn).to.equal('initSegment'); - expect(result.fragments[1].initSegment.url).to.equal( + expect(result.fragments[0].initSegment?.sn).to.equal('initSegment'); + expect(result.fragments[1].initSegment?.url).to.equal( 'http://video.example.com/alt.mp4' ); - expect(result.fragments[1].initSegment.sn).to.equal('initSegment'); + expect(result.fragments[1].initSegment?.sn).to.equal('initSegment'); }); describe('PDT calculations', function () { @@ -1011,6 +1070,8 @@ Rollover38803/20160525T064049-01-69844069.ts const result = M3U8Parser.parseLevelPlaylist( level, 'http://video.example.com/disc.m3u8', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.hasProgramDateTime).to.be.true; @@ -1042,6 +1103,8 @@ frag2.ts const result = M3U8Parser.parseLevelPlaylist( level, 'http://video.example.com/disc.m3u8', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.hasProgramDateTime).to.be.true; @@ -1066,6 +1129,8 @@ frag2.ts const result = M3U8Parser.parseLevelPlaylist( level, 'http://video.example.com/disc.m3u8', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.hasProgramDateTime).to.be.true; @@ -1102,6 +1167,8 @@ frag5.ts const result = M3U8Parser.parseLevelPlaylist( level, 'http://video.example.com/disc.m3u8', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.hasProgramDateTime).to.be.true; @@ -1134,10 +1201,11 @@ frag1.ts const result = M3U8Parser.parseLevelPlaylist( level, 'http://video.example.com/disc.m3u8', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.hasProgramDateTime).to.be.true; - expect(result.sn).to.not.equal('initSegment'); expect(result.fragments[0].rawProgramDateTime).to.equal( '2016-05-27T16:35:04Z' ); @@ -1155,6 +1223,8 @@ frag1.ts const result = M3U8Parser.parseLevelPlaylist( level, 'http://video.example.com/disc.m3u8', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.hasProgramDateTime).to.be.false; @@ -1260,65 +1330,66 @@ fileSequence1151226.ts`, // TODO: Partial Segments for a yet to be appended EXT-INF entry will be added to the fragments list // once PartLoader is implemented to abstract away part loading complexity using progressive loader events expect(details.fragments).to.have.lengthOf(8); - expect(details.partList).to.be.an('array').which.has.lengthOf(8); - expect(details.partList[0].fragment).to.equal(details.fragments[6]); - expect(details.partList[1].fragment).to.equal(details.fragments[6]); - expect(details.partList[2].fragment).to.equal(details.fragments[6]); - expect(details.partList[3].fragment).to.equal(details.fragments[6]); - expect(details.partList[4].fragment).to.equal(details.fragments[7]); - expect(details.partList[5].fragment).to.equal(details.fragments[7]); - expect(details.partList[6].fragment).to.equal(details.fragments[7]); - expect(details.partList[7].fragment).to.equal(details.fragments[7]); - expectWithJSONMessage(details.partList[0], '6-0').to.deep.include({ + const partList = details.partList as Part[]; + expect(partList).to.be.an('array').which.has.lengthOf(8); + expect(partList[0].fragment).to.equal(details.fragments[6]); + expect(partList[1].fragment).to.equal(details.fragments[6]); + expect(partList[2].fragment).to.equal(details.fragments[6]); + expect(partList[3].fragment).to.equal(details.fragments[6]); + expect(partList[4].fragment).to.equal(details.fragments[7]); + expect(partList[5].fragment).to.equal(details.fragments[7]); + expect(partList[6].fragment).to.equal(details.fragments[7]); + expect(partList[7].fragment).to.equal(details.fragments[7]); + expectWithJSONMessage(partList[0], '6-0').to.deep.include({ duration: 1, gap: false, independent: true, index: 0, relurl: 'lowLatencyHLS.php?segment=filePart1151232.1.ts', }); - expectWithJSONMessage(details.partList[1], '6-1').to.deep.include({ + expectWithJSONMessage(partList[1], '6-1').to.deep.include({ duration: 1.00001, gap: false, independent: false, index: 1, relurl: 'lowLatencyHLS.php?segment=filePart1151232.2.ts', }); - expectWithJSONMessage(details.partList[2], '6-2').to.deep.include({ + expectWithJSONMessage(partList[2], '6-2').to.deep.include({ duration: 1, gap: false, independent: true, index: 2, relurl: 'lowLatencyHLS.php?segment=filePart1151232.3.ts', }); - expectWithJSONMessage(details.partList[3], '6-3').to.deep.include({ + expectWithJSONMessage(partList[3], '6-3').to.deep.include({ duration: 1, gap: false, independent: true, index: 3, relurl: 'lowLatencyHLS.php?segment=filePart1151232.4.ts', }); - expectWithJSONMessage(details.partList[4], '7-0').to.deep.include({ + expectWithJSONMessage(partList[4], '7-0').to.deep.include({ duration: 1, gap: false, independent: true, index: 0, relurl: 'lowLatencyHLS.php?segment=filePart1151233.1.ts', }); - expectWithJSONMessage(details.partList[5], '7-1').to.deep.include({ + expectWithJSONMessage(partList[5], '7-1').to.deep.include({ duration: 0.99999, gap: false, independent: true, index: 1, relurl: 'lowLatencyHLS.php?segment=filePart1151233.2.ts', }); - expectWithJSONMessage(details.partList[6], '7-2').to.deep.include({ + expectWithJSONMessage(partList[6], '7-2').to.deep.include({ duration: 1, gap: false, independent: false, index: 2, relurl: 'lowLatencyHLS.php?segment=filePart1151233.3.ts', }); - expectWithJSONMessage(details.partList[7], '7-3').to.deep.include({ + expectWithJSONMessage(partList[7], '7-3').to.deep.include({ duration: 1, gap: true, independent: true, @@ -1336,8 +1407,8 @@ fileSequence1151226.ts`, 0 ); expect(details.preloadHint).to.be.an('object'); - expect(details.preloadHint.TYPE).to.equal('PART'); - expect(details.preloadHint.URI).to.equal( + expect(details.preloadHint?.TYPE).to.equal('PART'); + expect(details.preloadHint?.URI).to.equal( 'lowLatencyHLS.php?segment=filePart1151234.1.ts' ); }); @@ -1350,14 +1421,13 @@ fileSequence1151226.ts`, PlaylistLevelType.MAIN, 0 ); - expect(details.renditionReports).to.be.an('array').which.has.lengthOf(2); - expect(details.renditionReports[0].URI).to.equal( - '/media0/lowLatencyHLS.php' - ); - expect(details.renditionReports[0]['LAST-MSN']).to.equal('1151201'); - expect(details.renditionReports[0]['LAST-PART']).to.equal('3'); - expect(details.renditionReports[0]['LAST-I-MSN']).to.equal('1151201'); - expect(details.renditionReports[0]['LAST-I-PART']).to.equal('3'); + const renditionReports = details.renditionReports as AttrList[]; + expect(renditionReports).to.be.an('array').which.has.lengthOf(2); + expect(renditionReports[0].URI).to.equal('/media0/lowLatencyHLS.php'); + expect(renditionReports[0]['LAST-MSN']).to.equal('1151201'); + expect(renditionReports[0]['LAST-PART']).to.equal('3'); + expect(renditionReports[0]['LAST-I-MSN']).to.equal('1151201'); + expect(renditionReports[0]['LAST-I-PART']).to.equal('3'); }); }); @@ -1384,15 +1454,16 @@ fileSequence2.ts PlaylistLevelType.MAIN, 0 ); - expectWithJSONMessage(details.fragments[0].tagList).to.deep.equal([ + const fragments = details.fragments as Fragment[]; + expectWithJSONMessage(fragments[0].tagList).to.deep.equal([ ['INF', '5.97263', '\t'], ['BITRATE', '5083'], ]); - expectWithJSONMessage(details.fragments[1].tagList).to.deep.equal([ + expectWithJSONMessage(fragments[1].tagList).to.deep.equal([ ['INF', '5.97263', '\t'], ['BITRATE', '5453'], ]); - expectWithJSONMessage(details.fragments[2].tagList).to.deep.equal([ + expectWithJSONMessage(fragments[2].tagList).to.deep.equal([ ['INF', '5.97263', '\t'], ['BITRATE', '4802'], ]); @@ -1419,16 +1490,15 @@ fileSequence2.ts PlaylistLevelType.MAIN, 0 ); - expectWithJSONMessage(details.fragments[0].tagList).to.deep.equal([ + const fragments = details.fragments as Fragment[]; + expectWithJSONMessage(fragments[0].tagList).to.deep.equal([ ['INF', '5', 'title'], ]); - expectWithJSONMessage(details.fragments[1].tagList).to.deep.equal([ + expectWithJSONMessage(fragments[1].tagList).to.deep.equal([ ['INF', '5'], ['GAP'], ]); - expectWithJSONMessage(details.fragments[2].tagList).to.deep.equal([ - ['INF', '5'], - ]); + expectWithJSONMessage(fragments[2].tagList).to.deep.equal([['INF', '5']]); }); it('adds unhandled tags (DATERANGE) and comments to fragment.tagList', function () { @@ -1501,6 +1571,8 @@ http://dummy.url.com/hls/live/segment/segment_022916_164500865_719928.ts const result = M3U8Parser.parseLevelPlaylist( level, 'http://dummy.url.com/playlist.m3u8', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.fragments[2].tagList[0][0]).to.equal('EXT-X-CUSTOM-DATE'); @@ -1528,6 +1600,8 @@ http://dummy.url.com/hls/live/segment/segment_022916_164500865_719928.ts const result = M3U8Parser.parseLevelPlaylist( level, 'http://dummy.url.com/playlist.m3u8', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.fragments.length).to.equal(2); @@ -1557,6 +1631,8 @@ http://dummy.url.com/hls/live/segment/segment_022916_164500865_719928.ts const result = M3U8Parser.parseLevelPlaylist( level, 'http://dummy.url.com/playlist.m3u8', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.fragments.length).to.equal(2); @@ -1634,6 +1710,8 @@ media_1638278.m4s`; const result = M3U8Parser.parseLevelPlaylist( level, 'http://foo.com/adaptive/test.m3u8', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.fragments.length).to.equal(22); @@ -1646,6 +1724,6 @@ media_1638278.m4s`; }); }); -function expectWithJSONMessage(value, msg) { +function expectWithJSONMessage(value: any, msg?: string) { return expect(value, `${msg || 'actual:'} ${JSON.stringify(value, null, 2)}`); } From b30ed725b2c85cf16981f321d62a9aa4b4fcf3c4 Mon Sep 17 00:00:00 2001 From: Rob Walch Date: Wed, 5 Oct 2022 18:52:32 -0700 Subject: [PATCH 03/19] Update README `licenseXhrSetup` example --- docs/API.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/API.md b/docs/API.md index bd60663ebf0..0170f665c1e 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1234,9 +1234,12 @@ var config = { xhr.setRequestHeader('Content-Type', 'application/octet-stream'); } - // Return the desired payload or a Promise - // return Promise.resolve(payload); - return payload; + // Return the desired payload or a Promise. + // Not returning a value, or returning `undefined` or` Promise` will result in the `licenseChallenge` being used. + return fetchDRMToken(this.authData).then((result) => { + xhr.setRequestHeader('token', token); + return payload; + }); }, }; ``` From acf4309f516d13faca7a30e1813b2adaff6385a3 Mon Sep 17 00:00:00 2001 From: Rob Walch Date: Wed, 19 Oct 2022 16:31:00 -0700 Subject: [PATCH 04/19] Update api-extractor markdown --- api-extractor/report/hls.js.api.md | 346 ++++++++++++++++------------- 1 file changed, 191 insertions(+), 155 deletions(-) diff --git a/api-extractor/report/hls.js.api.md b/api-extractor/report/hls.js.api.md index b6e1d80a2b3..4371f0bf667 100644 --- a/api-extractor/report/hls.js.api.md +++ b/api-extractor/report/hls.js.api.md @@ -4,7 +4,7 @@ ```ts -// Warning: (ae-missing-release-tag) "ABRControllerConfig" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "ABRControllerConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type ABRControllerConfig = { @@ -20,7 +20,7 @@ export type ABRControllerConfig = { maxLoadingDelay: number; }; -// Warning: (ae-missing-release-tag) "AttrList" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "AttrList" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export class AttrList { @@ -50,18 +50,18 @@ export class AttrList { static parseAttrList(input: string): Record; } -// Warning: (ae-missing-release-tag) "AudioPlaylistType" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "AudioPlaylistType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type AudioPlaylistType = 'AUDIO'; -// Warning: (ae-missing-release-tag) "AudioTrackLoadedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "AudioTrackLoadedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface AudioTrackLoadedData extends TrackLoadedData { } -// Warning: (ae-missing-release-tag) "AudioTracksUpdatedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "AudioTracksUpdatedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface AudioTracksUpdatedData { @@ -69,7 +69,7 @@ export interface AudioTracksUpdatedData { audioTracks: MediaPlaylist[]; } -// Warning: (ae-missing-release-tag) "AudioTrackSwitchedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "AudioTrackSwitchedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface AudioTrackSwitchedData { @@ -77,7 +77,7 @@ export interface AudioTrackSwitchedData { id: number; } -// Warning: (ae-missing-release-tag) "AudioTrackSwitchingData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "AudioTrackSwitchingData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface AudioTrackSwitchingData { @@ -93,7 +93,7 @@ export interface AudioTrackSwitchingData { url: string; } -// Warning: (ae-missing-release-tag) "BackBufferData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "BackBufferData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface BackBufferData { @@ -101,7 +101,7 @@ export interface BackBufferData { bufferEnd: number; } -// Warning: (ae-missing-release-tag) "BaseSegment" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "BaseSegment" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export class BaseSegment { @@ -125,7 +125,7 @@ export class BaseSegment { set url(value: string); } -// Warning: (ae-missing-release-tag) "BufferAppendedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "BufferAppendedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface BufferAppendedData { @@ -143,7 +143,7 @@ export interface BufferAppendedData { type: SourceBufferName; } -// Warning: (ae-missing-release-tag) "BufferAppendingData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "BufferAppendingData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface BufferAppendingData { @@ -161,7 +161,7 @@ export interface BufferAppendingData { type: SourceBufferName; } -// Warning: (ae-missing-release-tag) "BufferCodecsData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "BufferCodecsData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface BufferCodecsData { @@ -171,7 +171,7 @@ export interface BufferCodecsData { video?: Track; } -// Warning: (ae-missing-release-tag) "BufferControllerConfig" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "BufferControllerConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type BufferControllerConfig = { @@ -181,7 +181,7 @@ export type BufferControllerConfig = { liveBackBufferLength: number | null; }; -// Warning: (ae-missing-release-tag) "BufferCreatedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "BufferCreatedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface BufferCreatedData { @@ -189,7 +189,7 @@ export interface BufferCreatedData { tracks: TrackSet; } -// Warning: (ae-missing-release-tag) "BufferEOSData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "BufferEOSData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface BufferEOSData { @@ -197,7 +197,7 @@ export interface BufferEOSData { type?: SourceBufferName; } -// Warning: (ae-missing-release-tag) "BufferFlushedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "BufferFlushedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface BufferFlushedData { @@ -205,7 +205,7 @@ export interface BufferFlushedData { type: SourceBufferName; } -// Warning: (ae-missing-release-tag) "BufferFlushingData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "BufferFlushingData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface BufferFlushingData { @@ -219,14 +219,14 @@ export interface BufferFlushingData { type: SourceBufferName | null; } -// Warning: (ae-missing-release-tag) "CapLevelControllerConfig" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "CapLevelControllerConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type CapLevelControllerConfig = { capLevelToPlayerSize: boolean; }; -// Warning: (ae-missing-release-tag) "ChunkMetadata" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "ChunkMetadata" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export class ChunkMetadata { @@ -251,7 +251,7 @@ export class ChunkMetadata { readonly transmuxing: HlsChunkPerformanceTiming; } -// Warning: (ae-missing-release-tag) "CMCDControllerConfig" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "CMCDControllerConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type CMCDControllerConfig = { @@ -260,7 +260,7 @@ export type CMCDControllerConfig = { useHeaders?: boolean; }; -// Warning: (ae-missing-release-tag) "CuesInterface" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "CuesInterface" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface CuesInterface { @@ -270,7 +270,7 @@ export interface CuesInterface { newCue(track: TextTrack | null, startTime: number, endTime: number, captionScreen: CaptionScreen): VTTCue[]; } -// Warning: (ae-missing-release-tag) "CuesParsedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "CuesParsedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface CuesParsedData { @@ -282,7 +282,7 @@ export interface CuesParsedData { type: 'captions' | 'subtitles'; } -// Warning: (ae-missing-release-tag) "DateRange" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "DateRange" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export class DateRange { @@ -307,15 +307,19 @@ export class DateRange { get startDate(): Date; } -// Warning: (ae-missing-release-tag) "DRMSystemOptions" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "DRMSystemOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type DRMSystemOptions = { audioRobustness?: string; videoRobustness?: string; + persistentState?: MediaKeysRequirement; + distinctiveIdentifier?: MediaKeysRequirement; + sessionTypes?: string[]; + sessionType?: string; }; -// Warning: (ae-missing-release-tag) "ElementaryStreamInfo" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "ElementaryStreamInfo" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface ElementaryStreamInfo { @@ -331,12 +335,12 @@ export interface ElementaryStreamInfo { startPTS: number; } -// Warning: (ae-missing-release-tag) "ElementaryStreams" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "ElementaryStreams" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type ElementaryStreams = Record; -// Warning: (ae-missing-release-tag) "ElementaryStreamTypes" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "ElementaryStreamTypes" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export enum ElementaryStreamTypes { @@ -348,20 +352,21 @@ export enum ElementaryStreamTypes { VIDEO = "video" } -// Warning: (ae-missing-release-tag) "EMEControllerConfig" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "EMEControllerConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type EMEControllerConfig = { - licenseXhrSetup?: (xhr: XMLHttpRequest, url: string, keySystem: KeySystems) => void | Promise; - licenseResponseCallback?: (xhr: XMLHttpRequest, url: string, keySystem: KeySystems) => ArrayBuffer; + licenseXhrSetup?: (this: Hls, xhr: XMLHttpRequest, url: string, keyContext: MediaKeySessionContext, licenseChallenge: Uint8Array) => void | Promise; + licenseResponseCallback?: (this: Hls, xhr: XMLHttpRequest, url: string, keyContext: MediaKeySessionContext) => ArrayBuffer; emeEnabled: boolean; + useEmeEncryptedEvent: boolean; widevineLicenseUrl?: string; drmSystems: DRMSystemsConfiguration; drmSystemOptions: DRMSystemOptions; requestMediaKeySystemAccessFunc: MediaKeyFunc | null; }; -// Warning: (ae-missing-release-tag) "ErrorData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "ErrorData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface ErrorData { @@ -407,7 +412,7 @@ export interface ErrorData { url?: string; } -// Warning: (ae-missing-release-tag) "ErrorDetails" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "ErrorDetails" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public export enum ErrorDetails { @@ -464,6 +469,10 @@ export enum ErrorDetails { // (undocumented) KEY_SYSTEM_SESSION_UPDATE_FAILED = "keySystemSessionUpdateFailed", // (undocumented) + KEY_SYSTEM_STATUS_INTERNAL_ERROR = "keySystemStatusInternalError", + // (undocumented) + KEY_SYSTEM_STATUS_OUTPUT_RESTRICTED = "keySystemStatusOutputRestricted", + // (undocumented) LEVEL_EMPTY_ERROR = "levelEmptyError", // (undocumented) LEVEL_LOAD_ERROR = "levelLoadError", @@ -489,7 +498,7 @@ export enum ErrorDetails { UNKNOWN = "unknown" } -// Warning: (ae-missing-release-tag) "ErrorTypes" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "ErrorTypes" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export enum ErrorTypes { @@ -505,7 +514,7 @@ export enum ErrorTypes { OTHER_ERROR = "otherError" } -// Warning: (ae-missing-release-tag) "Events" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "Events" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public export enum Events { @@ -619,7 +628,7 @@ export enum Events { SUBTITLE_TRACKS_UPDATED = "hlsSubtitleTracksUpdated" } -// Warning: (ae-missing-release-tag) "FPSControllerConfig" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "FPSControllerConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type FPSControllerConfig = { @@ -628,7 +637,7 @@ export type FPSControllerConfig = { fpsDroppedMonitoringThreshold: number; }; -// Warning: (ae-missing-release-tag) "FPSDropData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "FPSDropData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface FPSDropData { @@ -640,7 +649,7 @@ export interface FPSDropData { totalDroppedFrames: number; } -// Warning: (ae-missing-release-tag) "FPSDropLevelCappingData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "FPSDropLevelCappingData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface FPSDropLevelCappingData { @@ -650,7 +659,7 @@ export interface FPSDropLevelCappingData { level: number; } -// Warning: (ae-missing-release-tag) "FragBufferedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "FragBufferedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface FragBufferedData { @@ -664,7 +673,7 @@ export interface FragBufferedData { stats: LoadStats; } -// Warning: (ae-missing-release-tag) "FragChangedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "FragChangedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface FragChangedData { @@ -672,7 +681,7 @@ export interface FragChangedData { frag: Fragment; } -// Warning: (ae-missing-release-tag) "FragDecryptedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "FragDecryptedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface FragDecryptedData { @@ -687,7 +696,7 @@ export interface FragDecryptedData { }; } -// Warning: (ae-missing-release-tag) "FragLoadedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "FragLoadedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface FragLoadedData { @@ -701,7 +710,7 @@ export interface FragLoadedData { payload: ArrayBuffer; } -// Warning: (ae-missing-release-tag) "FragLoadEmergencyAbortedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "FragLoadEmergencyAbortedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface FragLoadEmergencyAbortedData { @@ -713,7 +722,7 @@ export interface FragLoadEmergencyAbortedData { stats: LoaderStats; } -// Warning: (ae-missing-release-tag) "FragLoadingData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "FragLoadingData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface FragLoadingData { @@ -725,7 +734,7 @@ export interface FragLoadingData { targetBufferTime: number | null; } -// Warning: (ae-missing-release-tag) "Fragment" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "Fragment" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export class Fragment extends BaseSegment { @@ -740,7 +749,6 @@ export class Fragment extends BaseSegment { cc: number; // (undocumented) clearElementaryStreamInfo(): void; - createInitializationVector(segmentNumber: number): Uint8Array; // (undocumented) data?: Uint8Array; // (undocumented) @@ -761,12 +769,16 @@ export class Fragment extends BaseSegment { endPTS?: number; // (undocumented) initSegment: Fragment | null; + // Warning: (ae-forgotten-export) The symbol "KeyLoaderContext" needs to be exported by the entry point hls.d.ts + // // (undocumented) keyLoader: Loader | null; // (undocumented) level: number; // (undocumented) - levelkey?: LevelKey; + levelkeys?: { + [key: string]: LevelKey; + }; // (undocumented) loader: Loader | null; // (undocumented) @@ -777,10 +789,11 @@ export class Fragment extends BaseSegment { programDateTime: number | null; // (undocumented) rawProgramDateTime: string | null; - setDecryptDataFromLevelKey(levelkey: LevelKey, segmentNumber: number): LevelKey; // (undocumented) setElementaryStreamInfo(type: ElementaryStreamTypes, startPTS: number, endPTS: number, startDTS: number, endDTS: number, partial?: boolean): void; // (undocumented) + setKeyFormat(keyFormat: KeySystemFormats): void; + // (undocumented) sn: number | 'initSegment'; // (undocumented) start: number; @@ -800,7 +813,7 @@ export class Fragment extends BaseSegment { urlId: number; } -// Warning: (ae-missing-release-tag) "FragmentLoaderConfig" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "FragmentLoaderConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type FragmentLoaderConfig = { @@ -811,7 +824,7 @@ export type FragmentLoaderConfig = { fragLoadingMaxRetryTimeout: number; }; -// Warning: (ae-missing-release-tag) "FragmentLoaderConstructor" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "FragmentLoaderConstructor" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface FragmentLoaderConstructor { @@ -819,7 +832,7 @@ export interface FragmentLoaderConstructor { new (confg: HlsConfig): Loader; } -// Warning: (ae-missing-release-tag) "FragmentLoaderContext" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "FragmentLoaderContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface FragmentLoaderContext extends LoaderContext { @@ -831,7 +844,7 @@ export interface FragmentLoaderContext extends LoaderContext { resetIV?: boolean; } -// Warning: (ae-missing-release-tag) "FragParsedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "FragParsedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface FragParsedData { @@ -841,13 +854,13 @@ export interface FragParsedData { part: Part | null; } -// Warning: (ae-missing-release-tag) "FragParsingInitSegmentData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "FragParsingInitSegmentData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface FragParsingInitSegmentData { } -// Warning: (ae-missing-release-tag) "FragParsingMetadataData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "FragParsingMetadataData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface FragParsingMetadataData { @@ -861,7 +874,7 @@ export interface FragParsingMetadataData { samples: MetadataSample[]; } -// Warning: (ae-missing-release-tag) "FragParsingUserdataData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "FragParsingUserdataData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface FragParsingUserdataData { @@ -875,7 +888,7 @@ export interface FragParsingUserdataData { samples: UserdataSample[]; } -// Warning: (ae-missing-release-tag) "Hls" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "Hls" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public class Hls implements HlsEventEmitter { @@ -896,7 +909,7 @@ class Hls implements HlsEventEmitter { // (undocumented) readonly config: HlsConfig; // (undocumented) - createController(ControllerClass: any, fragmentTracker: any, components: any): any; + createController(ControllerClass: any, components: any): any; get currentLevel(): number; // Warning: (ae-setter-with-docs) The doc comment for the property "currentLevel" must appear on the getter, not the setter. set currentLevel(newLevel: number); @@ -989,7 +1002,7 @@ class Hls implements HlsEventEmitter { } export default Hls; -// Warning: (ae-missing-release-tag) "HlsChunkPerformanceTiming" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "HlsChunkPerformanceTiming" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface HlsChunkPerformanceTiming extends HlsPerformanceTiming { @@ -999,7 +1012,7 @@ export interface HlsChunkPerformanceTiming extends HlsPerformanceTiming { executeStart: number; } -// Warning: (ae-missing-release-tag) "HlsConfig" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "HlsConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type HlsConfig = { @@ -1029,7 +1042,7 @@ export type HlsConfig = { lowLatencyMode: boolean; } & ABRControllerConfig & BufferControllerConfig & CapLevelControllerConfig & EMEControllerConfig & FPSControllerConfig & FragmentLoaderConfig & LevelControllerConfig & MP4RemuxerConfig & PlaylistLoaderConfig & StreamControllerConfig & LatencyControllerConfig & MetadataControllerConfig & TimelineControllerConfig & TSDemuxerConfig; -// Warning: (ae-missing-release-tag) "HlsEventEmitter" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "HlsEventEmitter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface HlsEventEmitter { @@ -1049,7 +1062,7 @@ export interface HlsEventEmitter { removeAllListeners(event?: E): void; } -// Warning: (ae-missing-release-tag) "HlsListeners" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "HlsListeners" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface HlsListeners { @@ -1163,7 +1176,7 @@ export interface HlsListeners { [Events.SUBTITLE_TRACK_SWITCH]: (event: Events.SUBTITLE_TRACK_SWITCH, data: SubtitleTrackSwitchData) => void; } -// Warning: (ae-missing-release-tag) "HlsPerformanceTiming" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "HlsPerformanceTiming" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface HlsPerformanceTiming { @@ -1173,7 +1186,7 @@ export interface HlsPerformanceTiming { start: number; } -// Warning: (ae-missing-release-tag) "HlsProgressivePerformanceTiming" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "HlsProgressivePerformanceTiming" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface HlsProgressivePerformanceTiming extends HlsPerformanceTiming { @@ -1181,7 +1194,7 @@ export interface HlsProgressivePerformanceTiming extends HlsPerformanceTiming { first: number; } -// Warning: (ae-missing-release-tag) "HlsSkip" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "HlsSkip" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export enum HlsSkip { @@ -1193,7 +1206,7 @@ export enum HlsSkip { Yes = "YES" } -// Warning: (ae-missing-release-tag) "HlsUrlParameters" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "HlsUrlParameters" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export class HlsUrlParameters { @@ -1208,7 +1221,7 @@ export class HlsUrlParameters { skip?: HlsSkip; } -// Warning: (ae-missing-release-tag) "InitPTSFoundData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "InitPTSFoundData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface InitPTSFoundData { @@ -1222,32 +1235,46 @@ export interface InitPTSFoundData { timescale: number; } -// Warning: (ae-missing-release-tag) "KeyLoadedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "KeyLoadedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface KeyLoadedData { // (undocumented) frag: Fragment; + // Warning: (ae-forgotten-export) The symbol "KeyLoaderInfo" needs to be exported by the entry point hls.d.ts + // + // (undocumented) + keyInfo: KeyLoaderInfo; } -// Warning: (ae-missing-release-tag) "KeyLoaderContext" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "KeyLoadingData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export interface KeyLoaderContext extends FragmentLoaderContext { +export interface KeyLoadingData { + // (undocumented) + frag: Fragment; } -// Warning: (ae-missing-release-tag) "KeyLoadingData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "KeySystemFormats" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export interface KeyLoadingData { +export enum KeySystemFormats { // (undocumented) - frag: Fragment; + CLEARKEY = "org.w3.clearkey", + // (undocumented) + FAIRPLAY = "com.apple.streamingkeydelivery", + // (undocumented) + PLAYREADY = "com.microsoft.playready", + // (undocumented) + WIDEVINE = "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed" } -// Warning: (ae-missing-release-tag) "KeySystems" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "KeySystems" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export enum KeySystems { + // (undocumented) + CLEARKEY = "org.w3.clearkey", // (undocumented) FAIRPLAY = "com.apple.fps", // (undocumented) @@ -1256,7 +1283,7 @@ export enum KeySystems { WIDEVINE = "com.widevine.alpha" } -// Warning: (ae-missing-release-tag) "LatencyControllerConfig" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LatencyControllerConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type LatencyControllerConfig = { @@ -1267,7 +1294,7 @@ export type LatencyControllerConfig = { maxLiveSyncPlaybackRate: number; }; -// Warning: (ae-missing-release-tag) "Level" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "Level" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export class Level { @@ -1320,7 +1347,7 @@ export class Level { readonly width: number; } -// Warning: (ae-missing-release-tag) "LevelAttributes" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LevelAttributes" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface LevelAttributes extends AttrList { @@ -1362,14 +1389,14 @@ export interface LevelAttributes extends AttrList { URI?: string; } -// Warning: (ae-missing-release-tag) "LevelControllerConfig" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LevelControllerConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type LevelControllerConfig = { startLevel?: number; }; -// Warning: (ae-missing-release-tag) "LevelDetails" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LevelDetails" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export class LevelDetails { @@ -1480,31 +1507,39 @@ export class LevelDetails { version: number | null; } -// Warning: (ae-missing-release-tag) "LevelKey" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-forgotten-export) The symbol "DecryptData" needs to be exported by the entry point hls.d.ts +// Warning: (ae-missing-release-tag) "LevelKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export class LevelKey { +export class LevelKey implements DecryptData { + constructor(method: string, uri: string, format: string, formatversions?: number[], iv?: Uint8Array | null); + // (undocumented) + static clearKeyUriToKeyIdMap(): void; // (undocumented) - static fromURI(uri: string): LevelKey; + readonly encrypted: boolean; // (undocumented) - static fromURL(baseUrl: string, relativeUrl: string): LevelKey; + getDecryptData(sn: number | 'initSegment'): LevelKey | null; + // (undocumented) + readonly isCommonEncryption: boolean; // (undocumented) iv: Uint8Array | null; // (undocumented) key: Uint8Array | null; // (undocumented) - keyFormat: string | null; + readonly keyFormat: string; + // (undocumented) + readonly keyFormatVersions: number[]; // (undocumented) - keyFormatVersions: string | null; + keyId: Uint8Array | null; // (undocumented) - keyID: string | null; + readonly method: string; // (undocumented) - method: string | null; + pssh: Uint8Array | null; // (undocumented) - get uri(): string | null; + readonly uri: string; } -// Warning: (ae-missing-release-tag) "LevelLoadedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LevelLoadedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface LevelLoadedData { @@ -1522,7 +1557,7 @@ export interface LevelLoadedData { stats: LoaderStats; } -// Warning: (ae-missing-release-tag) "LevelLoadingData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LevelLoadingData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface LevelLoadingData { @@ -1536,7 +1571,7 @@ export interface LevelLoadingData { url: string; } -// Warning: (ae-missing-release-tag) "LevelParsed" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LevelParsed" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface LevelParsed { @@ -1568,7 +1603,7 @@ export interface LevelParsed { width?: number; } -// Warning: (ae-missing-release-tag) "LevelPTSUpdatedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LevelPTSUpdatedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface LevelPTSUpdatedData { @@ -1588,7 +1623,7 @@ export interface LevelPTSUpdatedData { type: string; } -// Warning: (ae-missing-release-tag) "LevelsUpdatedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LevelsUpdatedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface LevelsUpdatedData { @@ -1596,7 +1631,7 @@ export interface LevelsUpdatedData { levels: Array; } -// Warning: (ae-missing-release-tag) "LevelSwitchedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LevelSwitchedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface LevelSwitchedData { @@ -1604,7 +1639,7 @@ export interface LevelSwitchedData { level: number; } -// Warning: (ae-missing-release-tag) "LevelSwitchingData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LevelSwitchingData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface LevelSwitchingData extends Omit { @@ -1612,7 +1647,7 @@ export interface LevelSwitchingData extends Omit { level: number; } -// Warning: (ae-missing-release-tag) "LevelUpdatedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LevelUpdatedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface LevelUpdatedData { @@ -1622,13 +1657,13 @@ export interface LevelUpdatedData { level: number; } -// Warning: (ae-missing-release-tag) "LiveBackBufferData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LiveBackBufferData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public export interface LiveBackBufferData extends BackBufferData { } -// Warning: (ae-missing-release-tag) "Loader" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "Loader" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface Loader { @@ -1645,7 +1680,7 @@ export interface Loader { stats: LoaderStats; } -// Warning: (ae-missing-release-tag) "LoaderCallbacks" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LoaderCallbacks" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface LoaderCallbacks { @@ -1661,7 +1696,7 @@ export interface LoaderCallbacks { onTimeout: LoaderOnTimeout; } -// Warning: (ae-missing-release-tag) "LoaderConfiguration" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LoaderConfiguration" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface LoaderConfiguration { @@ -1677,7 +1712,7 @@ export interface LoaderConfiguration { timeout: number; } -// Warning: (ae-missing-release-tag) "LoaderContext" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LoaderContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface LoaderContext { @@ -1695,12 +1730,12 @@ export interface LoaderContext { url: string; } -// Warning: (ae-missing-release-tag) "LoaderOnAbort" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LoaderOnAbort" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type LoaderOnAbort = (stats: LoaderStats, context: T, networkDetails: any) => void; -// Warning: (ae-missing-release-tag) "LoaderOnError" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LoaderOnError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type LoaderOnError = (error: { @@ -1708,22 +1743,22 @@ export type LoaderOnError = (error: { text: string; }, context: T, networkDetails: any) => void; -// Warning: (ae-missing-release-tag) "LoaderOnProgress" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LoaderOnProgress" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type LoaderOnProgress = (stats: LoaderStats, context: T, data: string | ArrayBuffer, networkDetails: any) => void; -// Warning: (ae-missing-release-tag) "LoaderOnSuccess" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LoaderOnSuccess" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type LoaderOnSuccess = (response: LoaderResponse, stats: LoaderStats, context: T, networkDetails: any) => void; -// Warning: (ae-missing-release-tag) "LoaderOnTimeout" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LoaderOnTimeout" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type LoaderOnTimeout = (stats: LoaderStats, context: T, networkDetails: any) => void; -// Warning: (ae-missing-release-tag) "LoaderResponse" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LoaderResponse" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface LoaderResponse { @@ -1733,7 +1768,7 @@ export interface LoaderResponse { url: string; } -// Warning: (ae-missing-release-tag) "LoaderStats" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LoaderStats" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface LoaderStats { @@ -1757,7 +1792,7 @@ export interface LoaderStats { total: number; } -// Warning: (ae-missing-release-tag) "LoadStats" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LoadStats" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export class LoadStats implements LoaderStats { @@ -1781,12 +1816,12 @@ export class LoadStats implements LoaderStats { total: number; } -// Warning: (ae-missing-release-tag) "MainPlaylistType" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "MainPlaylistType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type MainPlaylistType = AudioPlaylistType | 'VIDEO'; -// Warning: (ae-missing-release-tag) "ManifestLoadedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "ManifestLoadedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface ManifestLoadedData { @@ -1808,7 +1843,7 @@ export interface ManifestLoadedData { url: string; } -// Warning: (ae-missing-release-tag) "ManifestLoadingData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "ManifestLoadingData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface ManifestLoadingData { @@ -1816,7 +1851,7 @@ export interface ManifestLoadingData { url: string; } -// Warning: (ae-missing-release-tag) "ManifestParsedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "ManifestParsedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface ManifestParsedData { @@ -1838,7 +1873,7 @@ export interface ManifestParsedData { video: boolean; } -// Warning: (ae-missing-release-tag) "MediaAttachedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "MediaAttachedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface MediaAttachedData { @@ -1846,7 +1881,7 @@ export interface MediaAttachedData { media: HTMLMediaElement; } -// Warning: (ae-missing-release-tag) "MediaAttachingData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "MediaAttachingData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface MediaAttachingData { @@ -1854,12 +1889,12 @@ export interface MediaAttachingData { media: HTMLMediaElement; } -// Warning: (ae-missing-release-tag) "MediaKeyFunc" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "MediaKeyFunc" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type MediaKeyFunc = (keySystem: KeySystems, supportedConfigurations: MediaKeySystemConfiguration[]) => Promise; -// Warning: (ae-missing-release-tag) "MediaPlaylist" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "MediaPlaylist" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface MediaPlaylist extends LevelParsed { @@ -1883,12 +1918,12 @@ export interface MediaPlaylist extends LevelParsed { type: MediaPlaylistType | 'main'; } -// Warning: (ae-missing-release-tag) "MediaPlaylistType" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "MediaPlaylistType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type MediaPlaylistType = MainPlaylistType | SubtitlePlaylistType; -// Warning: (ae-missing-release-tag) "MetadataControllerConfig" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "MetadataControllerConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type MetadataControllerConfig = { @@ -1897,7 +1932,7 @@ export type MetadataControllerConfig = { enableID3MetadataCues: boolean; }; -// Warning: (ae-missing-release-tag) "MetadataSample" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "MetadataSample" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface MetadataSample { @@ -1915,7 +1950,7 @@ export interface MetadataSample { type: MetadataSchema; } -// Warning: (ae-missing-release-tag) "MetadataSchema" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "MetadataSchema" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export enum MetadataSchema { @@ -1927,7 +1962,7 @@ export enum MetadataSchema { emsg = "https://aomedia.org/emsg/ID3" } -// Warning: (ae-missing-release-tag) "MP4RemuxerConfig" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "MP4RemuxerConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type MP4RemuxerConfig = { @@ -1935,7 +1970,7 @@ export type MP4RemuxerConfig = { maxAudioFramesDrift: number; }; -// Warning: (ae-missing-release-tag) "NonNativeTextTrack" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "NonNativeTextTrack" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface NonNativeTextTrack { @@ -1953,7 +1988,7 @@ export interface NonNativeTextTrack { subtitleTrack?: MediaPlaylist; } -// Warning: (ae-missing-release-tag) "NonNativeTextTracksData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "NonNativeTextTracksData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface NonNativeTextTracksData { @@ -1961,7 +1996,7 @@ export interface NonNativeTextTracksData { tracks: Array; } -// Warning: (ae-missing-release-tag) "Part" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "Part" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export class Part extends BaseSegment { @@ -1990,7 +2025,7 @@ export class Part extends BaseSegment { stats: LoadStats; } -// Warning: (ae-missing-release-tag) "PlaylistContextType" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "PlaylistContextType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export enum PlaylistContextType { @@ -2004,7 +2039,7 @@ export enum PlaylistContextType { SUBTITLE_TRACK = "subtitleTrack" } -// Warning: (ae-missing-release-tag) "PlaylistLevelType" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "PlaylistLevelType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export enum PlaylistLevelType { @@ -2016,7 +2051,7 @@ export enum PlaylistLevelType { SUBTITLE = "subtitle" } -// Warning: (ae-missing-release-tag) "PlaylistLoaderConfig" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "PlaylistLoaderConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type PlaylistLoaderConfig = { @@ -2031,7 +2066,7 @@ export type PlaylistLoaderConfig = { levelLoadingMaxRetryTimeout: number; }; -// Warning: (ae-missing-release-tag) "PlaylistLoaderConstructor" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "PlaylistLoaderConstructor" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface PlaylistLoaderConstructor { @@ -2039,7 +2074,7 @@ export interface PlaylistLoaderConstructor { new (confg: HlsConfig): Loader; } -// Warning: (ae-missing-release-tag) "PlaylistLoaderContext" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "PlaylistLoaderContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface PlaylistLoaderContext extends LoaderContext { @@ -2061,12 +2096,12 @@ export interface PlaylistLoaderContext extends LoaderContext { type: PlaylistContextType; } -// Warning: (ae-missing-release-tag) "SourceBufferName" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "SourceBufferName" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type SourceBufferName = 'video' | 'audio' | 'audiovideo'; -// Warning: (ae-missing-release-tag) "StreamControllerConfig" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "StreamControllerConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type StreamControllerConfig = { @@ -2086,7 +2121,7 @@ export type StreamControllerConfig = { testBandwidth: boolean; }; -// Warning: (ae-missing-release-tag) "SubtitleFragProcessedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "SubtitleFragProcessedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface SubtitleFragProcessedData { @@ -2098,18 +2133,18 @@ export interface SubtitleFragProcessedData { success: boolean; } -// Warning: (ae-missing-release-tag) "SubtitlePlaylistType" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "SubtitlePlaylistType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type SubtitlePlaylistType = 'SUBTITLES' | 'CLOSED-CAPTIONS'; -// Warning: (ae-missing-release-tag) "SubtitleTrackLoadedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "SubtitleTrackLoadedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface SubtitleTrackLoadedData extends TrackLoadedData { } -// Warning: (ae-missing-release-tag) "SubtitleTracksUpdatedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "SubtitleTracksUpdatedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface SubtitleTracksUpdatedData { @@ -2117,7 +2152,7 @@ export interface SubtitleTracksUpdatedData { subtitleTracks: MediaPlaylist[]; } -// Warning: (ae-missing-release-tag) "SubtitleTrackSwitchData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "SubtitleTrackSwitchData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface SubtitleTrackSwitchData { @@ -2133,7 +2168,7 @@ export interface SubtitleTrackSwitchData { url?: string; } -// Warning: (ae-missing-release-tag) "TimelineControllerConfig" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "TimelineControllerConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type TimelineControllerConfig = { @@ -2152,7 +2187,7 @@ export type TimelineControllerConfig = { renderTextTracksNatively: boolean; }; -// Warning: (ae-missing-release-tag) "Track" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "Track" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface Track { @@ -2172,7 +2207,7 @@ export interface Track { metadata?: any; } -// Warning: (ae-missing-release-tag) "TrackLoadedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "TrackLoadedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface TrackLoadedData { @@ -2190,7 +2225,7 @@ export interface TrackLoadedData { stats: LoaderStats; } -// Warning: (ae-missing-release-tag) "TrackLoadingData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "TrackLoadingData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface TrackLoadingData { @@ -2204,7 +2239,7 @@ export interface TrackLoadingData { url: string; } -// Warning: (ae-missing-release-tag) "TrackSet" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "TrackSet" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface TrackSet { @@ -2216,14 +2251,14 @@ export interface TrackSet { video?: Track; } -// Warning: (ae-missing-release-tag) "TSDemuxerConfig" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "TSDemuxerConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type TSDemuxerConfig = { forceKeyFrameOnDiscontinuity: boolean; }; -// Warning: (ae-missing-release-tag) "UserdataSample" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "UserdataSample" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface UserdataSample { @@ -2245,19 +2280,20 @@ export interface UserdataSample { // Warnings were encountered during analysis: // -// 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 +// src/config.ts:79:3 - (ae-forgotten-export) The symbol "MediaKeySessionContext" needs to be exported by the entry point hls.d.ts +// src/config.ts:95:3 - (ae-forgotten-export) The symbol "DRMSystemsConfiguration" needs to be exported by the entry point hls.d.ts +// src/config.ts:198:3 - (ae-forgotten-export) The symbol "ILogger" needs to be exported by the entry point hls.d.ts +// src/config.ts:208:3 - (ae-forgotten-export) The symbol "AudioStreamController" needs to be exported by the entry point hls.d.ts +// src/config.ts:209:3 - (ae-forgotten-export) The symbol "AudioTrackController" needs to be exported by the entry point hls.d.ts +// src/config.ts:211:3 - (ae-forgotten-export) The symbol "SubtitleStreamController" needs to be exported by the entry point hls.d.ts +// src/config.ts:212:3 - (ae-forgotten-export) The symbol "SubtitleTrackController" needs to be exported by the entry point hls.d.ts +// src/config.ts:213:3 - (ae-forgotten-export) The symbol "TimelineController" needs to be exported by the entry point hls.d.ts +// src/config.ts:215:3 - (ae-forgotten-export) The symbol "EMEController" needs to be exported by the entry point hls.d.ts +// src/config.ts:218:3 - (ae-forgotten-export) The symbol "CMCDController" needs to be exported by the entry point hls.d.ts +// src/config.ts:220:3 - (ae-forgotten-export) The symbol "AbrController" needs to be exported by the entry point hls.d.ts +// src/config.ts:221:3 - (ae-forgotten-export) The symbol "BufferController" needs to be exported by the entry point hls.d.ts +// src/config.ts:222:3 - (ae-forgotten-export) The symbol "CapLevelController" needs to be exported by the entry point hls.d.ts +// src/config.ts:223:3 - (ae-forgotten-export) The symbol "FPSController" needs to be exported by the entry point hls.d.ts // (No @packageDocumentation comment for this package) From fe33d26a6998a17dc9b9046a4bb2db2e96c3a75c Mon Sep 17 00:00:00 2001 From: Rob Walch Date: Wed, 19 Oct 2022 21:15:29 -0700 Subject: [PATCH 05/19] Attach CDM on start when even when initial fragments do not have a key associated with them --- api-extractor/report/hls.js.api.md | 2 + src/controller/base-stream-controller.ts | 2 + src/controller/eme-controller.ts | 2 +- src/loader/fragment.ts | 2 +- src/loader/key-loader.ts | 18 +++++ src/loader/level-details.ts | 2 + src/loader/m3u8-parser.ts | 18 ++++- tests/unit/loader/playlist-loader.ts | 88 ++++++++++++++++++++++++ 8 files changed, 131 insertions(+), 3 deletions(-) diff --git a/api-extractor/report/hls.js.api.md b/api-extractor/report/hls.js.api.md index 4371f0bf667..c06d9051513 100644 --- a/api-extractor/report/hls.js.api.md +++ b/api-extractor/report/hls.js.api.md @@ -1438,6 +1438,8 @@ export class LevelDetails { // (undocumented) get edge(): number; // (undocumented) + encryptedFragments: Fragment[]; + // (undocumented) endCC: number; // (undocumented) endSN: number; diff --git a/src/controller/base-stream-controller.ts b/src/controller/base-stream-controller.ts index b4a95b57b84..a2b16945cbc 100644 --- a/src/controller/base-stream-controller.ts +++ b/src/controller/base-stream-controller.ts @@ -581,6 +581,8 @@ export default class BaseStreamController }); this.hls.trigger(Events.KEY_LOADING, { frag }); this.throwIfFragContextChanged('KEY_LOADING'); + } else if (!frag.encrypted && details.encryptedFragments.length) { + this.keyLoader.loadClear(frag, details.encryptedFragments); } targetBufferTime = Math.max(frag.start, targetBufferTime || 0); diff --git a/src/controller/eme-controller.ts b/src/controller/eme-controller.ts index 7683971d7ac..18d6ea61dbc 100644 --- a/src/controller/eme-controller.ts +++ b/src/controller/eme-controller.ts @@ -724,7 +724,7 @@ class EMEController implements ComponentAPI { `key status change "${status}" for keyStatuses keyId: ${Hex.hexDump( keyId )} session keyId: ${Hex.hexDump( - mediaKeySessionContext.decryptdata.keyId + mediaKeySessionContext.decryptdata.keyId || [] )} uri: ${mediaKeySessionContext.decryptdata.uri}` ); mediaKeySessionContext.keyStatus = status; diff --git a/src/loader/fragment.ts b/src/loader/fragment.ts index 8e7d65681f5..e5265345c7f 100644 --- a/src/loader/fragment.ts +++ b/src/loader/fragment.ts @@ -213,7 +213,7 @@ export class Fragment extends BaseSegment { setKeyFormat(keyFormat: KeySystemFormats) { if (this.levelkeys) { const key = this.levelkeys[keyFormat]; - if (key) { + if (key && !this._decryptdata) { this._decryptdata = key.getDecryptData(this.sn); } } diff --git a/src/loader/key-loader.ts b/src/loader/key-loader.ts index f4ceeed888a..f6ed0eedb31 100644 --- a/src/loader/key-loader.ts +++ b/src/loader/key-loader.ts @@ -80,6 +80,24 @@ export default class KeyLoader implements ComponentAPI { }); } + loadClear(loadingFrag: Fragment, encryptedFragments: Fragment[]): void | Promise { + if (this.emeController && this.config.emeEnabled) { + // access key-system with nearest key on start (loaidng frag is unencrypted) + const { sn, cc } = loadingFrag; + for (let i = 0; i < encryptedFragments.length; i++) { + const frag = encryptedFragments[i]; + if (cc <= frag.cc && (sn === 'initSegment' || sn < frag.sn)) { + this.emeController + .selectKeySystemFormat(frag) + .then((keySystemFormat) => { + frag.setKeyFormat(keySystemFormat); + }); + break; + } + } + } + } + load(frag: Fragment): Promise { if (!frag.decryptdata && frag.encrypted && this.emeController) { // Multiple keys, but none selected, resolve in eme-controller diff --git a/src/loader/level-details.ts b/src/loader/level-details.ts index a39ecfe7578..269d7eff89e 100644 --- a/src/loader/level-details.ts +++ b/src/loader/level-details.ts @@ -48,9 +48,11 @@ export class LevelDetails { public driftEndTime: number = 0; public driftStart: number = 0; public driftEnd: number = 0; + public encryptedFragments: Fragment[]; constructor(baseUrl) { this.fragments = []; + this.encryptedFragments = []; this.dateRanges = {}; this.url = baseUrl; } diff --git a/src/loader/m3u8-parser.ts b/src/loader/m3u8-parser.ts index 76b0466d356..7c27146150a 100644 --- a/src/loader/m3u8-parser.ts +++ b/src/loader/m3u8-parser.ts @@ -245,6 +245,18 @@ export default class M3U8Parser { frag.start = totalduration; if (levelkeys) { frag.levelkeys = levelkeys; + const { encryptedFragments } = level; + if ( + frag.levelkeys && + Object.keys(frag.levelkeys).some( + (format) => frag.levelkeys![format].isCommonEncryption + ) && + (!encryptedFragments.length || + encryptedFragments[encryptedFragments.length - 1].levelkeys !== + levelkeys) + ) { + encryptedFragments.push(frag); + } } frag.sn = currentSN; frag.level = id; @@ -406,7 +418,11 @@ export default class M3U8Parser { .filter(Number.isFinite); if (isKeyTagSupported(decryptkeyformat, decryptmethod)) { - if (decryptmethod === 'NONE' || !levelkeys) { + if (decryptmethod === 'NONE') { + levelkeys = undefined; + break; + } + if (!levelkeys) { levelkeys = {}; } if (levelkeys[decryptkeyformat]) { diff --git a/tests/unit/loader/playlist-loader.ts b/tests/unit/loader/playlist-loader.ts index 6d7367e435f..db72ba6ecab 100644 --- a/tests/unit/loader/playlist-loader.ts +++ b/tests/unit/loader/playlist-loader.ts @@ -1722,6 +1722,94 @@ media_1638278.m4s`; pdt += frag.duration * 1000; } }); + + it('parse clear->enc->clear->enc playlist', function () { + const level = `#EXTM3U +#EXT-X-VERSION:6 +#EXT-X-TARGETDURATION:6 + +#EXT-X-MAP:URI="init.mp4" +#EXTINF:5.5, +1.mp4 +#EXTINF:5.0, +2.mp4 + +#EXT-X-DISCONTINUITY +#EXT-X-KEY:METHOD=SAMPLE-AES,URI="skd://a",KEYFORMAT="com.apple.streamingkeydelivery",KEYFORMATVERSIONS="1" +#EXT-X-KEY:METHOD=SAMPLE-AES,URI="data:text/plain;base64,YQo=",KEYFORMAT="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed",KEYFORMATVERSIONS="1" +#EXT-X-MAP:URI="init.mp4" +#EXTINF:5.5, +3.mp4 +#EXTINF:5.0, +4.mp4 + +#EXT-X-DISCONTINUITY +#EXT-X-KEY:METHOD=NONE +#EXT-X-MAP:URI="init.mp4" +#EXTINF:5.5, +5.mp4 +#EXTINF:5.0, +6.mp4 + +#EXT-X-DISCONTINUITY +#EXT-X-KEY:METHOD=SAMPLE-AES,URI="skd://b",KEYFORMAT="com.apple.streamingkeydelivery",KEYFORMATVERSIONS="1" +#EXT-X-KEY:METHOD=SAMPLE-AES,URI="data:text/plain;base64,Yg==",KEYFORMAT="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed",KEYFORMATVERSIONS="1" +#EXT-X-MAP:URI="init.mp4" +#EXTINF:5.0, +7.mp4 +#EXTINF:4.0, +8.mp4 +#EXT-X-ENDLIST`; + const result = M3U8Parser.parseLevelPlaylist( + level, + 'http://foo.com/adaptive/test.m3u8', + 0, + PlaylistLevelType.MAIN, + 0 + ); + expect(result.fragments.length).to.equal(8); + expect(result.fragments[0].levelkeys, 'first segment has no keys').to.equal( + undefined + ); + expect( + result.fragments[1].levelkeys, + 'second segment has no keys' + ).to.equal(undefined); + expect(result.fragments[2].levelkeys, 'third segment has two keys') + .to.be.an('object') + .with.keys([ + 'com.apple.streamingkeydelivery', + 'urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed', + ]); + expect(result.fragments[3].levelkeys, 'forth segment has two keys') + .to.be.an('object') + .with.keys([ + 'com.apple.streamingkeydelivery', + 'urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed', + ]); + expect(result.fragments[4].levelkeys, 'fifth segment has no keys').to.equal( + undefined + ); + expect(result.fragments[5].levelkeys, 'sixth segment has no keys').to.equal( + undefined + ); + expect(result.fragments[6].levelkeys, 'seventh segment has two keys') + .to.be.an('object') + .with.keys([ + 'com.apple.streamingkeydelivery', + 'urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed', + ]); + expect(result.fragments[7].levelkeys, 'eighth segment has two keys') + .to.be.an('object') + .with.keys([ + 'com.apple.streamingkeydelivery', + 'urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed', + ]); + expect(result) + .to.have.property('encryptedFragments') + .which.is.an('array') + .which.has.members([result.fragments[2], result.fragments[6]]); + }); }); function expectWithJSONMessage(value: any, msg?: string) { From 94bf06d229bfe2dce1134e8aac4bd827ce1c66e2 Mon Sep 17 00:00:00 2001 From: Rob Walch Date: Thu, 20 Oct 2022 17:58:26 -0700 Subject: [PATCH 06/19] Handle expired key status correctly --- src/controller/eme-controller.ts | 13 +++++++++++-- src/loader/key-loader.ts | 7 +++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/controller/eme-controller.ts b/src/controller/eme-controller.ts index 18d6ea61dbc..543da9aaa8d 100644 --- a/src/controller/eme-controller.ts +++ b/src/controller/eme-controller.ts @@ -318,12 +318,16 @@ class EMEController implements ComponentAPI { private renewKeySession(mediaKeySessionContext: MediaKeySessionContext) { const decryptdata = mediaKeySessionContext.decryptdata; + const keySessionContext = this.createMediaKeySessionContext( + mediaKeySessionContext + ); this.keyUriToKeySessionPromise[decryptdata.uri] = this.generateRequestWithPreferredKeySession( - mediaKeySessionContext, + keySessionContext, 'cenc', decryptdata.pssh ); + this.removeSession(mediaKeySessionContext); } private handleParsedKeyResponse( @@ -1043,6 +1047,7 @@ class EMEController implements ComponentAPI { LevelKey.clearKeyUriToKeyIdMap(); // Close all sessions and remove media keys from the video element. + const keySessionCount = mediaKeysList.length; EMEController.CDMCleanupPromise = Promise.all( mediaKeysList .map((mediaKeySessionContext) => @@ -1057,7 +1062,7 @@ class EMEController implements ComponentAPI { ) ) .then(() => { - if (mediaKeysList.length) { + if (keySessionCount) { this.log('finished closing key sessions and clearing media keys'); mediaKeysList.length = 0; } @@ -1086,6 +1091,10 @@ class EMEController implements ComponentAPI { mediaKeySessionContext.decryptdata = mediaKeySessionContext.licenseXhr = undefined!; + const index = this.mediaKeySessions.indexOf(mediaKeySessionContext); + if (index > -1) { + this.mediaKeySessions.splice(index, 1); + } return mediaKeysSession .remove() .catch((error) => { diff --git a/src/loader/key-loader.ts b/src/loader/key-loader.ts index f6ed0eedb31..d2b33c2a599 100644 --- a/src/loader/key-loader.ts +++ b/src/loader/key-loader.ts @@ -80,8 +80,11 @@ export default class KeyLoader implements ComponentAPI { }); } - loadClear(loadingFrag: Fragment, encryptedFragments: Fragment[]): void | Promise { - if (this.emeController && this.config.emeEnabled) { + loadClear( + loadingFrag: Fragment, + encryptedFragments: Fragment[] + ): void | Promise { + if (this.emeController && this.config.emeEnabled) { // access key-system with nearest key on start (loaidng frag is unencrypted) const { sn, cc } = loadingFrag; for (let i = 0; i < encryptedFragments.length; i++) { From d00fd45a7cc52f3f697009f10076296c992e6c20 Mon Sep 17 00:00:00 2001 From: Rob Walch Date: Thu, 27 Oct 2022 10:42:16 -0700 Subject: [PATCH 07/19] Map key-sessions by key ID and log key ID more often than URI --- src/controller/eme-controller.ts | 122 +++++++++++++----------- src/utils/hex.ts | 2 +- tests/unit/controller/eme-controller.ts | 72 ++++++++------ 3 files changed, 111 insertions(+), 85 deletions(-) diff --git a/src/controller/eme-controller.ts b/src/controller/eme-controller.ts index 543da9aaa8d..278d15edc3e 100644 --- a/src/controller/eme-controller.ts +++ b/src/controller/eme-controller.ts @@ -20,8 +20,7 @@ import { import { strToUtf8array } from '../utils/keysystem-util'; import { utf8ArrayToStr } from '../demux/id3'; import { base64Decode, base64Encode } from '../utils/numeric-encoding-utils'; -import { LevelKey } from '../loader/level-key'; - +import { DecryptData, LevelKey } from '../loader/level-key'; import type Hls from '../hls'; import type { ComponentAPI } from '../types/component-api'; import type { @@ -70,8 +69,8 @@ class EMEController implements ComponentAPI { } = {}; private _requestLicenseFailureCount: number = 0; private mediaKeySessions: MediaKeySessionContext[] = []; - private keyUriToKeySessionPromise: { - [keyuri: string]: Promise; + private keyIdToKeySessionPromise: { + [keyId: string]: Promise; } = {}; private setMediaKeysQueue: Promise[] = EMEController.CDMCleanupPromise ? [EMEController.CDMCleanupPromise] @@ -83,10 +82,6 @@ class EMEController implements ComponentAPI { private warn: (msg: any) => void = logger.warn.bind(logger, LOGGER_PREFIX); private error: (msg: any) => void = logger.error.bind(logger, LOGGER_PREFIX); - /** - * @constructs - * @param {Hls} hls Our Hls.js instance - */ constructor(hls: Hls) { this.hls = hls; this.config = hls.config; @@ -100,7 +95,7 @@ class EMEController implements ComponentAPI { this.hls = this.onMediaEncrypted = this.onWaitingForKey = - this.keyUriToKeySessionPromise = + this.keyIdToKeySessionPromise = null as any; } @@ -298,7 +293,9 @@ class EMEController implements ComponentAPI { console.assert(!!mediaKeys, 'mediaKeys is defined'); this.log( - `Creating key-system session "${keySystem}" uri: ${decryptdata.uri}` + `Creating key-system session "${keySystem}" keyId: ${Hex.hexDump( + decryptdata.keyId! || [] + )}` ); const mediaKeysSession = mediaKeys.createSession(); @@ -321,7 +318,8 @@ class EMEController implements ComponentAPI { const keySessionContext = this.createMediaKeySessionContext( mediaKeySessionContext ); - this.keyUriToKeySessionPromise[decryptdata.uri] = + const keyId = this.getKeyIdString(decryptdata); + this.keyIdToKeySessionPromise[keyId] = this.generateRequestWithPreferredKeySession( keySessionContext, 'cenc', @@ -330,6 +328,16 @@ class EMEController implements ComponentAPI { this.removeSession(mediaKeySessionContext); } + private getKeyIdString(decryptdata: DecryptData | undefined): string | never { + if (!decryptdata) { + throw new Error('Could not read keyId of undefined decryptdata'); + } + if (decryptdata.keyId === null) { + throw new Error('keyId is null'); + } + return Hex.hexDump(decryptdata.keyId); + } + private handleParsedKeyResponse( mediaKeySessionContext: MediaKeySessionContext, licenseResponse: ArrayBuffer @@ -357,8 +365,9 @@ class EMEController implements ComponentAPI { ): Promise { const keySession = mediaKeySessionContext.mediaKeysSession; this.log( - `Updating key-session "${keySession.sessionId}" for ${ - mediaKeySessionContext.decryptdata?.uri + `Updating key-session "${keySession.sessionId}" for keyID ${Hex.hexDump( + mediaKeySessionContext.decryptdata?.keyId! || [] + )} } (data length: ${data ? data.byteLength : data})` ); return keySession.update(data); @@ -396,45 +405,44 @@ class EMEController implements ComponentAPI { public loadKey(data: KeyLoadedData): Promise { const decryptdata = data.keyInfo.decryptdata; - this.log( - `Starting session for key ${decryptdata.keyFormat} ${decryptdata.uri}` - ); + const keyId = this.getKeyIdString(decryptdata); + const keyDetails = `(keyId: ${keyId} format: "${decryptdata.keyFormat}" method: ${decryptdata.method} uri: ${decryptdata.uri})`; + + this.log(`Starting session for key ${keyDetails}`); if (this.media && !this.config.useEmeEncryptedEvent) { this.media.removeEventListener('encrypted', this.onMediaEncrypted); } - let keySessionContextPromise = - this.keyUriToKeySessionPromise[decryptdata.uri]; + let keySessionContextPromise = this.keyIdToKeySessionPromise[keyId]; if (!keySessionContextPromise) { - keySessionContextPromise = this.keyUriToKeySessionPromise[ - decryptdata.uri - ] = this.getKeySystemForKeyPromise(decryptdata).then( - ({ keySystem, mediaKeys }) => { - this.throwIfDestroyed(); - this.log( - `Handle encrypted media sn: ${data.frag.sn} ${data.frag.type}: ${data.frag.level} using key (method: ${decryptdata.method} format: "${decryptdata.keyFormat}" uri: ${decryptdata.uri})` - ); - - return this.attemptSetMediaKeys(keySystem, mediaKeys).then(() => { + keySessionContextPromise = this.keyIdToKeySessionPromise[keyId] = + this.getKeySystemForKeyPromise(decryptdata).then( + ({ keySystem, mediaKeys }) => { this.throwIfDestroyed(); - const keySessionContext = this.createMediaKeySessionContext({ - keySystem, - mediaKeys, - decryptdata, - }); - if (this.config.useEmeEncryptedEvent) { - // Use 'encrypted' event initData and type rather than 'cenc' pssh from level-key - return keySessionContext; - } - return this.generateRequestWithPreferredKeySession( - keySessionContext, - 'cenc', - decryptdata.pssh + this.log( + `Handle encrypted media sn: ${data.frag.sn} ${data.frag.type}: ${data.frag.level} using key ${keyDetails}` ); - }); - } - ); + + return this.attemptSetMediaKeys(keySystem, mediaKeys).then(() => { + this.throwIfDestroyed(); + const keySessionContext = this.createMediaKeySessionContext({ + keySystem, + mediaKeys, + decryptdata, + }); + if (this.config.useEmeEncryptedEvent) { + // Use 'encrypted' event initData and type rather than 'cenc' pssh from level-key + return keySessionContext; + } + return this.generateRequestWithPreferredKeySession( + keySessionContext, + 'cenc', + decryptdata.pssh + ); + }); + } + ); keySessionContextPromise.catch((error) => this.handleError(error)); } @@ -468,8 +476,8 @@ class EMEController implements ComponentAPI { private getKeySystemForKeyPromise( decryptdata: LevelKey ): Promise<{ keySystem: KeySystems; mediaKeys: MediaKeys }> { - const mediaKeySessionContext = - this.keyUriToKeySessionPromise[decryptdata.uri]; + const keyId = this.getKeyIdString(decryptdata); + const mediaKeySessionContext = this.keyIdToKeySessionPromise[keyId]; if (!mediaKeySessionContext) { const keySystem = keySystemFormatToKeySystemDomain( decryptdata.keyFormat as KeySystemFormats @@ -498,9 +506,9 @@ class EMEController implements ComponentAPI { return; } - let keySessionContextPromise = this.keyUriToKeySessionPromise.encrypted; + let keySessionContextPromise = this.keyIdToKeySessionPromise.encrypted; if (!keySessionContextPromise) { - keySessionContextPromise = this.keyUriToKeySessionPromise.encrypted = + keySessionContextPromise = this.keyIdToKeySessionPromise.encrypted = this.getKeySystemSelectionPromise().then(({ keySystem, mediaKeys }) => { this.throwIfDestroyed(); const sessionParameters = { @@ -512,6 +520,7 @@ class EMEController implements ComponentAPI { keySystem, mediaKeys, }; + sessionParameters.decryptdata.keyId = new Uint8Array(16); return this.attemptSetMediaKeys(keySystem, mediaKeys).then(() => { this.throwIfDestroyed(); const keySessionContext = @@ -574,10 +583,9 @@ class EMEController implements ComponentAPI { ); } + const keyId = this.getKeyIdString(context.decryptdata); this.log( - `Generating key-session request for ${ - context.decryptdata?.uri - } (init data type: ${initDataType} length: ${ + `Generating key-session request for ${keyId} (init data type: ${initDataType} length: ${ initData ? initData.byteLength : null })` ); @@ -649,7 +657,7 @@ class EMEController implements ComponentAPI { .generateRequest(initDataType, initData) .then(() => { this.log( - `Key-session generation succeeded for "${context.mediaKeysSession?.sessionId}" ${context.decryptdata?.uri}` + `Request generated for key-session "${context.mediaKeysSession?.sessionId}" keyId: ${keyId}` ); return context; }) @@ -711,7 +719,7 @@ class EMEController implements ComponentAPI { const keyStatus = mediaKeySessionContext.keyStatus; if (keyStatus === 'expired') { this.warn( - `${mediaKeySessionContext.keySystem} expired for key ${mediaKeySessionContext.decryptdata.uri}` + `${mediaKeySessionContext.keySystem} expired for key ${keyId}` ); this.renewKeySession(mediaKeySessionContext); } @@ -726,9 +734,11 @@ class EMEController implements ComponentAPI { (status: MediaKeyStatus, keyId: BufferSource) => { this.log( `key status change "${status}" for keyStatuses keyId: ${Hex.hexDump( - keyId + 'buffer' in keyId + ? new Uint8Array(keyId.buffer, keyId.byteOffset, keyId.byteLength) + : new Uint8Array(keyId) )} session keyId: ${Hex.hexDump( - mediaKeySessionContext.decryptdata.keyId || [] + new Uint8Array(mediaKeySessionContext.decryptdata.keyId || []) )} uri: ${mediaKeySessionContext.decryptdata.uri}` ); mediaKeySessionContext.keyStatus = status; @@ -1043,7 +1053,7 @@ class EMEController implements ComponentAPI { this._requestLicenseFailureCount = 0; this.setMediaKeysQueue = []; this.mediaKeySessions = []; - this.keyUriToKeySessionPromise = {}; + this.keyIdToKeySessionPromise = {}; LevelKey.clearKeyUriToKeyIdMap(); // Close all sessions and remove media keys from the video element. diff --git a/src/utils/hex.ts b/src/utils/hex.ts index cd8710a89e8..703988cf601 100644 --- a/src/utils/hex.ts +++ b/src/utils/hex.ts @@ -3,7 +3,7 @@ */ const Hex = { - hexDump: function (array) { + hexDump: function (array: Uint8Array) { let str = ''; for (let i = 0; i < array.length; i++) { let h = array[i].toString(16); diff --git a/tests/unit/controller/eme-controller.ts b/tests/unit/controller/eme-controller.ts index 18b0441d607..5fa88500e40 100644 --- a/tests/unit/controller/eme-controller.ts +++ b/tests/unit/controller/eme-controller.ts @@ -16,11 +16,11 @@ const expect = chai.expect; type EMEControllerTestable = Omit< EMEController, - 'hls' | 'keyUriToKeySessionPromise' | 'mediaKeySessions' + 'hls' | 'keyIdToKeySessionPromise' | 'mediaKeySessions' > & { hls: HlsMock; - keyUriToKeySessionPromise: { - [keyuri: string]: Promise; + keyIdToKeySessionPromise: { + [keyId: string]: Promise; }; mediaKeySessions: MediaKeySessionContext[]; onMediaAttached: ( @@ -286,13 +286,11 @@ describe('EMEController', function () { media.emit('encrypted', badData); - expect(emeController.keyUriToKeySessionPromise.encrypted).to.be.a( - 'Promise' - ); - if (!emeController.keyUriToKeySessionPromise.encrypted) { + expect(emeController.keyIdToKeySessionPromise.encrypted).to.be.a('Promise'); + if (!emeController.keyIdToKeySessionPromise.encrypted) { return; } - return emeController.keyUriToKeySessionPromise.encrypted + return emeController.keyIdToKeySessionPromise.encrypted .catch(() => {}) .finally(() => { expect(emeController.hls.trigger).callCount(1); @@ -373,20 +371,24 @@ describe('EMEController', function () { }, } as any); - expect(emeController.keyUriToKeySessionPromise['data://key-uri']).to.be.a( - 'Promise' - ); - if (!emeController.keyUriToKeySessionPromise['data://key-uri']) { + expect( + emeController.keyIdToKeySessionPromise['00000000000000000000000000000000'] + ).to.be.a('Promise'); + if ( + !emeController.keyIdToKeySessionPromise[ + '00000000000000000000000000000000' + ] + ) { return; } - return emeController.keyUriToKeySessionPromise['data://key-uri'].finally( - () => { - expect(mediaKeysSetServerCertificateSpy).to.have.been.calledOnce; - expect(mediaKeysSetServerCertificateSpy).to.have.been.calledWith( - xhrInstance.response - ); - } - ); + return emeController.keyIdToKeySessionPromise[ + '00000000000000000000000000000000' + ].finally(() => { + expect(mediaKeysSetServerCertificateSpy).to.have.been.calledOnce; + expect(mediaKeysSetServerCertificateSpy).to.have.been.calledWith( + xhrInstance.response + ); + }); }); it('should fetch the server certificate and trigger update failed error', function () { @@ -444,17 +446,24 @@ describe('EMEController', function () { encrypted: true, method: 'SAMPLE-AES', uri: 'data://key-uri', + keyId: new Uint8Array(16), }, }, } as any); expect( - emeController.keyUriToKeySessionPromise['data://key-uri'] - ).to.not.equal(null); - if (!emeController.keyUriToKeySessionPromise['data://key-uri']) { + emeController.keyIdToKeySessionPromise['00000000000000000000000000000000'] + ).to.be.a('Promise'); + if ( + !emeController.keyIdToKeySessionPromise[ + '00000000000000000000000000000000' + ] + ) { return; } - return emeController.keyUriToKeySessionPromise['data://key-uri'] + return emeController.keyIdToKeySessionPromise[ + '00000000000000000000000000000000' + ] .catch(() => {}) .finally(() => { expect(mediaKeysSetServerCertificateSpy).to.have.been.calledOnce; @@ -517,17 +526,24 @@ describe('EMEController', function () { encrypted: true, method: 'SAMPLE-AES', uri: 'data://key-uri', + keyId: new Uint8Array(16), }, }, } as any); expect( - emeController.keyUriToKeySessionPromise['data://key-uri'] - ).to.not.equal(null); - if (!emeController.keyUriToKeySessionPromise['data://key-uri']) { + emeController.keyIdToKeySessionPromise['00000000000000000000000000000000'] + ).to.be.a('Promise'); + if ( + !emeController.keyIdToKeySessionPromise[ + '00000000000000000000000000000000' + ] + ) { return; } - return emeController.keyUriToKeySessionPromise['data://key-uri'] + return emeController.keyIdToKeySessionPromise[ + '00000000000000000000000000000000' + ] .catch(() => {}) .finally(() => { expect(emeController.hls.trigger).to.have.been.calledOnce; From 2afa6d34ee9d8c037bbee101db88bc55568d947b Mon Sep 17 00:00:00 2001 From: Rob Walch Date: Thu, 27 Oct 2022 14:41:10 -0700 Subject: [PATCH 08/19] Support "clear-lead" key-session creation without new config --- api-extractor/report/hls.js.api.md | 277 ++++++++++++------------ src/config.ts | 2 - src/controller/eme-controller.ts | 90 +++++--- src/utils/mediakeys-helper.ts | 23 +- src/utils/mp4-tools.ts | 39 ++++ tests/unit/controller/eme-controller.ts | 26 ++- 6 files changed, 272 insertions(+), 185 deletions(-) diff --git a/api-extractor/report/hls.js.api.md b/api-extractor/report/hls.js.api.md index c06d9051513..cc58b99d8a0 100644 --- a/api-extractor/report/hls.js.api.md +++ b/api-extractor/report/hls.js.api.md @@ -4,7 +4,7 @@ ```ts -// Warning: (ae-missing-release-tag) "ABRControllerConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "ABRControllerConfig" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type ABRControllerConfig = { @@ -20,7 +20,7 @@ export type ABRControllerConfig = { maxLoadingDelay: number; }; -// Warning: (ae-missing-release-tag) "AttrList" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "AttrList" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export class AttrList { @@ -50,18 +50,18 @@ export class AttrList { static parseAttrList(input: string): Record; } -// Warning: (ae-missing-release-tag) "AudioPlaylistType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "AudioPlaylistType" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type AudioPlaylistType = 'AUDIO'; -// Warning: (ae-missing-release-tag) "AudioTrackLoadedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "AudioTrackLoadedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface AudioTrackLoadedData extends TrackLoadedData { } -// Warning: (ae-missing-release-tag) "AudioTracksUpdatedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "AudioTracksUpdatedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface AudioTracksUpdatedData { @@ -69,7 +69,7 @@ export interface AudioTracksUpdatedData { audioTracks: MediaPlaylist[]; } -// Warning: (ae-missing-release-tag) "AudioTrackSwitchedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "AudioTrackSwitchedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface AudioTrackSwitchedData { @@ -77,7 +77,7 @@ export interface AudioTrackSwitchedData { id: number; } -// Warning: (ae-missing-release-tag) "AudioTrackSwitchingData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "AudioTrackSwitchingData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface AudioTrackSwitchingData { @@ -93,7 +93,7 @@ export interface AudioTrackSwitchingData { url: string; } -// Warning: (ae-missing-release-tag) "BackBufferData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "BackBufferData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface BackBufferData { @@ -101,7 +101,7 @@ export interface BackBufferData { bufferEnd: number; } -// Warning: (ae-missing-release-tag) "BaseSegment" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "BaseSegment" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export class BaseSegment { @@ -125,7 +125,7 @@ export class BaseSegment { set url(value: string); } -// Warning: (ae-missing-release-tag) "BufferAppendedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "BufferAppendedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface BufferAppendedData { @@ -143,7 +143,7 @@ export interface BufferAppendedData { type: SourceBufferName; } -// Warning: (ae-missing-release-tag) "BufferAppendingData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "BufferAppendingData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface BufferAppendingData { @@ -161,7 +161,7 @@ export interface BufferAppendingData { type: SourceBufferName; } -// Warning: (ae-missing-release-tag) "BufferCodecsData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "BufferCodecsData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface BufferCodecsData { @@ -171,7 +171,7 @@ export interface BufferCodecsData { video?: Track; } -// Warning: (ae-missing-release-tag) "BufferControllerConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "BufferControllerConfig" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type BufferControllerConfig = { @@ -181,7 +181,7 @@ export type BufferControllerConfig = { liveBackBufferLength: number | null; }; -// Warning: (ae-missing-release-tag) "BufferCreatedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "BufferCreatedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface BufferCreatedData { @@ -189,7 +189,7 @@ export interface BufferCreatedData { tracks: TrackSet; } -// Warning: (ae-missing-release-tag) "BufferEOSData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "BufferEOSData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface BufferEOSData { @@ -197,7 +197,7 @@ export interface BufferEOSData { type?: SourceBufferName; } -// Warning: (ae-missing-release-tag) "BufferFlushedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "BufferFlushedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface BufferFlushedData { @@ -205,7 +205,7 @@ export interface BufferFlushedData { type: SourceBufferName; } -// Warning: (ae-missing-release-tag) "BufferFlushingData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "BufferFlushingData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface BufferFlushingData { @@ -219,14 +219,14 @@ export interface BufferFlushingData { type: SourceBufferName | null; } -// Warning: (ae-missing-release-tag) "CapLevelControllerConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "CapLevelControllerConfig" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type CapLevelControllerConfig = { capLevelToPlayerSize: boolean; }; -// Warning: (ae-missing-release-tag) "ChunkMetadata" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "ChunkMetadata" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export class ChunkMetadata { @@ -251,7 +251,7 @@ export class ChunkMetadata { readonly transmuxing: HlsChunkPerformanceTiming; } -// Warning: (ae-missing-release-tag) "CMCDControllerConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "CMCDControllerConfig" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type CMCDControllerConfig = { @@ -260,7 +260,7 @@ export type CMCDControllerConfig = { useHeaders?: boolean; }; -// Warning: (ae-missing-release-tag) "CuesInterface" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "CuesInterface" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface CuesInterface { @@ -270,7 +270,7 @@ export interface CuesInterface { newCue(track: TextTrack | null, startTime: number, endTime: number, captionScreen: CaptionScreen): VTTCue[]; } -// Warning: (ae-missing-release-tag) "CuesParsedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "CuesParsedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface CuesParsedData { @@ -282,7 +282,7 @@ export interface CuesParsedData { type: 'captions' | 'subtitles'; } -// Warning: (ae-missing-release-tag) "DateRange" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "DateRange" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export class DateRange { @@ -307,7 +307,7 @@ export class DateRange { get startDate(): Date; } -// Warning: (ae-missing-release-tag) "DRMSystemOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "DRMSystemOptions" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type DRMSystemOptions = { @@ -319,7 +319,7 @@ export type DRMSystemOptions = { sessionType?: string; }; -// Warning: (ae-missing-release-tag) "ElementaryStreamInfo" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "ElementaryStreamInfo" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface ElementaryStreamInfo { @@ -335,12 +335,12 @@ export interface ElementaryStreamInfo { startPTS: number; } -// Warning: (ae-missing-release-tag) "ElementaryStreams" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "ElementaryStreams" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type ElementaryStreams = Record; -// Warning: (ae-missing-release-tag) "ElementaryStreamTypes" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "ElementaryStreamTypes" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export enum ElementaryStreamTypes { @@ -352,21 +352,20 @@ export enum ElementaryStreamTypes { VIDEO = "video" } -// Warning: (ae-missing-release-tag) "EMEControllerConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "EMEControllerConfig" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type EMEControllerConfig = { licenseXhrSetup?: (this: Hls, xhr: XMLHttpRequest, url: string, keyContext: MediaKeySessionContext, licenseChallenge: Uint8Array) => void | Promise; licenseResponseCallback?: (this: Hls, xhr: XMLHttpRequest, url: string, keyContext: MediaKeySessionContext) => ArrayBuffer; emeEnabled: boolean; - useEmeEncryptedEvent: boolean; widevineLicenseUrl?: string; drmSystems: DRMSystemsConfiguration; drmSystemOptions: DRMSystemOptions; requestMediaKeySystemAccessFunc: MediaKeyFunc | null; }; -// Warning: (ae-missing-release-tag) "ErrorData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "ErrorData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface ErrorData { @@ -412,7 +411,7 @@ export interface ErrorData { url?: string; } -// Warning: (ae-missing-release-tag) "ErrorDetails" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "ErrorDetails" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public export enum ErrorDetails { @@ -498,7 +497,7 @@ export enum ErrorDetails { UNKNOWN = "unknown" } -// Warning: (ae-missing-release-tag) "ErrorTypes" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "ErrorTypes" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export enum ErrorTypes { @@ -514,7 +513,7 @@ export enum ErrorTypes { OTHER_ERROR = "otherError" } -// Warning: (ae-missing-release-tag) "Events" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "Events" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public export enum Events { @@ -628,7 +627,7 @@ export enum Events { SUBTITLE_TRACKS_UPDATED = "hlsSubtitleTracksUpdated" } -// Warning: (ae-missing-release-tag) "FPSControllerConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "FPSControllerConfig" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type FPSControllerConfig = { @@ -637,7 +636,7 @@ export type FPSControllerConfig = { fpsDroppedMonitoringThreshold: number; }; -// Warning: (ae-missing-release-tag) "FPSDropData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "FPSDropData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface FPSDropData { @@ -649,7 +648,7 @@ export interface FPSDropData { totalDroppedFrames: number; } -// Warning: (ae-missing-release-tag) "FPSDropLevelCappingData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "FPSDropLevelCappingData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface FPSDropLevelCappingData { @@ -659,7 +658,7 @@ export interface FPSDropLevelCappingData { level: number; } -// Warning: (ae-missing-release-tag) "FragBufferedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "FragBufferedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface FragBufferedData { @@ -673,7 +672,7 @@ export interface FragBufferedData { stats: LoadStats; } -// Warning: (ae-missing-release-tag) "FragChangedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "FragChangedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface FragChangedData { @@ -681,7 +680,7 @@ export interface FragChangedData { frag: Fragment; } -// Warning: (ae-missing-release-tag) "FragDecryptedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "FragDecryptedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface FragDecryptedData { @@ -696,7 +695,7 @@ export interface FragDecryptedData { }; } -// Warning: (ae-missing-release-tag) "FragLoadedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "FragLoadedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface FragLoadedData { @@ -710,7 +709,7 @@ export interface FragLoadedData { payload: ArrayBuffer; } -// Warning: (ae-missing-release-tag) "FragLoadEmergencyAbortedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "FragLoadEmergencyAbortedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface FragLoadEmergencyAbortedData { @@ -722,7 +721,7 @@ export interface FragLoadEmergencyAbortedData { stats: LoaderStats; } -// Warning: (ae-missing-release-tag) "FragLoadingData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "FragLoadingData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface FragLoadingData { @@ -734,7 +733,7 @@ export interface FragLoadingData { targetBufferTime: number | null; } -// Warning: (ae-missing-release-tag) "Fragment" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "Fragment" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export class Fragment extends BaseSegment { @@ -813,7 +812,7 @@ export class Fragment extends BaseSegment { urlId: number; } -// Warning: (ae-missing-release-tag) "FragmentLoaderConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "FragmentLoaderConfig" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type FragmentLoaderConfig = { @@ -824,7 +823,7 @@ export type FragmentLoaderConfig = { fragLoadingMaxRetryTimeout: number; }; -// Warning: (ae-missing-release-tag) "FragmentLoaderConstructor" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "FragmentLoaderConstructor" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface FragmentLoaderConstructor { @@ -832,7 +831,7 @@ export interface FragmentLoaderConstructor { new (confg: HlsConfig): Loader; } -// Warning: (ae-missing-release-tag) "FragmentLoaderContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "FragmentLoaderContext" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface FragmentLoaderContext extends LoaderContext { @@ -844,7 +843,7 @@ export interface FragmentLoaderContext extends LoaderContext { resetIV?: boolean; } -// Warning: (ae-missing-release-tag) "FragParsedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "FragParsedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface FragParsedData { @@ -854,13 +853,13 @@ export interface FragParsedData { part: Part | null; } -// Warning: (ae-missing-release-tag) "FragParsingInitSegmentData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "FragParsingInitSegmentData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface FragParsingInitSegmentData { } -// Warning: (ae-missing-release-tag) "FragParsingMetadataData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "FragParsingMetadataData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface FragParsingMetadataData { @@ -874,7 +873,7 @@ export interface FragParsingMetadataData { samples: MetadataSample[]; } -// Warning: (ae-missing-release-tag) "FragParsingUserdataData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "FragParsingUserdataData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface FragParsingUserdataData { @@ -888,7 +887,7 @@ export interface FragParsingUserdataData { samples: UserdataSample[]; } -// Warning: (ae-missing-release-tag) "Hls" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "Hls" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public class Hls implements HlsEventEmitter { @@ -1002,7 +1001,7 @@ class Hls implements HlsEventEmitter { } export default Hls; -// Warning: (ae-missing-release-tag) "HlsChunkPerformanceTiming" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "HlsChunkPerformanceTiming" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface HlsChunkPerformanceTiming extends HlsPerformanceTiming { @@ -1012,7 +1011,7 @@ export interface HlsChunkPerformanceTiming extends HlsPerformanceTiming { executeStart: number; } -// Warning: (ae-missing-release-tag) "HlsConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "HlsConfig" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type HlsConfig = { @@ -1042,7 +1041,7 @@ export type HlsConfig = { lowLatencyMode: boolean; } & ABRControllerConfig & BufferControllerConfig & CapLevelControllerConfig & EMEControllerConfig & FPSControllerConfig & FragmentLoaderConfig & LevelControllerConfig & MP4RemuxerConfig & PlaylistLoaderConfig & StreamControllerConfig & LatencyControllerConfig & MetadataControllerConfig & TimelineControllerConfig & TSDemuxerConfig; -// Warning: (ae-missing-release-tag) "HlsEventEmitter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "HlsEventEmitter" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface HlsEventEmitter { @@ -1062,7 +1061,7 @@ export interface HlsEventEmitter { removeAllListeners(event?: E): void; } -// Warning: (ae-missing-release-tag) "HlsListeners" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "HlsListeners" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface HlsListeners { @@ -1176,7 +1175,7 @@ export interface HlsListeners { [Events.SUBTITLE_TRACK_SWITCH]: (event: Events.SUBTITLE_TRACK_SWITCH, data: SubtitleTrackSwitchData) => void; } -// Warning: (ae-missing-release-tag) "HlsPerformanceTiming" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "HlsPerformanceTiming" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface HlsPerformanceTiming { @@ -1186,7 +1185,7 @@ export interface HlsPerformanceTiming { start: number; } -// Warning: (ae-missing-release-tag) "HlsProgressivePerformanceTiming" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "HlsProgressivePerformanceTiming" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface HlsProgressivePerformanceTiming extends HlsPerformanceTiming { @@ -1194,7 +1193,7 @@ export interface HlsProgressivePerformanceTiming extends HlsPerformanceTiming { first: number; } -// Warning: (ae-missing-release-tag) "HlsSkip" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "HlsSkip" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export enum HlsSkip { @@ -1206,7 +1205,7 @@ export enum HlsSkip { Yes = "YES" } -// Warning: (ae-missing-release-tag) "HlsUrlParameters" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "HlsUrlParameters" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export class HlsUrlParameters { @@ -1221,7 +1220,7 @@ export class HlsUrlParameters { skip?: HlsSkip; } -// Warning: (ae-missing-release-tag) "InitPTSFoundData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "InitPTSFoundData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface InitPTSFoundData { @@ -1235,7 +1234,7 @@ export interface InitPTSFoundData { timescale: number; } -// Warning: (ae-missing-release-tag) "KeyLoadedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "KeyLoadedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface KeyLoadedData { @@ -1247,7 +1246,7 @@ export interface KeyLoadedData { keyInfo: KeyLoaderInfo; } -// Warning: (ae-missing-release-tag) "KeyLoadingData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "KeyLoadingData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface KeyLoadingData { @@ -1255,7 +1254,7 @@ export interface KeyLoadingData { frag: Fragment; } -// Warning: (ae-missing-release-tag) "KeySystemFormats" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "KeySystemFormats" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export enum KeySystemFormats { @@ -1269,7 +1268,7 @@ export enum KeySystemFormats { WIDEVINE = "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed" } -// Warning: (ae-missing-release-tag) "KeySystems" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "KeySystems" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export enum KeySystems { @@ -1283,7 +1282,7 @@ export enum KeySystems { WIDEVINE = "com.widevine.alpha" } -// Warning: (ae-missing-release-tag) "LatencyControllerConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LatencyControllerConfig" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type LatencyControllerConfig = { @@ -1294,7 +1293,7 @@ export type LatencyControllerConfig = { maxLiveSyncPlaybackRate: number; }; -// Warning: (ae-missing-release-tag) "Level" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "Level" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export class Level { @@ -1347,7 +1346,7 @@ export class Level { readonly width: number; } -// Warning: (ae-missing-release-tag) "LevelAttributes" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LevelAttributes" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface LevelAttributes extends AttrList { @@ -1389,14 +1388,14 @@ export interface LevelAttributes extends AttrList { URI?: string; } -// Warning: (ae-missing-release-tag) "LevelControllerConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LevelControllerConfig" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type LevelControllerConfig = { startLevel?: number; }; -// Warning: (ae-missing-release-tag) "LevelDetails" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LevelDetails" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export class LevelDetails { @@ -1510,7 +1509,7 @@ export class LevelDetails { } // Warning: (ae-forgotten-export) The symbol "DecryptData" needs to be exported by the entry point hls.d.ts -// Warning: (ae-missing-release-tag) "LevelKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LevelKey" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export class LevelKey implements DecryptData { @@ -1541,7 +1540,7 @@ export class LevelKey implements DecryptData { readonly uri: string; } -// Warning: (ae-missing-release-tag) "LevelLoadedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LevelLoadedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface LevelLoadedData { @@ -1559,7 +1558,7 @@ export interface LevelLoadedData { stats: LoaderStats; } -// Warning: (ae-missing-release-tag) "LevelLoadingData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LevelLoadingData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface LevelLoadingData { @@ -1573,7 +1572,7 @@ export interface LevelLoadingData { url: string; } -// Warning: (ae-missing-release-tag) "LevelParsed" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LevelParsed" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface LevelParsed { @@ -1605,7 +1604,7 @@ export interface LevelParsed { width?: number; } -// Warning: (ae-missing-release-tag) "LevelPTSUpdatedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LevelPTSUpdatedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface LevelPTSUpdatedData { @@ -1625,7 +1624,7 @@ export interface LevelPTSUpdatedData { type: string; } -// Warning: (ae-missing-release-tag) "LevelsUpdatedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LevelsUpdatedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface LevelsUpdatedData { @@ -1633,7 +1632,7 @@ export interface LevelsUpdatedData { levels: Array; } -// Warning: (ae-missing-release-tag) "LevelSwitchedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LevelSwitchedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface LevelSwitchedData { @@ -1641,7 +1640,7 @@ export interface LevelSwitchedData { level: number; } -// Warning: (ae-missing-release-tag) "LevelSwitchingData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LevelSwitchingData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface LevelSwitchingData extends Omit { @@ -1649,7 +1648,7 @@ export interface LevelSwitchingData extends Omit { level: number; } -// Warning: (ae-missing-release-tag) "LevelUpdatedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LevelUpdatedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface LevelUpdatedData { @@ -1659,13 +1658,13 @@ export interface LevelUpdatedData { level: number; } -// Warning: (ae-missing-release-tag) "LiveBackBufferData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LiveBackBufferData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public export interface LiveBackBufferData extends BackBufferData { } -// Warning: (ae-missing-release-tag) "Loader" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "Loader" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface Loader { @@ -1682,7 +1681,7 @@ export interface Loader { stats: LoaderStats; } -// Warning: (ae-missing-release-tag) "LoaderCallbacks" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LoaderCallbacks" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface LoaderCallbacks { @@ -1698,7 +1697,7 @@ export interface LoaderCallbacks { onTimeout: LoaderOnTimeout; } -// Warning: (ae-missing-release-tag) "LoaderConfiguration" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LoaderConfiguration" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface LoaderConfiguration { @@ -1714,7 +1713,7 @@ export interface LoaderConfiguration { timeout: number; } -// Warning: (ae-missing-release-tag) "LoaderContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LoaderContext" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface LoaderContext { @@ -1732,12 +1731,12 @@ export interface LoaderContext { url: string; } -// Warning: (ae-missing-release-tag) "LoaderOnAbort" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LoaderOnAbort" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type LoaderOnAbort = (stats: LoaderStats, context: T, networkDetails: any) => void; -// Warning: (ae-missing-release-tag) "LoaderOnError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LoaderOnError" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type LoaderOnError = (error: { @@ -1745,22 +1744,22 @@ export type LoaderOnError = (error: { text: string; }, context: T, networkDetails: any) => void; -// Warning: (ae-missing-release-tag) "LoaderOnProgress" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LoaderOnProgress" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type LoaderOnProgress = (stats: LoaderStats, context: T, data: string | ArrayBuffer, networkDetails: any) => void; -// Warning: (ae-missing-release-tag) "LoaderOnSuccess" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LoaderOnSuccess" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type LoaderOnSuccess = (response: LoaderResponse, stats: LoaderStats, context: T, networkDetails: any) => void; -// Warning: (ae-missing-release-tag) "LoaderOnTimeout" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LoaderOnTimeout" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type LoaderOnTimeout = (stats: LoaderStats, context: T, networkDetails: any) => void; -// Warning: (ae-missing-release-tag) "LoaderResponse" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LoaderResponse" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface LoaderResponse { @@ -1770,7 +1769,7 @@ export interface LoaderResponse { url: string; } -// Warning: (ae-missing-release-tag) "LoaderStats" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LoaderStats" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface LoaderStats { @@ -1794,7 +1793,7 @@ export interface LoaderStats { total: number; } -// Warning: (ae-missing-release-tag) "LoadStats" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "LoadStats" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export class LoadStats implements LoaderStats { @@ -1818,12 +1817,12 @@ export class LoadStats implements LoaderStats { total: number; } -// Warning: (ae-missing-release-tag) "MainPlaylistType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "MainPlaylistType" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type MainPlaylistType = AudioPlaylistType | 'VIDEO'; -// Warning: (ae-missing-release-tag) "ManifestLoadedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "ManifestLoadedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface ManifestLoadedData { @@ -1845,7 +1844,7 @@ export interface ManifestLoadedData { url: string; } -// Warning: (ae-missing-release-tag) "ManifestLoadingData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "ManifestLoadingData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface ManifestLoadingData { @@ -1853,7 +1852,7 @@ export interface ManifestLoadingData { url: string; } -// Warning: (ae-missing-release-tag) "ManifestParsedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "ManifestParsedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface ManifestParsedData { @@ -1875,7 +1874,7 @@ export interface ManifestParsedData { video: boolean; } -// Warning: (ae-missing-release-tag) "MediaAttachedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "MediaAttachedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface MediaAttachedData { @@ -1883,7 +1882,7 @@ export interface MediaAttachedData { media: HTMLMediaElement; } -// Warning: (ae-missing-release-tag) "MediaAttachingData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "MediaAttachingData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface MediaAttachingData { @@ -1891,12 +1890,12 @@ export interface MediaAttachingData { media: HTMLMediaElement; } -// Warning: (ae-missing-release-tag) "MediaKeyFunc" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "MediaKeyFunc" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type MediaKeyFunc = (keySystem: KeySystems, supportedConfigurations: MediaKeySystemConfiguration[]) => Promise; -// Warning: (ae-missing-release-tag) "MediaPlaylist" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "MediaPlaylist" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface MediaPlaylist extends LevelParsed { @@ -1920,12 +1919,12 @@ export interface MediaPlaylist extends LevelParsed { type: MediaPlaylistType | 'main'; } -// Warning: (ae-missing-release-tag) "MediaPlaylistType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "MediaPlaylistType" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type MediaPlaylistType = MainPlaylistType | SubtitlePlaylistType; -// Warning: (ae-missing-release-tag) "MetadataControllerConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "MetadataControllerConfig" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type MetadataControllerConfig = { @@ -1934,7 +1933,7 @@ export type MetadataControllerConfig = { enableID3MetadataCues: boolean; }; -// Warning: (ae-missing-release-tag) "MetadataSample" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "MetadataSample" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface MetadataSample { @@ -1952,7 +1951,7 @@ export interface MetadataSample { type: MetadataSchema; } -// Warning: (ae-missing-release-tag) "MetadataSchema" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "MetadataSchema" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export enum MetadataSchema { @@ -1964,7 +1963,7 @@ export enum MetadataSchema { emsg = "https://aomedia.org/emsg/ID3" } -// Warning: (ae-missing-release-tag) "MP4RemuxerConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "MP4RemuxerConfig" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type MP4RemuxerConfig = { @@ -1972,7 +1971,7 @@ export type MP4RemuxerConfig = { maxAudioFramesDrift: number; }; -// Warning: (ae-missing-release-tag) "NonNativeTextTrack" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "NonNativeTextTrack" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface NonNativeTextTrack { @@ -1990,7 +1989,7 @@ export interface NonNativeTextTrack { subtitleTrack?: MediaPlaylist; } -// Warning: (ae-missing-release-tag) "NonNativeTextTracksData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "NonNativeTextTracksData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface NonNativeTextTracksData { @@ -1998,7 +1997,7 @@ export interface NonNativeTextTracksData { tracks: Array; } -// Warning: (ae-missing-release-tag) "Part" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "Part" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export class Part extends BaseSegment { @@ -2027,7 +2026,7 @@ export class Part extends BaseSegment { stats: LoadStats; } -// Warning: (ae-missing-release-tag) "PlaylistContextType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "PlaylistContextType" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export enum PlaylistContextType { @@ -2041,7 +2040,7 @@ export enum PlaylistContextType { SUBTITLE_TRACK = "subtitleTrack" } -// Warning: (ae-missing-release-tag) "PlaylistLevelType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "PlaylistLevelType" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export enum PlaylistLevelType { @@ -2053,7 +2052,7 @@ export enum PlaylistLevelType { SUBTITLE = "subtitle" } -// Warning: (ae-missing-release-tag) "PlaylistLoaderConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "PlaylistLoaderConfig" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type PlaylistLoaderConfig = { @@ -2068,7 +2067,7 @@ export type PlaylistLoaderConfig = { levelLoadingMaxRetryTimeout: number; }; -// Warning: (ae-missing-release-tag) "PlaylistLoaderConstructor" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "PlaylistLoaderConstructor" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface PlaylistLoaderConstructor { @@ -2076,7 +2075,7 @@ export interface PlaylistLoaderConstructor { new (confg: HlsConfig): Loader; } -// Warning: (ae-missing-release-tag) "PlaylistLoaderContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "PlaylistLoaderContext" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface PlaylistLoaderContext extends LoaderContext { @@ -2098,12 +2097,12 @@ export interface PlaylistLoaderContext extends LoaderContext { type: PlaylistContextType; } -// Warning: (ae-missing-release-tag) "SourceBufferName" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "SourceBufferName" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type SourceBufferName = 'video' | 'audio' | 'audiovideo'; -// Warning: (ae-missing-release-tag) "StreamControllerConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "StreamControllerConfig" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type StreamControllerConfig = { @@ -2123,7 +2122,7 @@ export type StreamControllerConfig = { testBandwidth: boolean; }; -// Warning: (ae-missing-release-tag) "SubtitleFragProcessedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "SubtitleFragProcessedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface SubtitleFragProcessedData { @@ -2135,18 +2134,18 @@ export interface SubtitleFragProcessedData { success: boolean; } -// Warning: (ae-missing-release-tag) "SubtitlePlaylistType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "SubtitlePlaylistType" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type SubtitlePlaylistType = 'SUBTITLES' | 'CLOSED-CAPTIONS'; -// Warning: (ae-missing-release-tag) "SubtitleTrackLoadedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "SubtitleTrackLoadedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface SubtitleTrackLoadedData extends TrackLoadedData { } -// Warning: (ae-missing-release-tag) "SubtitleTracksUpdatedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "SubtitleTracksUpdatedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface SubtitleTracksUpdatedData { @@ -2154,7 +2153,7 @@ export interface SubtitleTracksUpdatedData { subtitleTracks: MediaPlaylist[]; } -// Warning: (ae-missing-release-tag) "SubtitleTrackSwitchData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "SubtitleTrackSwitchData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface SubtitleTrackSwitchData { @@ -2170,7 +2169,7 @@ export interface SubtitleTrackSwitchData { url?: string; } -// Warning: (ae-missing-release-tag) "TimelineControllerConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "TimelineControllerConfig" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type TimelineControllerConfig = { @@ -2189,7 +2188,7 @@ export type TimelineControllerConfig = { renderTextTracksNatively: boolean; }; -// Warning: (ae-missing-release-tag) "Track" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "Track" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface Track { @@ -2209,7 +2208,7 @@ export interface Track { metadata?: any; } -// Warning: (ae-missing-release-tag) "TrackLoadedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "TrackLoadedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface TrackLoadedData { @@ -2227,7 +2226,7 @@ export interface TrackLoadedData { stats: LoaderStats; } -// Warning: (ae-missing-release-tag) "TrackLoadingData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "TrackLoadingData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface TrackLoadingData { @@ -2241,7 +2240,7 @@ export interface TrackLoadingData { url: string; } -// Warning: (ae-missing-release-tag) "TrackSet" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "TrackSet" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface TrackSet { @@ -2253,14 +2252,14 @@ export interface TrackSet { video?: Track; } -// Warning: (ae-missing-release-tag) "TSDemuxerConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "TSDemuxerConfig" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type TSDemuxerConfig = { forceKeyFrameOnDiscontinuity: boolean; }; -// Warning: (ae-missing-release-tag) "UserdataSample" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "UserdataSample" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface UserdataSample { @@ -2283,19 +2282,19 @@ export interface UserdataSample { // Warnings were encountered during analysis: // // src/config.ts:79:3 - (ae-forgotten-export) The symbol "MediaKeySessionContext" needs to be exported by the entry point hls.d.ts -// src/config.ts:95:3 - (ae-forgotten-export) The symbol "DRMSystemsConfiguration" needs to be exported by the entry point hls.d.ts -// src/config.ts:198:3 - (ae-forgotten-export) The symbol "ILogger" needs to be exported by the entry point hls.d.ts -// src/config.ts:208:3 - (ae-forgotten-export) The symbol "AudioStreamController" needs to be exported by the entry point hls.d.ts -// src/config.ts:209:3 - (ae-forgotten-export) The symbol "AudioTrackController" needs to be exported by the entry point hls.d.ts -// src/config.ts:211:3 - (ae-forgotten-export) The symbol "SubtitleStreamController" needs to be exported by the entry point hls.d.ts -// src/config.ts:212:3 - (ae-forgotten-export) The symbol "SubtitleTrackController" needs to be exported by the entry point hls.d.ts -// src/config.ts:213:3 - (ae-forgotten-export) The symbol "TimelineController" needs to be exported by the entry point hls.d.ts -// src/config.ts:215:3 - (ae-forgotten-export) The symbol "EMEController" needs to be exported by the entry point hls.d.ts -// src/config.ts:218:3 - (ae-forgotten-export) The symbol "CMCDController" needs to be exported by the entry point hls.d.ts -// src/config.ts:220:3 - (ae-forgotten-export) The symbol "AbrController" needs to be exported by the entry point hls.d.ts -// src/config.ts:221:3 - (ae-forgotten-export) The symbol "BufferController" needs to be exported by the entry point hls.d.ts -// src/config.ts:222:3 - (ae-forgotten-export) The symbol "CapLevelController" needs to be exported by the entry point hls.d.ts -// src/config.ts:223:3 - (ae-forgotten-export) The symbol "FPSController" needs to be exported by the entry point hls.d.ts +// src/config.ts:94:3 - (ae-forgotten-export) The symbol "DRMSystemsConfiguration" needs to be exported by the entry point hls.d.ts +// src/config.ts:197:3 - (ae-forgotten-export) The symbol "ILogger" needs to be exported by the entry point hls.d.ts +// src/config.ts:207:3 - (ae-forgotten-export) The symbol "AudioStreamController" needs to be exported by the entry point hls.d.ts +// src/config.ts:208:3 - (ae-forgotten-export) The symbol "AudioTrackController" needs to be exported by the entry point hls.d.ts +// src/config.ts:210:3 - (ae-forgotten-export) The symbol "SubtitleStreamController" needs to be exported by the entry point hls.d.ts +// src/config.ts:211:3 - (ae-forgotten-export) The symbol "SubtitleTrackController" needs to be exported by the entry point hls.d.ts +// src/config.ts:212:3 - (ae-forgotten-export) The symbol "TimelineController" needs to be exported by the entry point hls.d.ts +// src/config.ts:214:3 - (ae-forgotten-export) The symbol "EMEController" needs to be exported by the entry point hls.d.ts +// src/config.ts:217:3 - (ae-forgotten-export) The symbol "CMCDController" needs to be exported by the entry point hls.d.ts +// src/config.ts:219:3 - (ae-forgotten-export) The symbol "AbrController" needs to be exported by the entry point hls.d.ts +// src/config.ts:220:3 - (ae-forgotten-export) The symbol "BufferController" needs to be exported by the entry point hls.d.ts +// src/config.ts:221:3 - (ae-forgotten-export) The symbol "CapLevelController" needs to be exported by the entry point hls.d.ts +// src/config.ts:222: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/src/config.ts b/src/config.ts index df8f8a1091d..57a6e60bc6f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -90,7 +90,6 @@ export type EMEControllerConfig = { keyContext: MediaKeySessionContext ) => ArrayBuffer; emeEnabled: boolean; - useEmeEncryptedEvent: boolean; widevineLicenseUrl?: string; drmSystems: DRMSystemsConfiguration; drmSystemOptions: DRMSystemOptions; @@ -311,7 +310,6 @@ export const hlsDefaultConfig: HlsConfig = { maxLoadingDelay: 4, // used by abr-controller minAutoBitrate: 0, // used by hls emeEnabled: false, // used by eme-controller - useEmeEncryptedEvent: false, // used by eme-controller widevineLicenseUrl: undefined, // used by eme-controller drmSystems: {}, // used by eme-controller drmSystemOptions: {}, // used by eme-controller diff --git a/src/controller/eme-controller.ts b/src/controller/eme-controller.ts index 278d15edc3e..c914df4196b 100644 --- a/src/controller/eme-controller.ts +++ b/src/controller/eme-controller.ts @@ -12,6 +12,8 @@ import { keySystemDomainToKeySystemFormat as keySystemToKeySystemFormat, KeySystemFormats, keySystemFormatToKeySystemDomain, + KeySystemIds, + keySystemIdToKeySystemDomain, } from '../utils/mediakeys-helper'; import { KeySystems, @@ -31,6 +33,7 @@ import type { import type { EMEControllerConfig } from '../config'; import type { Fragment } from '../loader/fragment'; import Hex from '../utils/hex'; +import { parsePssh } from '../utils/mp4-tools'; const MAX_LICENSE_REQUEST_FAILURES = 3; const LOGGER_PREFIX = '[eme]'; @@ -410,10 +413,6 @@ class EMEController implements ComponentAPI { this.log(`Starting session for key ${keyDetails}`); - if (this.media && !this.config.useEmeEncryptedEvent) { - this.media.removeEventListener('encrypted', this.onMediaEncrypted); - } - let keySessionContextPromise = this.keyIdToKeySessionPromise[keyId]; if (!keySessionContextPromise) { keySessionContextPromise = this.keyIdToKeySessionPromise[keyId] = @@ -431,10 +430,6 @@ class EMEController implements ComponentAPI { mediaKeys, decryptdata, }); - if (this.config.useEmeEncryptedEvent) { - // Use 'encrypted' event initData and type rather than 'cenc' pssh from level-key - return keySessionContext; - } return this.generateRequestWithPreferredKeySession( keySessionContext, 'cenc', @@ -500,38 +495,63 @@ class EMEController implements ComponentAPI { } private _onMediaEncrypted(event: MediaEncryptedEvent) { - this.log(`"${event.type}" event: init data type: "${event.initDataType}"`); + const { initDataType, initData } = event; + this.log(`"${event.type}" event: init data type: "${initDataType}"`); - if (!this.config.useEmeEncryptedEvent) { + // Ignore event when initData is null + if (initData === null) { return; } - let keySessionContextPromise = this.keyIdToKeySessionPromise.encrypted; + // Support clear-lead key-session creation (otherwise depend on playlist keys) + const psshInfo = parsePssh(initData); + if (psshInfo === null) { + return; + } + let keyId: Uint8Array | null = null; + if ( + psshInfo.version === 0 && + psshInfo.systemId === KeySystemIds.WIDEVINE && + psshInfo.data + ) { + keyId = psshInfo.data.subarray(8, 24); + } + const keySystemDomain = keySystemIdToKeySystemDomain( + psshInfo.systemId as KeySystemIds + ); + if (!keySystemDomain || !keyId) { + return; + } + + const keyIdHex = Hex.hexDump(keyId); + let keySessionContextPromise = this.keyIdToKeySessionPromise[keyIdHex]; if (!keySessionContextPromise) { - keySessionContextPromise = this.keyIdToKeySessionPromise.encrypted = - this.getKeySystemSelectionPromise().then(({ keySystem, mediaKeys }) => { - this.throwIfDestroyed(); - const sessionParameters = { - decryptdata: new LevelKey( - 'UNKNOWN', - 'encrypted', - keySystemToKeySystemFormat(keySystem) ?? '' - ), - keySystem, - mediaKeys, - }; - sessionParameters.decryptdata.keyId = new Uint8Array(16); - return this.attemptSetMediaKeys(keySystem, mediaKeys).then(() => { + keySessionContextPromise = this.keyIdToKeySessionPromise[keyIdHex] = + this.getKeySystemSelectionPromise([keySystemDomain]).then( + ({ keySystem, mediaKeys }) => { this.throwIfDestroyed(); - const keySessionContext = - this.createMediaKeySessionContext(sessionParameters); - return this.generateRequestWithPreferredKeySession( - keySessionContext, - event.initDataType, - event.initData + const decryptdata = new LevelKey( + 'ISO-23001-7', + keyIdHex, + keySystemToKeySystemFormat(keySystem) ?? '' ); - }); - }); + decryptdata.pssh = new Uint8Array(initData); + decryptdata.keyId = keyId; + return this.attemptSetMediaKeys(keySystem, mediaKeys).then(() => { + this.throwIfDestroyed(); + const keySessionContext = this.createMediaKeySessionContext({ + decryptdata, + keySystem, + mediaKeys, + }); + return this.generateRequestWithPreferredKeySession( + keySessionContext, + initDataType, + initData + ); + }); + } + ); } keySessionContextPromise.catch((error) => this.handleError(error)); } @@ -1035,9 +1055,7 @@ class EMEController implements ComponentAPI { // keep reference of media this.media = media; - if (this.config.useEmeEncryptedEvent) { - media.addEventListener('encrypted', this.onMediaEncrypted); - } + media.addEventListener('encrypted', this.onMediaEncrypted); media.addEventListener('waitingforkey', this.onWaitingForKey); } diff --git a/src/utils/mediakeys-helper.ts b/src/utils/mediakeys-helper.ts index ca18c3f5736..ca991284d7c 100644 --- a/src/utils/mediakeys-helper.ts +++ b/src/utils/mediakeys-helper.ts @@ -10,7 +10,7 @@ export enum KeySystems { WIDEVINE = 'com.widevine.alpha', } -// Playlist parser +// Playlist #EXT-X-KEY KEYFORMAT values export enum KeySystemFormats { CLEARKEY = 'org.w3.clearkey', FAIRPLAY = 'com.apple.streamingkeydelivery', @@ -32,6 +32,27 @@ export function keySystemFormatToKeySystemDomain( } } +// System IDs for which we can extract a key ID from "encrypted" event PSSH +export enum KeySystemIds { + // CENC = '1077efecc0b24d02ace33c1e52e2fb4b' + // CLEARKEY = 'e2719d58a985b3c9781ab030af78d30e', + // FAIRPLAY = '94ce86fb07ff4f43adb893d2fa968ca2', + // PLAYREADY = '9a04f07998404286ab92e65be0885f95', + WIDEVINE = 'edef8ba979d64acea3c827dcd51d21ed', +} + +export function keySystemIdToKeySystemDomain( + systemId: KeySystemIds +): KeySystems | undefined { + if (systemId === KeySystemIds.WIDEVINE) { + return KeySystems.WIDEVINE; + // } else if (systemId === KeySystemIds.PLAYREADY) { + // return KeySystems.PLAYREADY; + // } else if (systemId === KeySystemIds.CENC || systemId === KeySystemIds.CLEARKEY) { + // return KeySystems.CLEARKEY; + } +} + export function keySystemDomainToKeySystemFormat( keySystem: KeySystems ): KeySystemFormats | undefined { diff --git a/src/utils/mp4-tools.ts b/src/utils/mp4-tools.ts index 34ccb6af692..7a260bf6f27 100644 --- a/src/utils/mp4-tools.ts +++ b/src/utils/mp4-tools.ts @@ -1099,3 +1099,42 @@ export function mp4pssh( data || new Uint8Array() ); } + +export function parsePssh(initData: ArrayBuffer) { + if (!(initData instanceof ArrayBuffer) || initData.byteLength < 32) { + return null; + } + const result = { + version: 0, + systemId: '', + kids: null as null | Uint8Array[], + data: null as null | Uint8Array, + }; + const view = new DataView(initData); + const boxSize = view.getUint32(0); + if (initData.byteLength !== boxSize && boxSize > 44) { + return null; + } + const type = view.getUint32(4); + if (type !== 0x70737368) { + return null; + } + result.version = view.getUint32(8) >>> 24; + if (result.version > 1) { + return null; + } + result.systemId = Hex.hexDump(new Uint8Array(initData, 12, 16)); + const dataSizeOrKidCount = view.getUint32(28); + if (result.version === 0) { + if (boxSize - 32 < dataSizeOrKidCount) { + return null; + } + result.data = new Uint8Array(initData, 32, dataSizeOrKidCount); + } else if (result.version === 1) { + result.kids = []; + for (let i = 0; i < dataSizeOrKidCount; i++) { + result.kids.push(new Uint8Array(initData, 32 + i * 16, 16)); + } + } + return result; +} diff --git a/tests/unit/controller/eme-controller.ts b/tests/unit/controller/eme-controller.ts index 5fa88500e40..6dc4801c2ed 100644 --- a/tests/unit/controller/eme-controller.ts +++ b/tests/unit/controller/eme-controller.ts @@ -111,7 +111,6 @@ describe('EMEController', function () { }, }, requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy, - // useEmeEncryptedEvent: true, // skip generate request, license exchange, and key status "usable" }); sinonFakeXMLHttpRequestStatic.onCreate = ( @@ -249,7 +248,7 @@ describe('EMEController', function () { }); }); - it('should trigger key system error(s) when bad encrypted data is received', function () { + it('should ignore "encrypted" events with bad data', function () { const reqMediaKsAccessSpy = sinon.spy(function () { return Promise.resolve({ // Media-keys mock @@ -271,13 +270,26 @@ describe('EMEController', function () { setupEach({ emeEnabled: true, - useEmeEncryptedEvent: true, requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy, }); const badData = { initDataType: 'cenc', - initData: 'bad data', + initData: new Uint8Array([ + // box size + 0, 0, 0, 44, + // "PSSH" + 112, 115, 115, 104, + // version + 0, 0, 0, 0, + // Widevine system id + 237, 239, 139, 169, 121, 214, 74, 206, 163, 200, 39, 220, 213, 29, 33, + 237, + // data size + 0, 0, 0, 12, + // data (incomplete key) + 0, 0, 0, 0, 0, 0, 0, 0, 240, 0, 186, 0, + ]).buffer, }; emeController.onMediaAttached(Events.MEDIA_ATTACHED, { @@ -286,11 +298,11 @@ describe('EMEController', function () { media.emit('encrypted', badData); - expect(emeController.keyIdToKeySessionPromise.encrypted).to.be.a('Promise'); - if (!emeController.keyIdToKeySessionPromise.encrypted) { + expect(emeController.keyIdToKeySessionPromise.f000ba00).to.be.a('Promise'); + if (!emeController.keyIdToKeySessionPromise.f000ba00) { return; } - return emeController.keyIdToKeySessionPromise.encrypted + return emeController.keyIdToKeySessionPromise.f000ba00 .catch(() => {}) .finally(() => { expect(emeController.hls.trigger).callCount(1); From 412413ba7af39cab5ab87be451bd045a53d7895c Mon Sep 17 00:00:00 2001 From: Rob Walch Date: Wed, 16 Nov 2022 17:37:19 -0800 Subject: [PATCH 09/19] Emit and handle FRAG_PARSING_ERROR from transmuxers (#5018) * Emit and handle FRAG_PARSING_ERROR from transmuxers Related to #5011 * Switch levels on Key and Fragment parsing errors or escalate to fatal error --- api-extractor/report/hls.js.api.md | 2 + src/controller/audio-stream-controller.ts | 1 + src/controller/base-stream-controller.ts | 19 +++++++- src/controller/level-controller.ts | 7 ++- src/controller/stream-controller.ts | 1 + src/demux/transmuxer-interface.ts | 58 +++++++++++++++++++---- src/demux/transmuxer-worker.ts | 47 ++++++++++++++---- src/demux/transmuxer.ts | 1 + src/types/events.ts | 1 + 9 files changed, 118 insertions(+), 19 deletions(-) diff --git a/api-extractor/report/hls.js.api.md b/api-extractor/report/hls.js.api.md index cc58b99d8a0..ddaa9948601 100644 --- a/api-extractor/report/hls.js.api.md +++ b/api-extractor/report/hls.js.api.md @@ -374,6 +374,8 @@ export interface ErrorData { // (undocumented) bytes?: number; // (undocumented) + chunkMeta?: ChunkMetadata; + // (undocumented) context?: PlaylistLoaderContext; // (undocumented) details: ErrorDetails; diff --git a/src/controller/audio-stream-controller.ts b/src/controller/audio-stream-controller.ts index 28d332005ed..3c86f6803e2 100644 --- a/src/controller/audio-stream-controller.ts +++ b/src/controller/audio-stream-controller.ts @@ -627,6 +627,7 @@ class AudioStreamController switch (data.details) { case ErrorDetails.FRAG_LOAD_ERROR: case ErrorDetails.FRAG_LOAD_TIMEOUT: + case ErrorDetails.FRAG_PARSING_ERROR: case ErrorDetails.KEY_LOAD_ERROR: case ErrorDetails.KEY_LOAD_TIMEOUT: case ErrorDetails.KEY_SYSTEM_NO_SESSION: diff --git a/src/controller/base-stream-controller.ts b/src/controller/base-stream-controller.ts index a2b16945cbc..cba4bfca4c2 100644 --- a/src/controller/base-stream-controller.ts +++ b/src/controller/base-stream-controller.ts @@ -759,7 +759,11 @@ export default class BaseStreamController protected _handleTransmuxerFlush(chunkMeta: ChunkMetadata) { const context = this.getCurrentContext(chunkMeta); if (!context || this.state !== State.PARSING) { - if (!this.fragCurrent) { + if ( + !this.fragCurrent && + this.state !== State.STOPPED && + this.state !== State.ERROR + ) { this.state = State.IDLE; } return; @@ -1312,8 +1316,20 @@ export default class BaseStreamController data: ErrorData ) { if (data.fatal) { + this.stopLoad(); + this.state = State.ERROR; return; } + const config = this.config; + if (data.chunkMeta) { + // Parsing Error: no retries + const context = this.getCurrentContext(data.chunkMeta); + if (context) { + data.frag = context.frag; + data.levelRetry = true; + this.fragLoadError = config.fragLoadingMaxRetry; + } + } const frag = data.frag; // Handle frag error related to caller's filterType if (!frag || frag.type !== filterType) { @@ -1327,7 +1343,6 @@ export default class BaseStreamController frag.urlId === fragCurrent.urlId, 'Frag load error must match current frag to retry' ); - const config = this.config; // keep retrying until the limit will be reached if (this.fragLoadError + 1 <= config.fragLoadingMaxRetry) { if (!this.loadedmetadata) { diff --git a/src/controller/level-controller.ts b/src/controller/level-controller.ts index c9e884ad978..181c3fc17ea 100644 --- a/src/controller/level-controller.ts +++ b/src/controller/level-controller.ts @@ -362,13 +362,15 @@ export default class LevelController extends BasePlaylistController { } } break; + case ErrorDetails.FRAG_PARSING_ERROR: case ErrorDetails.KEY_SYSTEM_NO_SESSION: case ErrorDetails.KEY_SYSTEM_STATUS_OUTPUT_RESTRICTED: levelIndex = data.frag?.type === PlaylistLevelType.MAIN ? data.frag.level : this.currentLevelIndex; - levelError = true; + // Do not retry level. Escalate to fatal if switching levels fails. + data.levelRetry = false; break; case ErrorDetails.LEVEL_LOAD_ERROR: case ErrorDetails.LEVEL_LOAD_TIMEOUT: @@ -443,6 +445,9 @@ export default class LevelController extends BasePlaylistController { this.warn(`${errorDetails}: switch to ${nextLevel}`); errorEvent.levelRetry = true; this.hls.nextAutoLevel = nextLevel; + } else if (errorEvent.levelRetry === false) { + // No levels to switch to and no more retries + errorEvent.fatal = true; } } } diff --git a/src/controller/stream-controller.ts b/src/controller/stream-controller.ts index 1bdc31e851e..974bc012bfc 100644 --- a/src/controller/stream-controller.ts +++ b/src/controller/stream-controller.ts @@ -852,6 +852,7 @@ export default class StreamController switch (data.details) { case ErrorDetails.FRAG_LOAD_ERROR: case ErrorDetails.FRAG_LOAD_TIMEOUT: + case ErrorDetails.FRAG_PARSING_ERROR: case ErrorDetails.KEY_LOAD_ERROR: case ErrorDetails.KEY_LOAD_TIMEOUT: case ErrorDetails.KEY_SYSTEM_NO_SESSION: diff --git a/src/demux/transmuxer-interface.ts b/src/demux/transmuxer-interface.ts index db5a8634525..a6af4619893 100644 --- a/src/demux/transmuxer-interface.ts +++ b/src/demux/transmuxer-interface.ts @@ -239,10 +239,20 @@ export default class TransmuxerInterface { state ); if (isPromise(transmuxResult)) { - transmuxResult.then((data) => { - this.handleTransmuxComplete(data); - }); + transmuxer.async = true; + transmuxResult + .then((data) => { + this.handleTransmuxComplete(data); + }) + .catch((error) => { + this.transmuxerError( + error, + chunkMeta, + 'transmuxer-interface push error' + ); + }); } else { + transmuxer.async = false; this.handleTransmuxComplete(transmuxResult as TransmuxerResult); } } @@ -252,16 +262,29 @@ export default class TransmuxerInterface { chunkMeta.transmuxing.start = self.performance.now(); const { transmuxer, worker } = this; if (worker) { + 1; worker.postMessage({ cmd: 'flush', chunkMeta, }); } else if (transmuxer) { - const transmuxResult = transmuxer.flush(chunkMeta); - if (isPromise(transmuxResult)) { - transmuxResult.then((data) => { - this.handleFlushResult(data, chunkMeta); - }); + let transmuxResult = transmuxer.flush(chunkMeta); + const asyncFlush = isPromise(transmuxResult); + if (asyncFlush || transmuxer.async) { + if (!isPromise(transmuxResult)) { + transmuxResult = Promise.resolve(transmuxResult); + } + transmuxResult + .then((data) => { + this.handleFlushResult(data, chunkMeta); + }) + .catch((error) => { + this.transmuxerError( + error, + chunkMeta, + 'transmuxer-interface flush error' + ); + }); } else { this.handleFlushResult( transmuxResult as Array, @@ -271,6 +294,25 @@ export default class TransmuxerInterface { } } + private transmuxerError( + error: Error, + chunkMeta: ChunkMetadata, + reason: string + ) { + if (!this.hls) { + return; + } + this.hls.trigger(Events.ERROR, { + type: ErrorTypes.MEDIA_ERROR, + details: ErrorDetails.FRAG_PARSING_ERROR, + chunkMeta, + fatal: false, + error, + err: error, + reason, + }); + } + private handleFlushResult( results: Array, chunkMeta: ChunkMetadata diff --git a/src/demux/transmuxer-worker.ts b/src/demux/transmuxer-worker.ts index 4c4d0e7d946..7f898e87efc 100644 --- a/src/demux/transmuxer-worker.ts +++ b/src/demux/transmuxer-worker.ts @@ -4,6 +4,7 @@ import { ILogFunction, enableLogs, logger } from '../utils/logger'; import { EventEmitter } from 'eventemitter3'; import type { RemuxedTrack, RemuxerResult } from '../types/remuxer'; import type { TransmuxerResult, ChunkMetadata } from '../types/transmuxer'; +import { ErrorDetails, ErrorTypes } from '../errors'; export default function TransmuxerWorker(self) { const observer = new EventEmitter(); @@ -59,21 +60,51 @@ export default function TransmuxerWorker(self) { data.state ); if (isPromise(transmuxResult)) { - transmuxResult.then((data) => { - emitTransmuxComplete(self, data); - }); + self.transmuxer.async = true; + transmuxResult + .then((data) => { + emitTransmuxComplete(self, data); + }) + .catch((error) => { + forwardMessage(Events.ERROR, { + type: ErrorTypes.MEDIA_ERROR, + details: ErrorDetails.FRAG_PARSING_ERROR, + chunkMeta: data.chunkMeta, + fatal: false, + error, + err: error, + reason: `transmuxer-worker push error`, + }); + }); } else { + self.transmuxer.async = false; emitTransmuxComplete(self, transmuxResult); } break; } case 'flush': { const id = data.chunkMeta; - const transmuxResult = self.transmuxer.flush(id); - if (isPromise(transmuxResult)) { - transmuxResult.then((results: Array) => { - handleFlushResult(self, results as Array, id); - }); + let transmuxResult = self.transmuxer.flush(id); + const asyncFlush = isPromise(transmuxResult); + if (asyncFlush || self.transmuxer.async) { + if (!isPromise(transmuxResult)) { + transmuxResult = Promise.resolve(transmuxResult); + } + transmuxResult + .then((results: Array) => { + handleFlushResult(self, results as Array, id); + }) + .catch((error) => { + forwardMessage(Events.ERROR, { + type: ErrorTypes.MEDIA_ERROR, + details: ErrorDetails.FRAG_PARSING_ERROR, + chunkMeta: data.chunkMeta, + fatal: false, + error, + err: error, + reason: `transmuxer-worker flush error`, + }); + }); } else { handleFlushResult( self, diff --git a/src/demux/transmuxer.ts b/src/demux/transmuxer.ts index d1443baf485..b98e98c7824 100644 --- a/src/demux/transmuxer.ts +++ b/src/demux/transmuxer.ts @@ -39,6 +39,7 @@ const muxConfig: MuxConfig[] = [ ]; export default class Transmuxer { + public async: boolean = false; private observer: HlsEventEmitter; private typeSupported: TypeSupported; private config: HlsConfig; diff --git a/src/types/events.ts b/src/types/events.ts index 6c1d0d70845..33860b55f74 100644 --- a/src/types/events.ts +++ b/src/types/events.ts @@ -216,6 +216,7 @@ export interface ErrorData { fatal: boolean; buffer?: number; bytes?: number; + chunkMeta?: ChunkMetadata; context?: PlaylistLoaderContext; error?: Error; event?: keyof HlsListeners | 'demuxerWorker'; From 83799b9a21a8df4c9c0ac3b6955429e9fe3c7c47 Mon Sep 17 00:00:00 2001 From: Rob Walch Date: Thu, 17 Nov 2022 14:40:36 -0800 Subject: [PATCH 10/19] Route all key-system errors to `onFragmentOrKeyLoadError` --- src/controller/audio-stream-controller.ts | 6 ++++-- src/controller/stream-controller.ts | 8 +++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/controller/audio-stream-controller.ts b/src/controller/audio-stream-controller.ts index 3c86f6803e2..e3f572bb454 100644 --- a/src/controller/audio-stream-controller.ts +++ b/src/controller/audio-stream-controller.ts @@ -624,14 +624,16 @@ class AudioStreamController } private onError(event: Events.ERROR, data: ErrorData) { + if (data.type === ErrorTypes.KEY_SYSTEM_ERROR) { + this.onFragmentOrKeyLoadError(PlaylistLevelType.AUDIO, data); + return; + } switch (data.details) { case ErrorDetails.FRAG_LOAD_ERROR: case ErrorDetails.FRAG_LOAD_TIMEOUT: case ErrorDetails.FRAG_PARSING_ERROR: case ErrorDetails.KEY_LOAD_ERROR: case ErrorDetails.KEY_LOAD_TIMEOUT: - case ErrorDetails.KEY_SYSTEM_NO_SESSION: - case ErrorDetails.KEY_SYSTEM_STATUS_OUTPUT_RESTRICTED: // TODO: Skip fragments that do not belong to this.fragCurrent audio-group id this.onFragmentOrKeyLoadError(PlaylistLevelType.AUDIO, data); break; diff --git a/src/controller/stream-controller.ts b/src/controller/stream-controller.ts index 974bc012bfc..ad4f32fcbee 100644 --- a/src/controller/stream-controller.ts +++ b/src/controller/stream-controller.ts @@ -8,7 +8,7 @@ import { ElementaryStreamTypes, Fragment } from '../loader/fragment'; import TransmuxerInterface from '../demux/transmuxer-interface'; import { ChunkMetadata } from '../types/transmuxer'; import GapController from './gap-controller'; -import { ErrorDetails } from '../errors'; +import { ErrorDetails, ErrorTypes } from '../errors'; import type { NetworkComponentAPI } from '../types/component-api'; import type Hls from '../hls'; import type { Level } from '../types/level'; @@ -849,14 +849,16 @@ export default class StreamController } private onError(event: Events.ERROR, data: ErrorData) { + if (data.type === ErrorTypes.KEY_SYSTEM_ERROR) { + this.onFragmentOrKeyLoadError(PlaylistLevelType.MAIN, data); + return; + } switch (data.details) { case ErrorDetails.FRAG_LOAD_ERROR: case ErrorDetails.FRAG_LOAD_TIMEOUT: case ErrorDetails.FRAG_PARSING_ERROR: case ErrorDetails.KEY_LOAD_ERROR: case ErrorDetails.KEY_LOAD_TIMEOUT: - case ErrorDetails.KEY_SYSTEM_NO_SESSION: - case ErrorDetails.KEY_SYSTEM_STATUS_OUTPUT_RESTRICTED: this.onFragmentOrKeyLoadError(PlaylistLevelType.MAIN, data); break; case ErrorDetails.LEVEL_LOAD_ERROR: From 8c34bfb568253cc127194e8af752fb3e1d7209cf Mon Sep 17 00:00:00 2001 From: Rob Walch Date: Thu, 17 Nov 2022 14:45:38 -0800 Subject: [PATCH 11/19] Populate EMEKeyError.err for better demo error logging --- 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 c914df4196b..9309983ae95 100644 --- a/src/controller/eme-controller.ts +++ b/src/controller/eme-controller.ts @@ -1143,6 +1143,7 @@ class EMEKeyError extends Error { constructor(data: ErrorData, message: string) { super(message); this.data = data; + data.err = data.error; } } From c73d45b4d338e63f3129031587bd399e65effbfe Mon Sep 17 00:00:00 2001 From: Rob Walch Date: Mon, 28 Nov 2022 15:46:06 -0800 Subject: [PATCH 12/19] Remove `useEmeEncryptedEvent` and mark `widevineLicenseUrl` as deprecated in API.md --- docs/API.md | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/docs/API.md b/docs/API.md index 0170f665c1e..da714e5d74e 100644 --- a/docs/API.md +++ b/docs/API.md @@ -94,8 +94,7 @@ - [`abrMaxWithRealBitrate`](#abrmaxwithrealbitrate) - [`minAutoBitrate`](#minautobitrate) - [`emeEnabled`](#emeEnabled) - - [`useEmeEncryptedEvent`](#useEmeEncryptedEvent) - - [`widevineLicenseUrl`](#widevineLicenseUrl) + - [`widevineLicenseUrl`](#widevineLicenseUrl) (deprecated) - [`licenseXhrSetup`](#licenseXhrSetup) - [`licenseResponseCallback`](#licenseResponseCallback) - [`drmSystems`](#drmSystems) @@ -400,8 +399,6 @@ var config = { maxLoadingDelay: 4, minAutoBitrate: 0, emeEnabled: false, - useEmeEncryptedEvent: false, - widevineLicenseUrl: undefined, licenseXhrSetup: undefined, drmSystems: {}, drmSystemOptions: {}, @@ -1194,17 +1191,11 @@ Useful when browser or tab of the browser is not in the focus and bandwidth drop Set to `true` to enable DRM key system access and license retrieval. -### `useEmeEncryptedEvent` - -(default: `false`) - -Set to `true` to use media "encrypted" event initData and ignore manifest DRM keys. - -### `widevineLicenseUrl` +### `widevineLicenseUrl` (deprecated) (default: `undefined`) -The Widevine license server URL. +`widevineLicenseUrl` has been deprecated. Use `drmSystems['com.widevine.alpha'].licenseUrl` instead. ### `licenseXhrSetup` From d3d57fd57b19d812094a8e30609164efca2aca36 Mon Sep 17 00:00:00 2001 From: Rob Walch Date: Tue, 29 Nov 2022 18:26:44 -0800 Subject: [PATCH 13/19] Add support for EXT-X-SESSION-KEY tags (for key-system access on manifest loaded) #4927 --- api-extractor/report/hls.js.api.md | 38 ++++-- src/config.ts | 2 + src/controller/eme-controller.ts | 73 ++++++++--- src/controller/level-controller.ts | 2 + src/loader/level-key.ts | 27 ++++ src/loader/m3u8-parser.ts | 150 ++++++++++------------ src/loader/playlist-loader.ts | 7 +- src/types/events.ts | 4 + src/utils/mediakeys-helper.ts | 19 +-- tests/unit/controller/level-controller.ts | 26 +++- tests/unit/loader/playlist-loader.ts | 51 ++++++++ 11 files changed, 267 insertions(+), 132 deletions(-) diff --git a/api-extractor/report/hls.js.api.md b/api-extractor/report/hls.js.api.md index ddaa9948601..a9bab59499e 100644 --- a/api-extractor/report/hls.js.api.md +++ b/api-extractor/report/hls.js.api.md @@ -313,6 +313,8 @@ export class DateRange { export type DRMSystemOptions = { audioRobustness?: string; videoRobustness?: string; + audioEncryptionScheme?: string | null; + videoEncryptionScheme?: string | null; persistentState?: MediaKeysRequirement; distinctiveIdentifier?: MediaKeysRequirement; sessionTypes?: string[]; @@ -1525,6 +1527,8 @@ export class LevelKey implements DecryptData { // (undocumented) readonly isCommonEncryption: boolean; // (undocumented) + isSupported(): boolean; + // (undocumented) iv: Uint8Array | null; // (undocumented) key: Uint8Array | null; @@ -1839,6 +1843,8 @@ export interface ManifestLoadedData { // (undocumented) sessionData: Record | null; // (undocumented) + sessionKeys: LevelKey[] | null; + // (undocumented) stats: LoaderStats; // (undocumented) subtitles?: MediaPlaylist[]; @@ -1869,6 +1875,10 @@ export interface ManifestParsedData { // (undocumented) levels: Level[]; // (undocumented) + sessionData: Record | null; + // (undocumented) + sessionKeys: LevelKey[] | null; + // (undocumented) stats: LoaderStats; // (undocumented) subtitleTracks: MediaPlaylist[]; @@ -2283,20 +2293,20 @@ export interface UserdataSample { // Warnings were encountered during analysis: // -// src/config.ts:79:3 - (ae-forgotten-export) The symbol "MediaKeySessionContext" needs to be exported by the entry point hls.d.ts -// src/config.ts:94:3 - (ae-forgotten-export) The symbol "DRMSystemsConfiguration" needs to be exported by the entry point hls.d.ts -// src/config.ts:197:3 - (ae-forgotten-export) The symbol "ILogger" needs to be exported by the entry point hls.d.ts -// src/config.ts:207:3 - (ae-forgotten-export) The symbol "AudioStreamController" needs to be exported by the entry point hls.d.ts -// src/config.ts:208:3 - (ae-forgotten-export) The symbol "AudioTrackController" needs to be exported by the entry point hls.d.ts -// src/config.ts:210:3 - (ae-forgotten-export) The symbol "SubtitleStreamController" needs to be exported by the entry point hls.d.ts -// src/config.ts:211:3 - (ae-forgotten-export) The symbol "SubtitleTrackController" needs to be exported by the entry point hls.d.ts -// src/config.ts:212:3 - (ae-forgotten-export) The symbol "TimelineController" needs to be exported by the entry point hls.d.ts -// src/config.ts:214:3 - (ae-forgotten-export) The symbol "EMEController" needs to be exported by the entry point hls.d.ts -// src/config.ts:217:3 - (ae-forgotten-export) The symbol "CMCDController" needs to be exported by the entry point hls.d.ts -// src/config.ts:219:3 - (ae-forgotten-export) The symbol "AbrController" needs to be exported by the entry point hls.d.ts -// src/config.ts:220:3 - (ae-forgotten-export) The symbol "BufferController" needs to be exported by the entry point hls.d.ts -// src/config.ts:221:3 - (ae-forgotten-export) The symbol "CapLevelController" needs to be exported by the entry point hls.d.ts -// src/config.ts:222:3 - (ae-forgotten-export) The symbol "FPSController" needs to be exported by the entry point hls.d.ts +// src/config.ts:81:3 - (ae-forgotten-export) The symbol "MediaKeySessionContext" needs to be exported by the entry point hls.d.ts +// src/config.ts:96:3 - (ae-forgotten-export) The symbol "DRMSystemsConfiguration" needs to be exported by the entry point hls.d.ts +// src/config.ts:199:3 - (ae-forgotten-export) The symbol "ILogger" needs to be exported by the entry point hls.d.ts +// src/config.ts:209:3 - (ae-forgotten-export) The symbol "AudioStreamController" needs to be exported by the entry point hls.d.ts +// src/config.ts:210:3 - (ae-forgotten-export) The symbol "AudioTrackController" needs to be exported by the entry point hls.d.ts +// src/config.ts:212:3 - (ae-forgotten-export) The symbol "SubtitleStreamController" needs to be exported by the entry point hls.d.ts +// src/config.ts:213:3 - (ae-forgotten-export) The symbol "SubtitleTrackController" needs to be exported by the entry point hls.d.ts +// src/config.ts:214:3 - (ae-forgotten-export) The symbol "TimelineController" needs to be exported by the entry point hls.d.ts +// src/config.ts:216:3 - (ae-forgotten-export) The symbol "EMEController" needs to be exported by the entry point hls.d.ts +// src/config.ts:219:3 - (ae-forgotten-export) The symbol "CMCDController" needs to be exported by the entry point hls.d.ts +// src/config.ts:221:3 - (ae-forgotten-export) The symbol "AbrController" needs to be exported by the entry point hls.d.ts +// src/config.ts:222:3 - (ae-forgotten-export) The symbol "BufferController" needs to be exported by the entry point hls.d.ts +// src/config.ts:223:3 - (ae-forgotten-export) The symbol "CapLevelController" needs to be exported by the entry point hls.d.ts +// src/config.ts:224: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/src/config.ts b/src/config.ts index 57a6e60bc6f..e9960f3d05b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -60,6 +60,8 @@ export type CMCDControllerConfig = { export type DRMSystemOptions = { audioRobustness?: string; videoRobustness?: string; + audioEncryptionScheme?: string | null; + videoEncryptionScheme?: string | null; persistentState?: MediaKeysRequirement; distinctiveIdentifier?: MediaKeysRequirement; sessionTypes?: string[]; diff --git a/src/controller/eme-controller.ts b/src/controller/eme-controller.ts index 9309983ae95..e35f002a00d 100644 --- a/src/controller/eme-controller.ts +++ b/src/controller/eme-controller.ts @@ -29,6 +29,7 @@ import type { MediaAttachedData, KeyLoadedData, ErrorData, + ManifestLoadedData, } from '../types/events'; import type { EMEControllerConfig } from '../config'; import type { Fragment } from '../loader/fragment'; @@ -105,11 +106,13 @@ class EMEController implements ComponentAPI { 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_LOADED, this.onManifestLoaded, 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_LOADED, this.onManifestLoaded, this); } private getLicenseServerUrl(keySystem: KeySystems): string | never { @@ -384,27 +387,31 @@ class EMEController implements ComponentAPI { frag.level }) key formats ${keyFormats.join(', ')}` ); - this.keyFormatPromise = new Promise((resolve, reject) => { - const keySystemsToAttempt = keyFormats - .map(keySystemFormatToKeySystemDomain) - .filter((value) => !!value) as any as KeySystems[]; - return this.getKeySystemSelectionPromise(keySystemsToAttempt).then( - ({ keySystem }) => { - const keySystemFormat = keySystemToKeySystemFormat(keySystem); - if (keySystemFormat) { - resolve(keySystemFormat); - } else { - reject( - new Error(`Unable to find format for key-system "${keySystem}"`) - ); - } - } - ); - }); + this.keyFormatPromise = this.getKeyFormatPromise(keyFormats); } return this.keyFormatPromise; } + private getKeyFormatPromise(keyFormats: string[]): Promise { + return new Promise((resolve, reject) => { + const keySystemsToAttempt = keyFormats + .map(keySystemFormatToKeySystemDomain) + .filter((value) => !!value) as any as KeySystems[]; + return this.getKeySystemSelectionPromise(keySystemsToAttempt).then( + ({ keySystem }) => { + const keySystemFormat = keySystemToKeySystemFormat(keySystem); + if (keySystemFormat) { + resolve(keySystemFormat); + } else { + reject( + new Error(`Unable to find format for key-system "${keySystem}"`) + ); + } + } + ); + }); + } + public loadKey(data: KeyLoadedData): Promise { const decryptdata = data.keyInfo.decryptdata; @@ -890,7 +897,11 @@ class EMEController implements ComponentAPI { licenseChallenge ); }) - .catch(() => { + .catch((error: Error) => { + if (!keysListItem.decryptdata) { + // Key session removed. Cancel license request. + throw error; + } // let's try to open before running setup xhr.open('POST', url, true); @@ -1102,7 +1113,31 @@ class EMEController implements ComponentAPI { }); } - removeSession( + private onManifestLoaded( + event: Events.MANIFEST_LOADED, + { sessionKeys }: ManifestLoadedData + ) { + if (!sessionKeys || !this.config.emeEnabled) { + return; + } + if (!this.keyFormatPromise) { + const keyFormats = sessionKeys.reduce( + (formats: string[], sessionKey: LevelKey) => { + if (formats.indexOf(sessionKey.keyFormat) === -1) { + formats.push(sessionKey.keyFormat); + } + return formats; + }, + [] + ); + this.log( + `Selecting key-system from session-keys ${keyFormats.join(', ')}` + ); + this.keyFormatPromise = this.getKeyFormatPromise(keyFormats); + } + } + + private removeSession( mediaKeySessionContext: MediaKeySessionContext ): Promise | void { const { mediaKeysSession, licenseXhr } = mediaKeySessionContext; diff --git a/src/controller/level-controller.ts b/src/controller/level-controller.ts index 181c3fc17ea..e794edf8b65 100644 --- a/src/controller/level-controller.ts +++ b/src/controller/level-controller.ts @@ -183,6 +183,8 @@ export default class LevelController extends BasePlaylistController { levels, audioTracks, subtitleTracks, + sessionData: data.sessionData, + sessionKeys: data.sessionKeys, firstLevel: this._firstLevel, stats: data.stats, audio: audioCodecFound, diff --git a/src/loader/level-key.ts b/src/loader/level-key.ts index 2011a36f52b..d8e0133cfd8 100644 --- a/src/loader/level-key.ts +++ b/src/loader/level-key.ts @@ -55,6 +55,33 @@ export class LevelKey implements DecryptData { this.isCommonEncryption = this.encrypted && method !== 'AES-128'; } + public isSupported(): boolean { + // If it's Segment encryption or No encryption, just select that key system + if (this.method) { + if (this.method === 'AES-128' || this.method === 'NONE') { + return true; + } + switch (this.keyFormat) { + case 'identity': + // Maintain support for clear SAMPLE-AES with MPEG-3 TS + return this.method === 'SAMPLE-AES'; + case KeySystemFormats.FAIRPLAY: + case KeySystemFormats.WIDEVINE: + case KeySystemFormats.PLAYREADY: + case KeySystemFormats.CLEARKEY: + return ( + [ + 'ISO-23001-7', + 'SAMPLE-AES', + 'SAMPLE-AES-CENC', + 'SAMPLE-AES-CTR', + ].indexOf(this.method) !== -1 + ); + } + } + return false; + } + public getDecryptData(sn: number | 'initSegment'): LevelKey | null { if (!this.encrypted || !this.uri) { return null; diff --git a/src/loader/m3u8-parser.ts b/src/loader/m3u8-parser.ts index 7c27146150a..1cc21ab709c 100644 --- a/src/loader/m3u8-parser.ts +++ b/src/loader/m3u8-parser.ts @@ -4,8 +4,6 @@ import { DateRange } from './date-range'; import { Fragment, Part } from './fragment'; import { LevelDetails } from './level-details'; import { LevelKey } from './level-key'; -import { KeySystemFormats } from '../utils/mediakeys-helper'; - import { AttrList } from '../utils/attr-list'; import { logger } from '../utils/logger'; import type { CodecType } from '../utils/codecs'; @@ -20,9 +18,15 @@ import type { LevelAttributes, LevelParsed } from '../types/level'; type M3U8ParserFragments = Array; +type ParsedMultiVariantPlaylist = { + levels: LevelParsed[]; + sessionData: Record | null; + sessionKeys: LevelKey[] | null; +}; + // https://regex101.com is your friend const MASTER_PLAYLIST_REGEX = - /#EXT-X-STREAM-INF:([^\r\n]*)(?:[\r\n](?:#[^\r\n]*)?)*([^\r\n]+)|#EXT-X-SESSION-DATA:([^\r\n]*)[\r\n]+/g; + /#EXT-X-STREAM-INF:([^\r\n]*)(?:[\r\n](?:#[^\r\n]*)?)*([^\r\n]+)|#EXT-X-SESSION-DATA:([^\r\n]*)[\r\n]+|#EXT-X-SESSION-KEY:([^\n\r]*)[\r\n]+/g; const MASTER_PLAYLIST_MEDIA_REGEX = /#EXT-X-MEDIA:(.*)/g; const LEVEL_PLAYLIST_REGEX_FAST = new RegExp( @@ -84,10 +88,15 @@ export default class M3U8Parser { return URLToolkit.buildAbsoluteURL(baseUrl, url, { alwaysNormalize: true }); } - static parseMasterPlaylist(string: string, baseurl: string) { - const levels: Array = []; - const levelsWithKnownCodecs: Array = []; + static parseMasterPlaylist( + string: string, + baseurl: string + ): ParsedMultiVariantPlaylist { + const levels: LevelParsed[] = []; + const levelsWithKnownCodecs: LevelParsed[] = []; const sessionData: Record = {}; + const sessionKeys: LevelKey[] = []; + let hasSessionData = false; MASTER_PLAYLIST_REGEX.lastIndex = 0; @@ -132,6 +141,17 @@ export default class M3U8Parser { hasSessionData = true; sessionData[sessionAttrs['DATA-ID']] = sessionAttrs; } + } else if (result[4]) { + // '#EXT-X-SESSION-KEY' is found + const keyTag = result[4]; + const sessionKey = parseKey(keyTag, baseurl); + if (sessionKey.encrypted && sessionKey.isSupported()) { + sessionKeys.push(sessionKey); + } else { + logger.warn( + `[Keys] Ignoring invalid EXT-X-SESSION-KEY tag: "${keyTag}"` + ); + } } } // Filter out levels with unknown codecs if it does not remove all levels @@ -142,6 +162,7 @@ export default class M3U8Parser { return { levels: stripUnknownCodecLevels ? levelsWithKnownCodecs : levels, sessionData: hasSessionData ? sessionData : null, + sessionKeys: sessionKeys.length ? sessionKeys : null, }; } @@ -378,64 +399,21 @@ export default class M3U8Parser { discontinuityCounter = parseInt(value1); break; case 'KEY': { - // https://tools.ietf.org/html/rfc8216#section-4.3.2.4 - const keyAttrs = new AttrList(value1); - const decryptmethod = keyAttrs.enumeratedString('METHOD'); - const decrypturi = keyAttrs.URI; - const decryptiv = keyAttrs.hexadecimalInteger('IV'); - const decryptkeyformatversions = - keyAttrs.enumeratedString('KEYFORMATVERSIONS'); - // From RFC: This attribute is OPTIONAL; its absence indicates an implicit value of "identity". - const decryptkeyformat = - keyAttrs.enumeratedString('KEYFORMAT') ?? 'identity'; - - if ( - !decryptmethod || - [ - 'NONE', - 'AES-128', - 'ISO-23001-7', - 'SAMPLE-AES', - 'SAMPLE-AES-CENC', - 'SAMPLE-AES-CTR', - ].indexOf(decryptmethod) === -1 - ) { - logger.warn(`[Keys] Ignoring invalid EXT-X-KEY tag: "${value1}"`); - } else { - if (decrypturi && keyAttrs.IV && !decryptiv) { - logger.error(`Invalid IV: ${keyAttrs.IV}`); + const levelKey = parseKey(value1, baseurl); + if (levelKey.isSupported()) { + if (levelKey.method === 'NONE') { + levelkeys = undefined; + break; } - // If decrypturi is a URI with a scheme, then baseurl will be ignored - // No uri is allowed when METHOD is NONE - const resolvedUri = decrypturi - ? M3U8Parser.resolve(decrypturi, baseurl) - : ''; - const keyFormatVersions = ( - decryptkeyformatversions ? decryptkeyformatversions : '1' - ) - .split('/') - .map(Number) - .filter(Number.isFinite); - - if (isKeyTagSupported(decryptkeyformat, decryptmethod)) { - if (decryptmethod === 'NONE') { - levelkeys = undefined; - break; - } - if (!levelkeys) { - levelkeys = {}; - } - if (levelkeys[decryptkeyformat]) { - levelkeys = Object.assign({}, levelkeys); - } - levelkeys[decryptkeyformat] = new LevelKey( - decryptmethod, - resolvedUri, - decryptkeyformat, - keyFormatVersions, - decryptiv - ); + if (!levelkeys) { + levelkeys = {}; } + if (levelkeys[levelKey.keyFormat]) { + levelkeys = Object.assign({}, levelkeys); + } + levelkeys[levelKey.keyFormat] = levelKey; + } else { + logger.warn(`[Keys] Ignoring invalid EXT-X-KEY tag: "${value1}"`); } break; } @@ -600,25 +578,37 @@ export default class M3U8Parser { } } -function isKeyTagSupported( - decryptformat: string, - decryptmethod: string -): boolean { - // If it's Segment encryption or No encryption, just select that key system - if ('AES-128' === decryptmethod || 'NONE' === decryptmethod) { - return true; - } - switch (decryptformat) { - case 'identity': - // maintain support for clear SAMPLE-AES with MPEG-3 TS - return true; - case KeySystemFormats.FAIRPLAY: - case KeySystemFormats.WIDEVINE: - case KeySystemFormats.PLAYREADY: - case KeySystemFormats.CLEARKEY: - return true; +function parseKey(keyTag: string, baseurl: string): LevelKey { + // https://tools.ietf.org/html/rfc8216#section-4.3.2.4 + const keyAttrs = new AttrList(keyTag); + const decryptmethod = keyAttrs.enumeratedString('METHOD') ?? ''; + const decrypturi = keyAttrs.URI; + const decryptiv = keyAttrs.hexadecimalInteger('IV'); + const decryptkeyformatversions = + keyAttrs.enumeratedString('KEYFORMATVERSIONS'); + // From RFC: This attribute is OPTIONAL; its absence indicates an implicit value of "identity". + const decryptkeyformat = keyAttrs.enumeratedString('KEYFORMAT') ?? 'identity'; + + if (decrypturi && keyAttrs.IV && !decryptiv) { + logger.error(`Invalid IV: ${keyAttrs.IV}`); } - return false; + // If decrypturi is a URI with a scheme, then baseurl will be ignored + // No uri is allowed when METHOD is NONE + const resolvedUri = decrypturi ? M3U8Parser.resolve(decrypturi, baseurl) : ''; + const keyFormatVersions = ( + decryptkeyformatversions ? decryptkeyformatversions : '1' + ) + .split('/') + .map(Number) + .filter(Number.isFinite); + + return new LevelKey( + decryptmethod, + resolvedUri, + decryptkeyformat, + keyFormatVersions, + decryptiv + ); } function setCodecs(codecs: Array, level: LevelParsed) { diff --git a/src/loader/playlist-loader.ts b/src/loader/playlist-loader.ts index 406e829a2e9..27d27391a08 100644 --- a/src/loader/playlist-loader.ts +++ b/src/loader/playlist-loader.ts @@ -376,7 +376,10 @@ class PlaylistLoader implements NetworkComponentAPI { const url = getResponseUrl(response, context); - const { levels, sessionData } = M3U8Parser.parseMasterPlaylist(string, url); + const { levels, sessionData, sessionKeys } = M3U8Parser.parseMasterPlaylist( + string, + url + ); if (!levels.length) { this.handleManifestParsingError( response, @@ -457,6 +460,7 @@ class PlaylistLoader implements NetworkComponentAPI { stats, networkDetails, sessionData, + sessionKeys, }); } @@ -513,6 +517,7 @@ class PlaylistLoader implements NetworkComponentAPI { stats, networkDetails, sessionData: null, + sessionKeys: null, }); } diff --git a/src/types/events.ts b/src/types/events.ts index 33860b55f74..64c927a6f0b 100644 --- a/src/types/events.ts +++ b/src/types/events.ts @@ -22,6 +22,7 @@ import type { MetadataSample, UserdataSample } from './demuxer'; import type { AttrList } from '../utils/attr-list'; import type { HlsListeners } from '../events'; import { KeyLoaderInfo } from '../loader/key-loader'; +import { LevelKey } from '../loader/level-key'; export interface MediaAttachingData { media: HTMLMediaElement; @@ -83,6 +84,7 @@ export interface ManifestLoadedData { levels: LevelParsed[]; networkDetails: any; sessionData: Record | null; + sessionKeys: LevelKey[] | null; stats: LoaderStats; subtitles?: MediaPlaylist[]; url: string; @@ -92,6 +94,8 @@ export interface ManifestParsedData { levels: Level[]; audioTracks: MediaPlaylist[]; subtitleTracks: MediaPlaylist[]; + sessionData: Record | null; + sessionKeys: LevelKey[] | null; firstLevel: number; stats: LoaderStats; audio: boolean; diff --git a/src/utils/mediakeys-helper.ts b/src/utils/mediakeys-helper.ts index ca991284d7c..86619219e53 100644 --- a/src/utils/mediakeys-helper.ts +++ b/src/utils/mediakeys-helper.ts @@ -154,22 +154,17 @@ function createMediaKeySystemConfigurations( sessionTypes: drmSystemOptions.sessionTypes || [ drmSystemOptions.sessionType || 'temporary', ], - audioCapabilities: [], - videoCapabilities: [], - }; - - audioCodecs.forEach((codec) => { - baseConfig.audioCapabilities!.push({ + audioCapabilities: audioCodecs.map((codec) => ({ contentType: `audio/mp4; codecs="${codec}"`, robustness: drmSystemOptions.audioRobustness || '', - }); - }); - videoCodecs.forEach((codec) => { - baseConfig.videoCapabilities!.push({ + encryptionScheme: drmSystemOptions.audioEncryptionScheme || null, + })), + videoCapabilities: videoCodecs.map((codec) => ({ contentType: `video/mp4; codecs="${codec}"`, robustness: drmSystemOptions.videoRobustness || '', - }); - }); + encryptionScheme: drmSystemOptions.videoEncryptionScheme || null, + })), + }; return [baseConfig]; } diff --git a/tests/unit/controller/level-controller.ts b/tests/unit/controller/level-controller.ts index 8f259d3a6c4..0a9b531750b 100644 --- a/tests/unit/controller/level-controller.ts +++ b/tests/unit/controller/level-controller.ts @@ -101,7 +101,8 @@ describe('LevelController', function () { }), ], networkDetails: '', - sessionData: {}, + sessionData: null, + sessionKeys: null, stats: {} as any, subtitles: [], url: 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8', @@ -189,7 +190,8 @@ describe('LevelController', function () { ], networkDetails: '', subtitles: [], - sessionData: {}, + sessionData: null, + sessionKeys: null, stats: {} as any, url: 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8', }; @@ -200,6 +202,8 @@ describe('LevelController', function () { levels: data.levels.map((levelParsed) => new Level(levelParsed)), audioTracks: [], subtitleTracks: [], + sessionData: null, + sessionKeys: null, firstLevel: 0, stats: {}, audio: false, @@ -224,7 +228,8 @@ describe('LevelController', function () { ], networkDetails: '', subtitles: [], - sessionData: {}, + sessionData: null, + sessionKeys: null, stats: {} as any, url: 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8', }; @@ -233,6 +238,8 @@ describe('LevelController', function () { levels: data.levels.map((levelParsed) => new Level(levelParsed)), audioTracks: data.audioTracks, subtitleTracks: [], + sessionData: null, + sessionKeys: null, firstLevel: 0, stats: {} as any, audio: false, @@ -262,7 +269,8 @@ describe('LevelController', function () { ], networkDetails: '', subtitles: [], - sessionData: {}, + sessionData: null, + sessionKeys: null, stats: {} as any, url: 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8', }; @@ -272,6 +280,8 @@ describe('LevelController', function () { levels: data.levels.map((levelParsed) => new Level(levelParsed)), audioTracks: data.audioTracks, subtitleTracks: [], + sessionData: null, + sessionKeys: null, firstLevel: 0, stats: {}, audio: true, @@ -295,7 +305,8 @@ describe('LevelController', function () { ], networkDetails: '', subtitles: [], - sessionData: {}, + sessionData: null, + sessionKeys: null, stats: {} as any, url: 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8', }; @@ -305,6 +316,8 @@ describe('LevelController', function () { levels: data.levels.map((levelParsed) => new Level(levelParsed)), audioTracks: data.audioTracks, subtitleTracks: [], + sessionData: null, + sessionKeys: null, firstLevel: 0, stats: {}, audio: true, @@ -327,7 +340,8 @@ describe('LevelController', function () { ], networkDetails: '', subtitles: [], - sessionData: {}, + sessionData: null, + sessionKeys: null, stats: {} as any, url: 'foo', }; diff --git a/tests/unit/loader/playlist-loader.ts b/tests/unit/loader/playlist-loader.ts index db72ba6ecab..9b505fe06ee 100644 --- a/tests/unit/loader/playlist-loader.ts +++ b/tests/unit/loader/playlist-loader.ts @@ -1810,6 +1810,57 @@ media_1638278.m4s`; .which.is.an('array') .which.has.members([result.fragments[2], result.fragments[6]]); }); + + it('parses manifest with EXT-X-SESSION-KEYs', function () { + const manifest = `#EXTM3U +#EXT-X-SESSION-DATA:DATA-ID="key",VALUE="value" + +#EXT-X-SESSION-KEY:METHOD=SAMPLE-AES,URI="skd://a",KEYFORMAT="com.apple.streamingkeydelivery",KEYFORMATVERSIONS="1" + +#EXT-X-SESSION-KEY:METHOD=SAMPLE-AES,URI="data:text/plain;base64,YQo=",KEYFORMAT="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed",KEYFORMATVERSIONS="1" + +#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=836280,CODECS="mp4a.40.2,avc1.64001f",RESOLUTION=848x360,NAME="480" +http://proxy-62.x.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/282/158282701_mp4_h264_aac_hq.m3u8#cell=core + +#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=836280,CODECS="mp4a.40.2,avc1.64001f",RESOLUTION=848x360,NAME="480" +http://proxy-21.x.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/282/158282701_mp4_h264_aac_hq.m3u8#cell=core`; + + const result = M3U8Parser.parseMasterPlaylist(manifest, 'http://www.x.com'); + expect(result.sessionData).to.deep.equal({ + key: new AttrList({ + 'DATA-ID': 'key', + VALUE: 'value', + }), + }); + expect(result.sessionKeys) + .to.be.an('array') + .with.property('length') + .which.equals(2); + // enforce type + if (result.sessionKeys === null) { + expect(result.sessionKeys).to.not.be.null; + return; + } + expect(result.sessionKeys[0]) + .to.have.property('uri') + .which.equals('skd://a'); + expect(result.sessionKeys[0]) + .to.have.property('method') + .which.equals('SAMPLE-AES'); + expect(result.sessionKeys[0]) + .to.have.property('keyFormat') + .which.equals('com.apple.streamingkeydelivery'); + expect(result.sessionKeys[1]) + .to.have.property('uri') + .which.equals('data:text/plain;base64,YQo='); + expect(result.sessionKeys[1]) + .to.have.property('method') + .which.equals('SAMPLE-AES'); + expect(result.sessionKeys[1]) + .to.have.property('keyFormat') + .which.equals('urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed'); + expect(result.levels.length).to.equal(2); + }); }); function expectWithJSONMessage(value: any, msg?: string) { From 480ff270b0726691d2c463c92d56f4336c00d52c Mon Sep 17 00:00:00 2001 From: Rob Walch Date: Thu, 1 Dec 2022 16:46:04 -0800 Subject: [PATCH 14/19] Throw before licenseXhrSetup if key was removed --- src/controller/eme-controller.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/controller/eme-controller.ts b/src/controller/eme-controller.ts index e35f002a00d..fff0340eca7 100644 --- a/src/controller/eme-controller.ts +++ b/src/controller/eme-controller.ts @@ -889,6 +889,9 @@ class EMEController implements ComponentAPI { return Promise.resolve() .then(() => { + if (!keysListItem.decryptdata) { + throw new Error('Key removed'); + } return licenseXhrSetup.call( this.hls, xhr, From f46950af66b9580d0e321fcd4974b47d25bc10da Mon Sep 17 00:00:00 2001 From: Rob Walch Date: Fri, 2 Dec 2022 16:34:21 -0800 Subject: [PATCH 15/19] Make key session promise chain more consice --- src/controller/eme-controller.ts | 146 +++++++++++------------- tests/unit/controller/eme-controller.ts | 4 +- 2 files changed, 66 insertions(+), 84 deletions(-) diff --git a/src/controller/eme-controller.ts b/src/controller/eme-controller.ts index fff0340eca7..38961bb498b 100644 --- a/src/controller/eme-controller.ts +++ b/src/controller/eme-controller.ts @@ -23,6 +23,9 @@ import { strToUtf8array } from '../utils/keysystem-util'; import { utf8ArrayToStr } from '../demux/id3'; import { base64Decode, base64Encode } from '../utils/numeric-encoding-utils'; import { DecryptData, LevelKey } from '../loader/level-key'; +import Hex from '../utils/hex'; +import { parsePssh } from '../utils/mp4-tools'; +import EventEmitter from 'eventemitter3'; import type Hls from '../hls'; import type { ComponentAPI } from '../types/component-api'; import type { @@ -33,8 +36,6 @@ import type { } from '../types/events'; import type { EMEControllerConfig } from '../config'; import type { Fragment } from '../loader/fragment'; -import Hex from '../utils/hex'; -import { parsePssh } from '../utils/mp4-tools'; const MAX_LICENSE_REQUEST_FAILURES = 3; const LOGGER_PREFIX = '[eme]'; @@ -617,38 +618,64 @@ class EMEController implements ComponentAPI { })` ); - const licensedPromise = new Promise((resolve, reject) => { - context.mediaKeysSession.onmessage = (event: MediaKeyMessageEvent) => { - if (!context.mediaKeysSession) { - return reject(new Error('invalid state')); - } - const { messageType, message } = event; - this.log( - `"${messageType}" message event for session "${context.mediaKeysSession.sessionId}" message size: ${message.byteLength}` - ); - if ( - messageType === 'license-request' || - messageType === 'license-renewal' - ) { - this.renewLicense(context, message).then(resolve).catch(reject); - } else { - this.warn(`unhandled media key message type "${messageType}"`); + const licenseStatus = new EventEmitter(); + + context.mediaKeysSession.onmessage = (event: MediaKeyMessageEvent) => { + const keySession = context.mediaKeysSession; + if (!keySession) { + licenseStatus.emit('error', new Error('invalid state')); + return; + } + const { messageType, message } = event; + this.log( + `"${messageType}" message event for session "${keySession.sessionId}" message size: ${message.byteLength}` + ); + if ( + messageType === 'license-request' || + messageType === 'license-renewal' + ) { + this.renewLicense(context, message).catch((error) => { + if ('data' in error) { + // We can fail to retrieve a new license and still continue, future key requests may succeed. + error.data.fatal = false; + } + this.handleError(error); + licenseStatus.emit('error', error); + }); + } else if (messageType === 'license-release') { + if (context.keySystem === KeySystems.FAIRPLAY) { + this.updateKeySession(context, strToUtf8array('acknowledged')); + this.removeSession(context); } - }; - }); + } else { + this.warn(`unhandled media key message type "${messageType}"`); + } + }; + + context.mediaKeysSession.onkeystatuseschange = ( + event: MediaKeyMessageEvent + ) => { + const keySession = context.mediaKeysSession; + if (!keySession) { + licenseStatus.emit('error', new Error('invalid state')); + return; + } + this.onKeyStatusChange(context); + const keyStatus = context.keyStatus; + licenseStatus.emit('keyStatus', keyStatus); + if (keyStatus === 'expired') { + this.warn(`${context.keySystem} expired for key ${keyId}`); + this.renewKeySession(context); + } + }; const keyUsablePromise = new Promise( - (resolve: (value: MediaKeySessionContext) => void, reject) => { - context.mediaKeysSession.onkeystatuseschange = ( - event: MediaKeyMessageEvent - ) => { - if (!context.mediaKeysSession) { - return reject(new Error('invalid state')); - } - this.onKeyStatusChange(context); - const keyStatus = context.keyStatus; + (resolve: (value?: void) => void, reject) => { + licenseStatus.on('error', reject); + + licenseStatus.on('keyStatus', (keyStatus) => { if (keyStatus.startsWith('usable')) { - resolve(context); + resolve(); } else if (keyStatus === 'output-restricted') { reject( new EMEKeyError( @@ -676,7 +703,7 @@ class EMEController implements ComponentAPI { } else { this.warn(`unhandled key status change "${keyStatus}"`); } - }; + }); } ); @@ -686,7 +713,6 @@ class EMEController implements ComponentAPI { this.log( `Request generated for key-session "${context.mediaKeysSession?.sessionId}" keyId: ${keyId}` ); - return context; }) .catch((error) => { throw new EMEKeyError( @@ -699,60 +725,15 @@ class EMEController implements ComponentAPI { `Error generating key-session request: ${error}` ); }) - .then(() => licensedPromise) .then(() => keyUsablePromise) .catch((error) => { + licenseStatus.removeAllListeners(); this.removeSession(context); throw error; }) - .then((mediaKeySessionContext) => { - context.mediaKeysSession.onmessage = (event: MediaKeyMessageEvent) => { - const keySession = mediaKeySessionContext.mediaKeysSession; - if (keySession) { - const { messageType, message } = event; - this.log( - `"${messageType}" message event for session "${mediaKeySessionContext.mediaKeysSession.sessionId}" message size: ${message.byteLength}` - ); - if ( - messageType === 'license-request' || - messageType === 'license-renewal' - ) { - this.renewLicense(context, message).catch((error) => { - if ('data' in error) { - // We can fail to retrieve a new license and still continue, future key requests may succeed. - error.data.fatal = false; - } - this.handleError(error); - }); - } else if (messageType === 'license-release') { - if (mediaKeySessionContext.keySystem === KeySystems.FAIRPLAY) { - this.updateKeySession( - mediaKeySessionContext, - strToUtf8array('acknowledged') - ); - this.removeSession(mediaKeySessionContext); - } - } else { - this.warn(`unhandled media key message type "${messageType}"`); - } - } - }; - mediaKeySessionContext.mediaKeysSession.onkeystatuseschange = ( - event: MediaKeyMessageEvent - ) => { - const keySession = mediaKeySessionContext.mediaKeysSession; - if (keySession) { - this.onKeyStatusChange(mediaKeySessionContext); - const keyStatus = mediaKeySessionContext.keyStatus; - if (keyStatus === 'expired') { - this.warn( - `${mediaKeySessionContext.keySystem} expired for key ${keyId}` - ); - this.renewKeySession(mediaKeySessionContext); - } - } - }; - return mediaKeySessionContext; + .then(() => { + licenseStatus.removeAllListeners(); + return context; }); } @@ -970,7 +951,8 @@ class EMEController implements ComponentAPI { ); this._requestLicenseFailureCount++; if ( - this._requestLicenseFailureCount > MAX_LICENSE_REQUEST_FAILURES + this._requestLicenseFailureCount > MAX_LICENSE_REQUEST_FAILURES || + (xhr.status >= 400 && xhr.status < 500) ) { reject( new EMEKeyError( diff --git a/tests/unit/controller/eme-controller.ts b/tests/unit/controller/eme-controller.ts index 6dc4801c2ed..7eee5de8954 100644 --- a/tests/unit/controller/eme-controller.ts +++ b/tests/unit/controller/eme-controller.ts @@ -360,10 +360,10 @@ describe('EMEController', function () { xhr: sinon.SinonFakeXMLHttpRequest ) => { xhrInstance = xhr; - self.setTimeout(() => { + Promise.resolve().then(() => { (xhr as any).response = new Uint8Array(); xhr.respond(200, {}, ''); - }, 0); + }); }; emeController.onMediaAttached(Events.MEDIA_ATTACHED, { From 497f17da522c992c46c0a98b9f957f86b0414399 Mon Sep 17 00:00:00 2001 From: Rob Walch Date: Fri, 2 Dec 2022 18:23:49 -0800 Subject: [PATCH 16/19] Stop on fatal key system errors --- src/controller/base-playlist-controller.ts | 8 ++++++-- src/controller/eme-controller.ts | 4 ---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/controller/base-playlist-controller.ts b/src/controller/base-playlist-controller.ts index 00c09e443c3..fffffdf37c6 100644 --- a/src/controller/base-playlist-controller.ts +++ b/src/controller/base-playlist-controller.ts @@ -36,8 +36,12 @@ export default class BasePlaylistController implements NetworkComponentAPI { } protected onError(event: Events.ERROR, data: ErrorData): void { - if (data.fatal && data.type === ErrorTypes.NETWORK_ERROR) { - this.clearTimer(); + if ( + data.fatal && + (data.type === ErrorTypes.NETWORK_ERROR || + data.type === ErrorTypes.KEY_SYSTEM_ERROR) + ) { + this.stopLoad(); } } diff --git a/src/controller/eme-controller.ts b/src/controller/eme-controller.ts index 38961bb498b..e9daaff2a5a 100644 --- a/src/controller/eme-controller.ts +++ b/src/controller/eme-controller.ts @@ -635,10 +635,6 @@ class EMEController implements ComponentAPI { messageType === 'license-renewal' ) { this.renewLicense(context, message).catch((error) => { - if ('data' in error) { - // We can fail to retrieve a new license and still continue, future key requests may succeed. - error.data.fatal = false; - } this.handleError(error); licenseStatus.emit('error', error); }); From 78dadb77bf557789300278c097f66148c31c35a8 Mon Sep 17 00:00:00 2001 From: Rob Walch Date: Fri, 9 Dec 2022 18:21:31 -0800 Subject: [PATCH 17/19] Only request access to key-systems for keys matching those found in the config one at time (to avoid gaining access to WV and PR on Edge) --- src/controller/eme-controller.ts | 60 +++++++++++++++++--------------- 1 file changed, 31 insertions(+), 29 deletions(-) diff --git a/src/controller/eme-controller.ts b/src/controller/eme-controller.ts index e9daaff2a5a..c106fc9aa34 100644 --- a/src/controller/eme-controller.ts +++ b/src/controller/eme-controller.ts @@ -169,32 +169,31 @@ class EMEController implements ComponentAPI { }) => void, reject: (Error) => void ) => { - let attempts = 0; - const catchAll = (error) => { - attempts++; - if (attempts === keySystemsToAttempt.length) { - if (error instanceof EMEKeyError) { - reject(error); - } else { - reject( - new EMEKeyError( - { - type: ErrorTypes.KEY_SYSTEM_ERROR, - details: ErrorDetails.KEY_SYSTEM_NO_ACCESS, - error, - fatal: true, - }, - error.message - ) - ); - } - } - }; - keySystemsToAttempt.forEach((keySystem) => { + const attempt = (keySystems) => { + const keySystem = keySystems.shift(); this.getMediaKeysPromise(keySystem, audioCodecs, videoCodecs) .then((mediaKeys) => resolve({ keySystem, mediaKeys })) - .catch(catchAll); - }); + .catch((error) => { + if (keySystems.length) { + attempt(keySystems); + } else if (error instanceof EMEKeyError) { + reject(error); + } else { + reject( + new EMEKeyError( + { + type: ErrorTypes.KEY_SYSTEM_ERROR, + details: ErrorDetails.KEY_SYSTEM_NO_ACCESS, + error, + fatal: true, + }, + error.message + ) + ); + } + }); + }; + attempt(keySystemsToAttempt); } ); } @@ -395,11 +394,14 @@ class EMEController implements ComponentAPI { private getKeyFormatPromise(keyFormats: string[]): Promise { return new Promise((resolve, reject) => { + const keySystemsInConfig = getKeySystemsForConfig(this.config); const keySystemsToAttempt = keyFormats .map(keySystemFormatToKeySystemDomain) - .filter((value) => !!value) as any as KeySystems[]; - return this.getKeySystemSelectionPromise(keySystemsToAttempt).then( - ({ keySystem }) => { + .filter( + (value) => !!value && keySystemsInConfig.indexOf(value) !== -1 + ) as any as KeySystems[]; + return this.getKeySystemSelectionPromise(keySystemsToAttempt) + .then(({ keySystem }) => { const keySystemFormat = keySystemToKeySystemFormat(keySystem); if (keySystemFormat) { resolve(keySystemFormat); @@ -408,8 +410,8 @@ class EMEController implements ComponentAPI { new Error(`Unable to find format for key-system "${keySystem}"`) ); } - } - ); + }) + .catch(reject); }); } From d3eefb76c337937dbd47436b3b003144b21eba74 Mon Sep 17 00:00:00 2001 From: Rob Walch Date: Wed, 14 Dec 2022 13:07:48 -0800 Subject: [PATCH 18/19] Modify key-system helpers so that it's easier to support additional key-system strings --- src/controller/eme-controller.ts | 22 +++++++----- src/loader/key-loader.ts | 2 +- src/loader/level-key.ts | 21 ++++++++---- src/utils/mediakeys-helper.ts | 58 +++++++++++++++----------------- 4 files changed, 57 insertions(+), 46 deletions(-) diff --git a/src/controller/eme-controller.ts b/src/controller/eme-controller.ts index c106fc9aa34..56f3c8be281 100644 --- a/src/controller/eme-controller.ts +++ b/src/controller/eme-controller.ts @@ -325,10 +325,11 @@ class EMEController implements ComponentAPI { mediaKeySessionContext ); const keyId = this.getKeyIdString(decryptdata); + const scheme = 'cenc'; this.keyIdToKeySessionPromise[keyId] = this.generateRequestWithPreferredKeySession( keySessionContext, - 'cenc', + scheme, decryptdata.pssh ); this.removeSession(mediaKeySessionContext); @@ -380,7 +381,7 @@ class EMEController implements ComponentAPI { } public selectKeySystemFormat(frag: Fragment): Promise { - const keyFormats = Object.keys(frag.levelkeys || {}); + const keyFormats = Object.keys(frag.levelkeys || {}) as KeySystemFormats[]; if (!this.keyFormatPromise) { this.log( `Selecting key-system from fragment (sn: ${frag.sn} ${frag.type}: ${ @@ -392,7 +393,9 @@ class EMEController implements ComponentAPI { return this.keyFormatPromise; } - private getKeyFormatPromise(keyFormats: string[]): Promise { + private getKeyFormatPromise( + keyFormats: KeySystemFormats[] + ): Promise { return new Promise((resolve, reject) => { const keySystemsInConfig = getKeySystemsForConfig(this.config); const keySystemsToAttempt = keyFormats @@ -440,9 +443,10 @@ class EMEController implements ComponentAPI { mediaKeys, decryptdata, }); + const scheme = 'cenc'; return this.generateRequestWithPreferredKeySession( keySessionContext, - 'cenc', + scheme, decryptdata.pssh ); }); @@ -1104,10 +1108,12 @@ class EMEController implements ComponentAPI { return; } if (!this.keyFormatPromise) { - const keyFormats = sessionKeys.reduce( - (formats: string[], sessionKey: LevelKey) => { - if (formats.indexOf(sessionKey.keyFormat) === -1) { - formats.push(sessionKey.keyFormat); + const keyFormats: KeySystemFormats[] = sessionKeys.reduce( + (formats: KeySystemFormats[], sessionKey: LevelKey) => { + if ( + formats.indexOf(sessionKey.keyFormat as KeySystemFormats) === -1 + ) { + formats.push(sessionKey.keyFormat as KeySystemFormats); } return formats; }, diff --git a/src/loader/key-loader.ts b/src/loader/key-loader.ts index d2b33c2a599..b193d7d5bd5 100644 --- a/src/loader/key-loader.ts +++ b/src/loader/key-loader.ts @@ -179,7 +179,7 @@ export default class KeyLoader implements ComponentAPI { case 'SAMPLE-AES-CENC': case 'SAMPLE-AES-CTR': if (decryptdata.keyFormat === 'identity') { - // loadKeyHTTP handles data URLs + // loadKeyHTTP handles http(s) and data URLs return this.loadKeyHTTP(keyInfo, frag); } return this.loadKeyEME(keyInfo, frag); diff --git a/src/loader/level-key.ts b/src/loader/level-key.ts index d8e0133cfd8..8a7a6e18e31 100644 --- a/src/loader/level-key.ts +++ b/src/loader/level-key.ts @@ -175,6 +175,16 @@ export class LevelKey implements DecryptData { } break; } + default: { + let keydata = keyBytes.subarray(0, 16); + if (keydata.length !== 16) { + const padded = new Uint8Array(16); + padded.set(keydata, 16 - keydata.length); + keydata = padded; + } + this.keyId = keydata; + break; + } } } @@ -257,15 +267,14 @@ function getFairPlayV3Pssh( const fpsk = mp4Box.apply(null, args as [ArrayLike, Uint8Array]); return fpsk; }; - const args = [ - FpsBoxTypes.fpsd, - makeFpsKeySystemInfoBox(scheme), - makeFpsKeyRequestBox(keyId, keyFormatVersions), - ]; - const data = mp4Box.apply(null, args as [ArrayLike, Uint8Array]); const kFairPlayStreamingKeySystemUUID = new Uint8Array([ 0x94, 0xce, 0x86, 0xfb, 0x07, 0xff, 0x4f, 0x43, 0xad, 0xb8, 0x93, 0xd2, 0xfa, 0x96, 0x8c, 0xa2, ]); + const data = mp4Box( + FpsBoxTypes.fpsd, + makeFpsKeySystemInfoBox(scheme), + makeFpsKeyRequestBox(keyId, keyFormatVersions) + ); return mp4pssh(kFairPlayStreamingKeySystemUUID, null, data); } diff --git a/src/utils/mediakeys-helper.ts b/src/utils/mediakeys-helper.ts index 86619219e53..b00dad42d9b 100644 --- a/src/utils/mediakeys-helper.ts +++ b/src/utils/mediakeys-helper.ts @@ -21,14 +21,15 @@ export enum KeySystemFormats { export function keySystemFormatToKeySystemDomain( format: KeySystemFormats ): KeySystems | undefined { - if (format === KeySystemFormats.FAIRPLAY) { - return KeySystems.FAIRPLAY; - } else if (format === KeySystemFormats.PLAYREADY) { - return KeySystems.PLAYREADY; - } else if (format === KeySystemFormats.WIDEVINE) { - return KeySystems.WIDEVINE; - } else if (format === KeySystemFormats.CLEARKEY) { - return KeySystems.CLEARKEY; + switch (format) { + case KeySystemFormats.FAIRPLAY: + return KeySystems.FAIRPLAY; + case KeySystemFormats.PLAYREADY: + return KeySystems.PLAYREADY; + case KeySystemFormats.WIDEVINE: + return KeySystems.WIDEVINE; + case KeySystemFormats.CLEARKEY: + return KeySystems.CLEARKEY; } } @@ -56,14 +57,15 @@ export function keySystemIdToKeySystemDomain( export function keySystemDomainToKeySystemFormat( keySystem: KeySystems ): KeySystemFormats | undefined { - if (keySystem === KeySystems.FAIRPLAY) { - return KeySystemFormats.FAIRPLAY; - } else if (keySystem === KeySystems.PLAYREADY) { - return KeySystemFormats.PLAYREADY; - } else if (keySystem === KeySystems.WIDEVINE) { - return KeySystemFormats.WIDEVINE; - } else if (keySystem === KeySystems.CLEARKEY) { - return KeySystemFormats.CLEARKEY; + switch (keySystem) { + case KeySystems.FAIRPLAY: + return KeySystemFormats.FAIRPLAY; + case KeySystems.PLAYREADY: + return KeySystemFormats.PLAYREADY; + case KeySystems.WIDEVINE: + return KeySystemFormats.WIDEVINE; + case KeySystems.CLEARKEY: + return KeySystemFormats.CLEARKEY; } } @@ -71,22 +73,16 @@ export function getKeySystemsForConfig( config: EMEControllerConfig ): KeySystems[] { const { drmSystems, widevineLicenseUrl } = config; - const keySystemsToAttempt: KeySystems[] = []; - [KeySystems.FAIRPLAY, KeySystems.PLAYREADY, KeySystems.CLEARKEY].forEach( - (keySystem) => { - if (drmSystems?.[keySystem]) { - keySystemsToAttempt.push(keySystem); - } - } - ); - if (widevineLicenseUrl || drmSystems?.[KeySystems.WIDEVINE]) { + const keySystemsToAttempt: KeySystems[] = drmSystems + ? [ + KeySystems.FAIRPLAY, + KeySystems.WIDEVINE, + KeySystems.PLAYREADY, + KeySystems.CLEARKEY, + ].filter((keySystem) => !!drmSystems[keySystem]) + : []; + if (!keySystemsToAttempt[KeySystems.WIDEVINE] && widevineLicenseUrl) { keySystemsToAttempt.push(KeySystems.WIDEVINE); - } else if (keySystemsToAttempt.length === 0) { - keySystemsToAttempt.push( - KeySystems.WIDEVINE, - KeySystems.FAIRPLAY, - KeySystems.PLAYREADY - ); } return keySystemsToAttempt; } From adea6fe54a607892a163fd6191c6242eeddfb284 Mon Sep 17 00:00:00 2001 From: Rob Walch Date: Wed, 14 Dec 2022 19:21:30 -0800 Subject: [PATCH 19/19] Add undocumented `generateRequest` ("Content ID") filter --- api-extractor/report/hls.js.api.md | 28 ++++++++++++++-------------- src/config.ts | 6 ++++++ src/controller/eme-controller.ts | 21 ++++++++++++++++++++- 3 files changed, 40 insertions(+), 15 deletions(-) diff --git a/api-extractor/report/hls.js.api.md b/api-extractor/report/hls.js.api.md index a9bab59499e..60795b77d06 100644 --- a/api-extractor/report/hls.js.api.md +++ b/api-extractor/report/hls.js.api.md @@ -2293,20 +2293,20 @@ export interface UserdataSample { // Warnings were encountered during analysis: // -// src/config.ts:81:3 - (ae-forgotten-export) The symbol "MediaKeySessionContext" needs to be exported by the entry point hls.d.ts -// src/config.ts:96:3 - (ae-forgotten-export) The symbol "DRMSystemsConfiguration" needs to be exported by the entry point hls.d.ts -// src/config.ts:199:3 - (ae-forgotten-export) The symbol "ILogger" needs to be exported by the entry point hls.d.ts -// src/config.ts:209:3 - (ae-forgotten-export) The symbol "AudioStreamController" needs to be exported by the entry point hls.d.ts -// src/config.ts:210:3 - (ae-forgotten-export) The symbol "AudioTrackController" needs to be exported by the entry point hls.d.ts -// src/config.ts:212:3 - (ae-forgotten-export) The symbol "SubtitleStreamController" needs to be exported by the entry point hls.d.ts -// src/config.ts:213:3 - (ae-forgotten-export) The symbol "SubtitleTrackController" needs to be exported by the entry point hls.d.ts -// src/config.ts:214:3 - (ae-forgotten-export) The symbol "TimelineController" needs to be exported by the entry point hls.d.ts -// src/config.ts:216:3 - (ae-forgotten-export) The symbol "EMEController" needs to be exported by the entry point hls.d.ts -// src/config.ts:219:3 - (ae-forgotten-export) The symbol "CMCDController" needs to be exported by the entry point hls.d.ts -// src/config.ts:221:3 - (ae-forgotten-export) The symbol "AbrController" needs to be exported by the entry point hls.d.ts -// src/config.ts:222:3 - (ae-forgotten-export) The symbol "BufferController" needs to be exported by the entry point hls.d.ts -// src/config.ts:223:3 - (ae-forgotten-export) The symbol "CapLevelController" needs to be exported by the entry point hls.d.ts -// src/config.ts:224:3 - (ae-forgotten-export) The symbol "FPSController" needs to be exported by the entry point hls.d.ts +// src/config.ts:87:3 - (ae-forgotten-export) The symbol "MediaKeySessionContext" needs to be exported by the entry point hls.d.ts +// src/config.ts:102:3 - (ae-forgotten-export) The symbol "DRMSystemsConfiguration" needs to be exported by the entry point hls.d.ts +// src/config.ts:205:3 - (ae-forgotten-export) The symbol "ILogger" needs to be exported by the entry point hls.d.ts +// src/config.ts:215:3 - (ae-forgotten-export) The symbol "AudioStreamController" needs to be exported by the entry point hls.d.ts +// src/config.ts:216:3 - (ae-forgotten-export) The symbol "AudioTrackController" needs to be exported by the entry point hls.d.ts +// src/config.ts:218:3 - (ae-forgotten-export) The symbol "SubtitleStreamController" needs to be exported by the entry point hls.d.ts +// src/config.ts:219:3 - (ae-forgotten-export) The symbol "SubtitleTrackController" needs to be exported by the entry point hls.d.ts +// src/config.ts:220:3 - (ae-forgotten-export) The symbol "TimelineController" needs to be exported by the entry point hls.d.ts +// src/config.ts:222:3 - (ae-forgotten-export) The symbol "EMEController" needs to be exported by the entry point hls.d.ts +// src/config.ts:225:3 - (ae-forgotten-export) The symbol "CMCDController" needs to be exported by the entry point hls.d.ts +// src/config.ts:227:3 - (ae-forgotten-export) The symbol "AbrController" needs to be exported by the entry point hls.d.ts +// src/config.ts:228:3 - (ae-forgotten-export) The symbol "BufferController" needs to be exported by the entry point hls.d.ts +// src/config.ts:229:3 - (ae-forgotten-export) The symbol "CapLevelController" needs to be exported by the entry point hls.d.ts +// src/config.ts:230: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/src/config.ts b/src/config.ts index e9960f3d05b..4cac855ae70 100644 --- a/src/config.ts +++ b/src/config.ts @@ -71,6 +71,12 @@ export type DRMSystemOptions = { export type DRMSystemConfiguration = { licenseUrl: string; serverCertificateUrl?: string; + generateRequest?: ( + this: Hls, + initDataType: string, + initData: ArrayBuffer, + keyContext: MediaKeySessionContext + ) => { initDataType: string; initData: ArrayBuffer }; }; export type DRMSystemsConfiguration = Partial< diff --git a/src/controller/eme-controller.ts b/src/controller/eme-controller.ts index 56f3c8be281..b064b5452f6 100644 --- a/src/controller/eme-controller.ts +++ b/src/controller/eme-controller.ts @@ -606,6 +606,23 @@ class EMEController implements ComponentAPI { initDataType: string, initData: ArrayBuffer | null ): Promise | never { + const generateRequestFilter = + this.config.drmSystems?.[context.keySystem]?.generateRequest; + if (generateRequestFilter) { + try { + const mappedInitData = generateRequestFilter.call( + this.hls, + initDataType, + initData, + context + ); + initDataType = mappedInitData.initDataType; + initData = mappedInitData.initData; + } catch (error) { + this.error(error); + } + } + if (!initData) { throw new EMEKeyError( { @@ -1024,7 +1041,9 @@ class EMEController implements ComponentAPI { } } } catch (error) { - this.warn('got unexpected license-request format'); + this.warn( + `Failed to extract spc from FairPlay license-request message. Fallback to message data for key uri: "${keySessionContext.decryptdata.uri}"` + ); } } return message;