diff --git a/README.md b/README.md index 125d8a06e71..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 @@ -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,9 @@ 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, 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)) ### 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..60795b77d06 100644 --- a/api-extractor/report/hls.js.api.md +++ b/api-extractor/report/hls.js.api.md @@ -313,6 +313,12 @@ export class DateRange { export type DRMSystemOptions = { audioRobustness?: string; videoRobustness?: string; + audioEncryptionScheme?: string | null; + videoEncryptionScheme?: string | null; + 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) @@ -352,10 +358,11 @@ export enum ElementaryStreamTypes { // // @public (undocumented) export type EMEControllerConfig = { - licenseXhrSetup?: (xhr: XMLHttpRequest, url: string) => void; - licenseResponseCallback?: (xhr: XMLHttpRequest, url: string) => 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; widevineLicenseUrl?: string; + drmSystems: DRMSystemsConfiguration; drmSystemOptions: DRMSystemOptions; requestMediaKeySystemAccessFunc: MediaKeyFunc | null; }; @@ -369,6 +376,8 @@ export interface ErrorData { // (undocumented) bytes?: number; // (undocumented) + chunkMeta?: ChunkMetadata; + // (undocumented) context?: PlaylistLoaderContext; // (undocumented) details: ErrorDetails; @@ -457,6 +466,16 @@ 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) + KEY_SYSTEM_STATUS_INTERNAL_ERROR = "keySystemStatusInternalError", + // (undocumented) + KEY_SYSTEM_STATUS_OUTPUT_RESTRICTED = "keySystemStatusOutputRestricted", + // (undocumented) LEVEL_EMPTY_ERROR = "levelEmptyError", // (undocumented) LEVEL_LOAD_ERROR = "levelLoadError", @@ -733,7 +752,6 @@ export class Fragment extends BaseSegment { cc: number; // (undocumented) clearElementaryStreamInfo(): void; - createInitializationVector(segmentNumber: number): Uint8Array; // (undocumented) data?: Uint8Array; // (undocumented) @@ -754,12 +772,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) @@ -770,10 +792,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; @@ -889,7 +912,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); @@ -1221,26 +1244,42 @@ export interface InitPTSFoundData { 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 part of the package's API, 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 part of the package's API, 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) // // @public (undocumented) export enum KeySystems { + // (undocumented) + CLEARKEY = "org.w3.clearkey", + // (undocumented) + FAIRPLAY = "com.apple.fps", // (undocumented) PLAYREADY = "com.microsoft.playready", // (undocumented) @@ -1402,6 +1441,8 @@ export class LevelDetails { // (undocumented) get edge(): number; // (undocumented) + encryptedFragments: Fragment[]; + // (undocumented) endCC: number; // (undocumented) endSN: number; @@ -1471,28 +1512,38 @@ export class LevelDetails { version: number | null; } +// 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 part of the package's API, 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 fromURI(uri: string): LevelKey; + static clearKeyUriToKeyIdMap(): void; // (undocumented) - static fromURL(baseUrl: string, relativeUrl: string): LevelKey; + readonly encrypted: boolean; + // (undocumented) + getDecryptData(sn: number | 'initSegment'): LevelKey | null; + // (undocumented) + readonly isCommonEncryption: boolean; + // (undocumented) + isSupported(): 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) @@ -1792,6 +1843,8 @@ export interface ManifestLoadedData { // (undocumented) sessionData: Record | null; // (undocumented) + sessionKeys: LevelKey[] | null; + // (undocumented) stats: LoaderStats; // (undocumented) subtitles?: MediaPlaylist[]; @@ -1822,6 +1875,10 @@ export interface ManifestParsedData { // (undocumented) levels: Level[]; // (undocumented) + sessionData: Record | null; + // (undocumented) + sessionKeys: LevelKey[] | null; + // (undocumented) stats: LoaderStats; // (undocumented) subtitleTracks: MediaPlaylist[]; @@ -2236,18 +2293,20 @@ 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: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/docs/API.md b/docs/API.md index dd74cd6aa26..da714e5d74e 100644 --- a/docs/API.md +++ b/docs/API.md @@ -94,9 +94,10 @@ - [`abrMaxWithRealBitrate`](#abrmaxwithrealbitrate) - [`minAutoBitrate`](#minautobitrate) - [`emeEnabled`](#emeEnabled) - - [`widevineLicenseUrl`](#widevineLicenseUrl) + - [`widevineLicenseUrl`](#widevineLicenseUrl) (deprecated) - [`licenseXhrSetup`](#licenseXhrSetup) - [`licenseResponseCallback`](#licenseResponseCallback) + - [`drmSystems`](#drmSystems) - [`drmSystemOptions`](#drmSystemOptions) - [`requestMediaKeySystemAccessFunc`](#requestMediaKeySystemAccessFunc) - [`cmcd`](#cmcd) @@ -398,8 +399,8 @@ var config = { maxLoadingDelay: 4, minAutoBitrate: 0, emeEnabled: false, - widevineLicenseUrl: undefined, licenseXhrSetup: undefined, + drmSystems: {}, drmSystemOptions: {}, requestMediaKeySystemAccessFunc: requestMediaKeySystemAccess, cmcd: undefined, @@ -1190,38 +1191,93 @@ 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. -### `widevineLicenseUrl` +### `widevineLicenseUrl` (deprecated) (default: `undefined`) -The Widevine license server URL. +`widevineLicenseUrl` has been deprecated. Use `drmSystems['com.widevine.alpha'].licenseUrl` instead. ### `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. + // 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; + }); }, }; ``` ### `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 key-system to your own DRM provider. `serverCertificateUrl` is not mandatory. Ex: + +```js +drmSystems: { + 'com.widevine.alpha': { + 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: `{}`) @@ -1241,7 +1297,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 27798f2acd3..4cac855ae70 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,8 +17,9 @@ 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 } from './utils/mediakeys-helper'; +import type { MediaKeyFunc, KeySystems } from './utils/mediakeys-helper'; import type { FragmentLoaderContext, Loader, @@ -57,13 +60,46 @@ export type CMCDControllerConfig = { export type DRMSystemOptions = { audioRobustness?: string; videoRobustness?: string; + audioEncryptionScheme?: string | null; + videoEncryptionScheme?: string | null; + persistentState?: MediaKeysRequirement; + distinctiveIdentifier?: MediaKeysRequirement; + sessionTypes?: string[]; + sessionType?: string; }; +export type DRMSystemConfiguration = { + licenseUrl: string; + serverCertificateUrl?: string; + generateRequest?: ( + this: Hls, + initDataType: string, + initData: ArrayBuffer, + keyContext: MediaKeySessionContext + ) => { initDataType: string; initData: ArrayBuffer }; +}; + +export type DRMSystemsConfiguration = Partial< + Record +>; + export type EMEControllerConfig = { - licenseXhrSetup?: (xhr: XMLHttpRequest, url: string) => void; - licenseResponseCallback?: (xhr: XMLHttpRequest, url: string) => 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; widevineLicenseUrl?: string; + drmSystems: DRMSystemsConfiguration; drmSystemOptions: DRMSystemOptions; requestMediaKeySystemAccessFunc: MediaKeyFunc | null; }; @@ -283,6 +319,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/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..e3f572bb454 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(); } @@ -619,9 +624,14 @@ 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: // TODO: Skip fragments that do not belong to this.fragCurrent audio-group id 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/base-stream-controller.ts b/src/controller/base-stream-controller.ts index 20ce34b8cc0..cba4bfca4c2 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,13 +574,15 @@ 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; } }); 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); @@ -609,7 +619,7 @@ export default class BaseStreamController .then((keyLoadedData) => { if ( !keyLoadedData || - this.fragContextChanged(keyLoadedData?.frag) + this.fragContextChanged(keyLoadedData.frag) ) { return null; } @@ -727,11 +737,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; } @@ -739,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; @@ -1292,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) { @@ -1307,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/eme-controller.ts b/src/controller/eme-controller.ts index 3db81779337..b064b5452f6 100644 --- a/src/controller/eme-controller.ts +++ b/src/controller/eme-controller.ts @@ -5,93 +5,54 @@ */ import { Events } from '../events'; import { ErrorTypes, ErrorDetails } from '../errors'; - import { logger } from '../utils/logger'; -import type { DRMSystemOptions, EMEControllerConfig } from '../config'; -import type { MediaKeyFunc } from '../utils/mediakeys-helper'; -import { KeySystems } from '../utils/mediakeys-helper'; +import { + getKeySystemsForConfig, + getSupportedMediaKeySystemConfigurations, + keySystemDomainToKeySystemFormat as keySystemToKeySystemFormat, + KeySystemFormats, + keySystemFormatToKeySystemDomain, + KeySystemIds, + keySystemIdToKeySystemDomain, +} 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 { 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 { MediaAttachedData, ManifestParsedData } from '../types/events'; +import type { + MediaAttachedData, + KeyLoadedData, + ErrorData, + ManifestLoadedData, +} from '../types/events'; +import type { EMEControllerConfig } from '../config'; +import type { Fragment } from '../loader/fragment'; 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 createWidevineMediaKeySystemConfigurations = function ( - audioCodecs: string[], - videoCodecs: string[], - drmSystemOptions: DRMSystemOptions -): MediaKeySystemConfiguration[] { - /* jshint ignore:line */ - const baseConfig: MediaKeySystemConfiguration = { - // initDataTypes: ['keyids', 'mp4'], - // label: "", - // persistentState: "not-allowed", // or "required" ? - // distinctiveIdentifier: "not-allowed", // or "required" ? - // sessionTypes: ['temporary'], - audioCapabilities: [], // { contentType: 'audio/mp4; codecs="mp4a.40.2"' } - videoCapabilities: [], // { contentType: 'video/mp4; codecs="avc1.42E01E"' } - }; - - audioCodecs.forEach((codec) => { - baseConfig.audioCapabilities!.push({ - contentType: `audio/mp4; codecs="${codec}"`, - robustness: drmSystemOptions.audioRobustness || '', - }); - }); - videoCodecs.forEach((codec) => { - baseConfig.videoCapabilities!.push({ - contentType: `video/mp4; codecs="${codec}"`, - robustness: drmSystemOptions.videoRobustness || '', - }); - }); - - return [baseConfig]; -}; - -/** - * The idea here is to handle key-system (and their respective platforms) specific configuration differences - * in order to work with the local requestMediaKeySystemAccess method. - * - * We can also rule-out platform-related key-system support at this point by throwing an error. - * - * @param {string} keySystem Identifier for the key-system, see `KeySystems` enum - * @param {Array} 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 createWidevineMediaKeySystemConfigurations( - 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; } /** @@ -102,76 +63,70 @@ interface MediaKeysListItem { * @constructor */ class EMEController implements ComponentAPI { - private hls: Hls; - private _widevineLicenseUrl?: string; - private _licenseXhrSetup?: (xhr: XMLHttpRequest, url: string) => void; - private _licenseResponseCallback?: ( - xhr: XMLHttpRequest, - url: string - ) => ArrayBuffer; - private _emeEnabled: boolean; - private _requestMediaKeySystemAccess: MediaKeyFunc | null; - private _drmSystemOptions: DRMSystemOptions; - - private _config: EMEControllerConfig; - private _mediaKeysList: MediaKeysListItem[] = []; - private _media: HTMLMediaElement | null = null; - private _hasSetMediaKeys: boolean = false; + 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 mediaKeySessions: MediaKeySessionContext[] = []; + private keyIdToKeySessionPromise: { + [keyId: 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); - private mediaKeysPromise: Promise | null = null; - private _onMediaEncrypted = this.onMediaEncrypted.bind(this); - - /** - * @constructs - * @param {Hls} hls Our Hls.js instance - */ constructor(hls: Hls) { this.hls = hls; - this._config = hls.config; - - this._widevineLicenseUrl = this._config.widevineLicenseUrl; - this._licenseXhrSetup = this._config.licenseXhrSetup; - this._licenseResponseCallback = this._config.licenseResponseCallback; - this._emeEnabled = this._config.emeEnabled; - this._requestMediaKeySystemAccess = - this._config.requestMediaKeySystemAccessFunc; - this._drmSystemOptions = this._config.drmSystemOptions; - - this._registerListeners(); + 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.keyIdToKeySessionPromise = + 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); + this.hls.on(Events.MANIFEST_LOADED, this.onManifestLoaded, 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); + this.hls.off(Events.MANIFEST_LOADED, this.onManifestLoaded, this); } - /** - * @param {string} keySystem Identifier for the key-system, see `KeySystems` enum - * @returns {string} License server URL for key-system (if any configured, otherwise causes error) - * @throws if a unsupported keysystem is passed - */ - getLicenseServerUrl(keySystem: KeySystems): string { - switch (keySystem) { - case KeySystems.WIDEVINE: - if (!this._widevineLicenseUrl) { - break; - } - return this._widevineLicenseUrl; + 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 && widevineLicenseUrl) { + return widevineLicenseUrl; } throw new Error( @@ -179,530 +134,1059 @@ class EMEController implements ComponentAPI { ); } - /** - * 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 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 new Promise( + ( + resolve: (result: { + keySystem: KeySystems; + mediaKeys: MediaKeys; + }) => void, + reject: (Error) => void + ) => { + const attempt = (keySystems) => { + const keySystem = keySystems.shift(); + this.getMediaKeysPromise(keySystem, audioCodecs, videoCodecs) + .then((mediaKeys) => resolve({ keySystem, mediaKeys })) + .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); + } + ); + } + + 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`); - - const mediaKeysListItem: MediaKeysListItem = { - mediaKeysSessionInitialized: false, - mediaKeySystemAccess: mediaKeySystemAccess, - mediaKeySystemDomain: keySystem, - }; + 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}" keyId: ${Hex.hexDump( + decryptdata.keyId! || [] + )}` + ); - this._mediaKeysList.push(mediaKeysListItem); + const mediaKeysSession = mediaKeys.createSession(); - const mediaKeysPromise = Promise.resolve() - .then(() => mediaKeySystemAccess.createMediaKeys()) - .then((mediaKeys) => { - mediaKeysListItem.mediaKeys = mediaKeys; + const mediaKeySessionContext: MediaKeySessionContext = { + decryptdata, + keySystem, + mediaKeys, + mediaKeysSession, + keyStatus: 'status-pending', + }; - logger.log(`Media-keys created for key-system "${keySystem}"`); + this.mediaKeySessions.push(mediaKeySessionContext); - this._onMediaKeysCreated(); + return mediaKeySessionContext; + } - return mediaKeys; - }); + private renewKeySession(mediaKeySessionContext: MediaKeySessionContext) { + const decryptdata = mediaKeySessionContext.decryptdata; + const keySessionContext = this.createMediaKeySessionContext( + mediaKeySessionContext + ); + const keyId = this.getKeyIdString(decryptdata); + const scheme = 'cenc'; + this.keyIdToKeySessionPromise[keyId] = + this.generateRequestWithPreferredKeySession( + keySessionContext, + scheme, + decryptdata.pssh + ); + this.removeSession(mediaKeySessionContext); + } - mediaKeysPromise.catch((err) => { - logger.error('Failed to create media-keys:', err); - }); + 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); + } - return mediaKeysPromise; - } - - /** - * Handles key-creation (represents access to CDM). We are going to create key-sessions upon this - * for all existing keys where no session exists yet. - * - * @private - */ - private _onMediaKeysCreated() { - // check for all key-list items if a session exists, otherwise, create one - this._mediaKeysList.forEach((mediaKeysListItem) => { - if (!mediaKeysListItem.mediaKeysSession) { - // mediaKeys is definitely initialized here - mediaKeysListItem.mediaKeysSession = - mediaKeysListItem.mediaKeys!.createSession(); - this._onNewMediaKeySession(mediaKeysListItem.mediaKeysSession); + private 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 - * @param {*} keySession - */ - private _onNewMediaKeySession(keySession: MediaKeySession) { - logger.log(`New key-system session ${keySession.sessionId}`); - - keySession.addEventListener( - 'message', - (event: MediaKeyMessageEvent) => { - this._onKeySessionMessage(keySession, event.message); - }, - false + private updateKeySession( + mediaKeySessionContext: MediaKeySessionContext, + data: Uint8Array + ): Promise { + const keySession = mediaKeySessionContext.mediaKeysSession; + this.log( + `Updating key-session "${keySession.sessionId}" for keyID ${Hex.hexDump( + mediaKeySessionContext.decryptdata?.keyId! || [] + )} + } (data length: ${data ? data.byteLength : data})` ); + return keySession.update(data); } - /** - * @private - * @param {MediaKeySession} keySession - * @param {ArrayBuffer} message - */ - private _onKeySessionMessage( - keySession: MediaKeySession, - message: ArrayBuffer - ) { - logger.log('Got EME message event, creating license request'); - - this._requestLicense(message, (data: ArrayBuffer) => { - logger.log( - `Received license data (length: ${ - data ? data.byteLength : data - }), updating key-session` + public selectKeySystemFormat(frag: Fragment): Promise { + const keyFormats = Object.keys(frag.levelkeys || {}) as KeySystemFormats[]; + if (!this.keyFormatPromise) { + this.log( + `Selecting key-system from fragment (sn: ${frag.sn} ${frag.type}: ${ + frag.level + }) key formats ${keyFormats.join(', ')}` ); - keySession.update(data).catch((err) => { - logger.warn(`Updating key-session failed: ${err}`); - }); + this.keyFormatPromise = this.getKeyFormatPromise(keyFormats); + } + return this.keyFormatPromise; + } + + private getKeyFormatPromise( + keyFormats: KeySystemFormats[] + ): Promise { + return new Promise((resolve, reject) => { + const keySystemsInConfig = getKeySystemsForConfig(this.config); + const keySystemsToAttempt = keyFormats + .map(keySystemFormatToKeySystemDomain) + .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); + } else { + reject( + new Error(`Unable to find format for key-system "${keySystem}"`) + ); + } + }) + .catch(reject); }); } - /** - * @private - * @param e {MediaEncryptedEvent} - */ - private onMediaEncrypted(e: MediaEncryptedEvent) { - logger.log(`Media is encrypted using "${e.initDataType}" init data type`); + public loadKey(data: KeyLoadedData): Promise { + const decryptdata = data.keyInfo.decryptdata; + + 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}`); + + let keySessionContextPromise = this.keyIdToKeySessionPromise[keyId]; + if (!keySessionContextPromise) { + keySessionContextPromise = this.keyIdToKeySessionPromise[keyId] = + 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 ${keyDetails}` + ); + + return this.attemptSetMediaKeys(keySystem, mediaKeys).then(() => { + this.throwIfDestroyed(); + const keySessionContext = this.createMediaKeySessionContext({ + keySystem, + mediaKeys, + decryptdata, + }); + const scheme = 'cenc'; + return this.generateRequestWithPreferredKeySession( + keySessionContext, + scheme, + decryptdata.pssh + ); + }); + } + ); - if (!this.mediaKeysPromise) { - logger.error( - 'Fatal: Media is encrypted but no CDM access or no keys have been requested' - ); + keySessionContextPromise.catch((error) => this.handleError(error)); + } + + return keySessionContextPromise; + } + + private throwIfDestroyed(message = 'Invalid state'): void | never { + if (!this.hls) { + throw new Error('invalid state'); + } + } + + 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 keyId = this.getKeyIdString(decryptdata); + const mediaKeySessionContext = this.keyIdToKeySessionPromise[keyId]; + 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, - }); + private _onMediaEncrypted(event: MediaEncryptedEvent) { + const { initDataType, initData } = event; + this.log(`"${event.type}" event: init data type: "${initDataType}"`); + + // Ignore event when initData is null + if (initData === null) { return; } - if (keysListItem.mediaKeysSessionInitialized) { - logger.warn('Key-Session already initialized but requested again'); + // Support clear-lead key-session creation (otherwise depend on playlist keys) + const psshInfo = parsePssh(initData); + if (psshInfo === null) { 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, - }); + 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; } - // initData is null if the media is not CORS-same-origin + const keyIdHex = Hex.hexDump(keyId); + let keySessionContextPromise = this.keyIdToKeySessionPromise[keyIdHex]; + if (!keySessionContextPromise) { + keySessionContextPromise = this.keyIdToKeySessionPromise[keyIdHex] = + this.getKeySystemSelectionPromise([keySystemDomain]).then( + ({ keySystem, mediaKeys }) => { + this.throwIfDestroyed(); + 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)); + } + + 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 + ); + }); + } + + private generateRequestWithPreferredKeySession( + context: MediaKeySessionContext, + 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) { - 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` + const keyId = this.getKeyIdString(context.decryptdata); + this.log( + `Generating key-session request for ${keyId} (init data type: ${initDataType} length: ${ + initData ? initData.byteLength : null + })` ); - keysListItem.mediaKeysSessionInitialized = true; - keySession + 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) => { + 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?: void) => void, reject) => { + licenseStatus.on('error', reject); + + licenseStatus.on('keyStatus', (keyStatus) => { + if (keyStatus.startsWith('usable')) { + resolve(); + } 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( + `Request generated for key-session "${context.mediaKeysSession?.sessionId}" keyId: ${keyId}` + ); }) - .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(() => keyUsablePromise) + .catch((error) => { + licenseStatus.removeAllListeners(); + this.removeSession(context); + throw error; + }) + .then(() => { + licenseStatus.removeAllListeners(); + return context; }); } - /** - * @private - * @param {string} url License server URL - * @param {ArrayBuffer} keyMessage Message data issued by key-system - * @param {function} callback Called when XHR has succeeded - * @returns {XMLHttpRequest} Unsent (but opened state) XHR object - * @throws if XMLHttpRequest construction failed - */ - private _createLicenseXhr( - url: string, - keyMessage: ArrayBuffer, - callback: (data: ArrayBuffer) => void - ): XMLHttpRequest { - const xhr = new XMLHttpRequest(); - xhr.responseType = 'arraybuffer'; - xhr.onreadystatechange = this._onLicenseRequestReadyStageChange.bind( - this, - xhr, - url, - keyMessage, - callback + private onKeyStatusChange(mediaKeySessionContext: MediaKeySessionContext) { + mediaKeySessionContext.mediaKeysSession.keyStatuses.forEach( + (status: MediaKeyStatus, keyId: BufferSource) => { + this.log( + `key status change "${status}" for keyStatuses keyId: ${Hex.hexDump( + 'buffer' in keyId + ? new Uint8Array(keyId.buffer, keyId.byteOffset, keyId.byteLength) + : new Uint8Array(keyId) + )} session keyId: ${Hex.hexDump( + new Uint8Array(mediaKeySessionContext.decryptdata.keyId || []) + )} uri: ${mediaKeySessionContext.decryptdata.uri}` + ); + mediaKeySessionContext.keyStatus = status; + } ); + } - let licenseXhrSetup = this._licenseXhrSetup; - if (licenseXhrSetup) { - try { - licenseXhrSetup.call(this.hls, xhr, url); - licenseXhrSetup = undefined; - } catch (e) { - logger.error(e); + private fetchServerCertificate( + keySystem: KeySystems + ): Promise { + return new Promise((resolve, reject) => { + const url = this.getServerCertificateUrl(keySystem); + if (!url) { + return resolve(); } - } - 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}`); - } + this.log(`Fetching serverCertificate for "${keySystem}"`); + const xhr = new XMLHttpRequest(); + xhr.open('GET', url, true); + xhr.responseType = 'arraybuffer'; + xhr.onreadystatechange = () => { + 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(); + }); + } - return xhr; + 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_UPDATE_FAILED, + error, + fatal: true, + }, + error.message + ) + ); + }); + }); + } + + 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 - * @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( + private setupLicenseXHR( xhr: XMLHttpRequest, url: string, - keyMessage: ArrayBuffer, - callback: (data: ArrayBuffer) => void - ) { - switch (xhr.readyState) { - case 4: - if (xhr.status === 200) { - this._requestLicenseFailureCount = 0; - logger.log('License request succeeded'); - let data: ArrayBuffer = xhr.response; - const licenseResponseCallback = this._licenseResponseCallback; - if (licenseResponseCallback) { - try { - data = licenseResponseCallback.call(this.hls, xhr, url); - } catch (e) { - logger.error(e); - } - } - callback(data); - } else { - logger.error( - `License Request XHR failed (${url}). Status: ${xhr.status} (${xhr.statusText})` - ); - this._requestLicenseFailureCount++; - if (this._requestLicenseFailureCount > MAX_LICENSE_REQUEST_FAILURES) { - this.hls.trigger(Events.ERROR, { - type: ErrorTypes.KEY_SYSTEM_ERROR, - details: ErrorDetails.KEY_SYSTEM_LICENSE_REQUEST_FAILED, - fatal: true, - }); - return; - } + keysListItem: MediaKeySessionContext, + licenseChallenge: Uint8Array + ): Promise<{ xhr: XMLHttpRequest; licenseChallenge: Uint8Array }> { + const licenseXhrSetup = this.config.licenseXhrSetup; - const attemptsLeft = - MAX_LICENSE_REQUEST_FAILURES - this._requestLicenseFailureCount + 1; - logger.warn( - `Retrying license request, ${attemptsLeft} attempts left` - ); - this._requestLicense(keyMessage, callback); - } - break; + if (!licenseXhrSetup) { + xhr.open('POST', url, true); + + return Promise.resolve({ xhr, licenseChallenge }); } - } - /** - * @private - * @param {MediaKeysListItem} keysListItem - * @param {ArrayBuffer} keyMessage - * @returns {ArrayBuffer} Challenge data posted to license server - * @throws if KeySystem is unsupported - */ - private _generateLicenseRequestChallenge( - keysListItem: MediaKeysListItem, - keyMessage: ArrayBuffer - ): ArrayBuffer { - switch (keysListItem.mediaKeySystemDomain) { - // case KeySystems.PLAYREADY: - // from https://github.com/MicrosoftEdge/Demos/blob/master/eme/scripts/demo.js - /* - if (this.licenseType !== this.LICENSE_TYPE_WIDEVINE) { - // For PlayReady CDMs, we need to dig the Challenge out of the XML. - var keyMessageXml = new DOMParser().parseFromString(String.fromCharCode.apply(null, new Uint16Array(keyMessage)), 'application/xml'); - if (keyMessageXml.getElementsByTagName('Challenge')[0]) { - challenge = atob(keyMessageXml.getElementsByTagName('Challenge')[0].childNodes[0].nodeValue); - } else { - throw 'Cannot find in key message'; - } - var headerNames = keyMessageXml.getElementsByTagName('name'); - var headerValues = keyMessageXml.getElementsByTagName('value'); - if (headerNames.length !== headerValues.length) { - throw 'Mismatched header / pair in key message'; + return Promise.resolve() + .then(() => { + if (!keysListItem.decryptdata) { + throw new Error('Key removed'); } - for (var i = 0; i < headerNames.length; i++) { - xhr.setRequestHeader(headerNames[i].childNodes[0].nodeValue, headerValues[i].childNodes[0].nodeValue); + return licenseXhrSetup.call( + this.hls, + xhr, + url, + keysListItem, + licenseChallenge + ); + }) + .catch((error: Error) => { + if (!keysListItem.decryptdata) { + // Key session removed. Cancel license request. + throw error; } - } - break; - */ - case KeySystems.WIDEVINE: - // For Widevine CDMs, the challenge is the keyMessage. - return keyMessage; - } + // let's try to open before running setup + xhr.open('POST', url, true); - throw new Error( - `unsupported key-system: ${keysListItem.mediaKeySystemDomain}` - ); + return licenseXhrSetup.call( + this.hls, + xhr, + url, + keysListItem, + licenseChallenge + ); + }) + .then((licenseXhrSetupResult) => { + // if licenseXhrSetup did not yet call open, let's do it now + if (!xhr.readyState) { + xhr.open('POST', url, true); + } + const finalLicenseChallenge = licenseXhrSetupResult + ? licenseXhrSetupResult + : licenseChallenge; + return { xhr, licenseChallenge: finalLicenseChallenge }; + }); } - /** - * @private - * @param keyMessage - * @param callback - */ - private _requestLicense( - keyMessage: ArrayBuffer, - callback: (data: ArrayBuffer) => void - ) { - logger.log('Requesting content license for key-system'); + 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 || + (xhr.status >= 400 && xhr.status < 500) + ) { + 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 + ); + } + } + } + }; + if ( + keySessionContext.licenseXhr && + keySessionContext.licenseXhr.readyState !== XMLHttpRequest.DONE + ) { + keySessionContext.licenseXhr.abort(); + } + keySessionContext.licenseXhr = xhr; - const keysListItem = this._mediaKeysList[0]; - if (!keysListItem) { - logger.error( - 'Fatal error: Media is encrypted but no key-system access has been obtained yet' + this.setupLicenseXHR(xhr, url, keySessionContext, licenseChallenge).then( + ({ xhr, licenseChallenge }) => { + xhr.send(licenseChallenge); + } ); - this.hls.trigger(Events.ERROR, { - type: ErrorTypes.KEY_SYSTEM_ERROR, - details: ErrorDetails.KEY_SYSTEM_NO_ACCESS, - fatal: true, - }); - return; - } + }); + } - try { - const url = this.getLicenseServerUrl(keysListItem.mediaKeySystemDomain); - const xhr = this._createLicenseXhr(url, keyMessage, callback); - logger.log(`Sending license request to URL: ${url}`); - const challenge = this._generateLicenseRequestChallenge( - keysListItem, - keyMessage - ); - xhr.send(challenge); - } catch (e) { - logger.error(`Failure requesting DRM license: ${e}`); - this.hls.trigger(Events.ERROR, { - type: ErrorTypes.KEY_SYSTEM_ERROR, - details: ErrorDetails.KEY_SYSTEM_LICENSE_REQUEST_FAILED, - fatal: true, - }); + private generateLicenseRequestChallenge( + keySessionContext: MediaKeySessionContext, + keyMessage: ArrayBuffer + ): 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( + `Failed to extract spc from FairPlay license-request message. Fallback to message data for key uri: "${keySessionContext.decryptdata.uri}"` + ); + } + } + return message; + } + case KeySystems.WIDEVINE: + case KeySystems.PLAYREADY: + case KeySystems.CLEARKEY: + return message; + default: + throw new Error( + `unsupported key-system: ${keySessionContext.keySystem}` + ); } } - onMediaAttached(event: Events.MEDIA_ATTACHED, data: MediaAttachedData) { - if (!this._emeEnabled) { + private onMediaAttached( + event: Events.MEDIA_ATTACHED, + data: MediaAttachedData + ) { + if (!this.config.emeEnabled) { return; } const media = data.media; // keep reference of media - this._media = media; + this.media = media; - media.addEventListener('encrypted', this._onMediaEncrypted); + 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.keyIdToKeySessionPromise = {}; + 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. - }); - } - }) + const keySessionCount = mediaKeysList.length; + 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 (keySessionCount) { + 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) { + private onManifestLoaded( + event: Events.MANIFEST_LOADED, + { sessionKeys }: ManifestLoadedData + ) { + if (!sessionKeys || !this.config.emeEnabled) { return; } - - const audioCodecs = data.levels - .map((level) => level.audioCodec) - .filter( - (audioCodec: string | undefined): audioCodec is string => !!audioCodec + if (!this.keyFormatPromise) { + 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; + }, + [] ); - const videoCodecs = data.levels - .map((level) => level.videoCodec) - .filter( - (videoCodec: string | undefined): videoCodec is string => !!videoCodec + 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; + 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!; + const index = this.mediaKeySessions.indexOf(mediaKeySessionContext); + if (index > -1) { + this.mediaKeySessions.splice(index, 1); + } + 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}`); + }); + } + } +} - this._attemptKeySystemAccess(KeySystems.WIDEVINE, audioCodecs, videoCodecs); +class EMEKeyError extends Error { + public readonly data: ErrorData; + constructor(data: ErrorData, message: string) { + super(message); + this.data = data; + data.err = data.error; } } diff --git a/src/controller/level-controller.ts b/src/controller/level-controller.ts index f392696f443..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, @@ -362,6 +364,16 @@ 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; + // 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: // Do not perform level switch if an error occurred using delivery directives @@ -435,6 +447,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 958cb297793..ad4f32fcbee 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 { ErrorDetails, ErrorTypes } 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(); } @@ -844,9 +849,14 @@ 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: this.onFragmentOrKeyLoadError(PlaylistLevelType.MAIN, data); 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-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 49411f068d2..b98e98c7824 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; @@ -39,6 +39,7 @@ const muxConfig: MuxConfig[] = [ ]; export default class Transmuxer { + public async: boolean = false; private observer: HlsEventEmitter; private typeSupported: TypeSupported; private config: HlsConfig; @@ -75,7 +76,7 @@ export default class Transmuxer { push( data: ArrayBuffer, - decryptdata: LevelKey | null, + decryptdata: DecryptData | null, chunkMeta: ChunkMetadata, state?: TransmuxState ): TransmuxerResult | Promise { @@ -104,19 +105,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 +140,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 +290,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 +303,12 @@ export default class Transmuxer { videoCodec, trackDuration ); - remuxer.resetInitSegment(initSegmentData, audioCodec, videoCodec); + remuxer.resetInitSegment( + initSegmentData, + audioCodec, + videoCodec, + decryptdata + ); } destroy(): void { @@ -388,18 +401,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 +430,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 +449,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 7552f0bd0f1..3271d8e3836 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -20,7 +20,12 @@ 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', + 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..e5265345c7f 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) { + 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..b193d7d5bd5 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,326 @@ 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, + }); + } + + 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; + } } - if (!uri) { - throw new Error('key uri is falsy'); + } + } + + 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; } - 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 - ); + // 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 + } - // 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, + // 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 http(s) and 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; + }); + } + } + return Promise.resolve(keyLoadedData); + } + + 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-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/level-key.ts b/src/loader/level-key.ts index 90fcf253b1b..8a7a6e18e31 100644 --- a/src/loader/level-key.ts +++ b/src/loader/level-key.ts @@ -1,33 +1,280 @@ -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 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; } - get uri() { - return this._uri; + 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: { + 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; + } + } + } + + // 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; +} + +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 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/loader/m3u8-parser.ts b/src/loader/m3u8-parser.ts index 8fc7f51cc76..1cc21ab709c 100644 --- a/src/loader/m3u8-parser.ts +++ b/src/loader/m3u8-parser.ts @@ -4,7 +4,6 @@ import { DateRange } from './date-range'; import { Fragment, Part } from './fragment'; import { LevelDetails } from './level-details'; import { LevelKey } from './level-key'; - import { AttrList } from '../utils/attr-list'; import { logger } from '../utils/logger'; import type { CodecType } from '../utils/codecs'; @@ -19,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( @@ -83,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; @@ -131,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 @@ -141,6 +162,7 @@ export default class M3U8Parser { return { levels: stripUnknownCodecLevels ? levelsWithKnownCodecs : levels, sessionData: hasSessionData ? sessionData : null, + sessionKeys: sessionKeys.length ? sessionKeys : null, }; } @@ -207,7 +229,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 +264,20 @@ export default class M3U8Parser { // url if (Number.isFinite(frag.duration)) { frag.start = totalduration; - if (levelkey) { - frag.levelkey = levelkey; + 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; @@ -365,66 +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'); - 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'; - - const unsupportedKnownKeyformatsInManifest = [ - 'com.apple.streamingkeydelivery', - 'com.microsoft.playready', - 'urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed', // widevine (v2) - 'com.widevine', // earlier widevine (v1) - ]; - - if ( - unsupportedKnownKeyformatsInManifest.indexOf(decryptkeyformat) > - -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; - } - - if (decryptkeyformatversions) { - levelkey.keyFormatVersions = decryptkeyformatversions; - } - - // Initialization Vector (IV) - levelkey.iv = decryptiv; + const levelKey = parseKey(value1, baseurl); + if (levelKey.isSupported()) { + if (levelKey.method === 'NONE') { + levelkeys = undefined; + break; } + 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; } @@ -445,7 +434,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 ( @@ -456,7 +445,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; } @@ -589,6 +578,39 @@ export default class M3U8Parser { } } +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}`); + } + // 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) { ['video', 'audio', 'text'].forEach((type: CodecType) => { const filtered = codecs.filter((codec) => isCodecType(codec, type)); @@ -650,7 +672,7 @@ function setInitSegment( frag: Fragment, mapAttrs: AttrList, id: number, - levelkey: LevelKey | undefined + levelkeys: { [key: string]: LevelKey } | undefined ) { frag.relurl = mapAttrs.URI; if (mapAttrs.BYTERANGE) { @@ -658,8 +680,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..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, }); } @@ -565,7 +570,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..64c927a6f0b 100644 --- a/src/types/events.ts +++ b/src/types/events.ts @@ -21,6 +21,8 @@ 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'; +import { LevelKey } from '../loader/level-key'; export interface MediaAttachingData { media: HTMLMediaElement; @@ -82,6 +84,7 @@ export interface ManifestLoadedData { levels: LevelParsed[]; networkDetails: any; sessionData: Record | null; + sessionKeys: LevelKey[] | null; stats: LoaderStats; subtitles?: MediaPlaylist[]; url: string; @@ -91,6 +94,8 @@ export interface ManifestParsedData { levels: Level[]; audioTracks: MediaPlaylist[]; subtitleTracks: MediaPlaylist[]; + sessionData: Record | null; + sessionKeys: LevelKey[] | null; firstLevel: number; stats: LoaderStats; audio: boolean; @@ -215,6 +220,7 @@ export interface ErrorData { fatal: boolean; buffer?: number; bytes?: number; + chunkMeta?: ChunkMetadata; context?: PlaylistLoaderContext; error?: Error; event?: keyof HlsListeners | 'demuxerWorker'; @@ -338,6 +344,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/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/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 4428b519e89..b00dad42d9b 100644 --- a/src/utils/mediakeys-helper.ts +++ b/src/utils/mediakeys-helper.ts @@ -1,16 +1,98 @@ +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 #EXT-X-KEY KEYFORMAT values +export enum KeySystemFormats { + CLEARKEY = 'org.w3.clearkey', + FAIRPLAY = 'com.apple.streamingkeydelivery', PLAYREADY = 'com.microsoft.playready', + WIDEVINE = 'urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed', +} + +export function keySystemFormatToKeySystemDomain( + format: KeySystemFormats +): KeySystems | undefined { + 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; + } +} + +// 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 { + 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; + } +} + +export function getKeySystemsForConfig( + config: EMEControllerConfig +): KeySystems[] { + const { drmSystems, widevineLicenseUrl } = config; + 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); + } + 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 && @@ -22,4 +104,63 @@ 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: audioCodecs.map((codec) => ({ + contentType: `audio/mp4; codecs="${codec}"`, + robustness: drmSystemOptions.audioRobustness || '', + 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/src/utils/mp4-tools.ts b/src/utils/mp4-tools.ts index 66ef21cdc70..7a260bf6f27 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,116 @@ 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() + ); +} + +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/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 32a19be2391..00000000000 --- a/tests/unit/controller/eme-controller.js +++ /dev/null @@ -1,188 +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)); -}; - -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 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..7eee5de8954 --- /dev/null +++ b/tests/unit/controller/eme-controller.ts @@ -0,0 +1,619 @@ +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' | 'keyIdToKeySessionPromise' | 'mediaKeySessions' +> & { + hls: HlsMock; + keyIdToKeySessionPromise: { + [keyId: 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, + }); + + 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 ignore "encrypted" events with bad data', 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, + requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy, + }); + + const badData = { + initDataType: 'cenc', + 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, { + media: media as any as HTMLMediaElement, + }); + + media.emit('encrypted', badData); + + expect(emeController.keyIdToKeySessionPromise.f000ba00).to.be.a('Promise'); + if (!emeController.keyIdToKeySessionPromise.f000ba00) { + return; + } + return emeController.keyIdToKeySessionPromise.f000ba00 + .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; + Promise.resolve().then(() => { + (xhr as any).response = new Uint8Array(); + xhr.respond(200, {}, ''); + }); + }; + + 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.keyIdToKeySessionPromise['00000000000000000000000000000000'] + ).to.be.a('Promise'); + if ( + !emeController.keyIdToKeySessionPromise[ + '00000000000000000000000000000000' + ] + ) { + return; + } + 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 () { + 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', + keyId: new Uint8Array(16), + }, + }, + } as any); + + expect( + emeController.keyIdToKeySessionPromise['00000000000000000000000000000000'] + ).to.be.a('Promise'); + if ( + !emeController.keyIdToKeySessionPromise[ + '00000000000000000000000000000000' + ] + ) { + return; + } + return emeController.keyIdToKeySessionPromise[ + '00000000000000000000000000000000' + ] + .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', + keyId: new Uint8Array(16), + }, + }, + } as any); + + expect( + emeController.keyIdToKeySessionPromise['00000000000000000000000000000000'] + ).to.be.a('Promise'); + if ( + !emeController.keyIdToKeySessionPromise[ + '00000000000000000000000000000000' + ] + ) { + return; + } + return emeController.keyIdToKeySessionPromise[ + '00000000000000000000000000000000' + ] + .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/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/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 83% rename from tests/unit/loader/playlist-loader.js rename to tests/unit/loader/playlist-loader.ts index 4adccc0afa1..9b505fe06ee 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); @@ -1644,8 +1722,147 @@ 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]]); + }); + + 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, msg) { +function expectWithJSONMessage(value: any, msg?: string) { return expect(value, `${msg || 'actual:'} ${JSON.stringify(value, null, 2)}`); }