diff --git a/README.md b/README.md index 888d3afc8ca..ebaae7ed2c4 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ HLS.js is written in [ECMAScript6] (`*.js`) and [TypeScript] (`*.ts`) (strongly - AES-128 decryption - SAMPLE-AES decryption (only supported if using MPEG-2 TS container) - Encrypted media extensions (EME) support for DRM (digital rights management) - - Widevine CDM (only tested with [shaka-packager](https://github.com/google/shaka-packager) test-stream on [the demo page](https://hls-js.netlify.app/demo/?src=https%3A%2F%2Fstorage.googleapis.com%2Fshaka-demo-assets%2Fangel-one-widevine-hls%2Fhls.m3u8&demoConfig=eyJlbmFibGVTdHJlYW1pbmciOnRydWUsImF1dG9SZWNvdmVyRXJyb3IiOnRydWUsInN0b3BPblN0YWxsIjpmYWxzZSwiZHVtcGZNUDQiOmZhbHNlLCJsZXZlbENhcHBpbmciOi0xLCJsaW1pdE1ldHJpY3MiOi0xfQ==)) + - FairPlay, PlayReady, Widevine CDMs with fmp4 segments - CEA-608/708 captions - WebVTT subtitles - Alternate Audio Track Rendition (Master Playlist with Alternative Audio) for VoD and Live playlists @@ -120,8 +120,7 @@ For a complete list of issues, see ["Top priorities" in the Release Planning and - `#EXT-X-GAP` filling [#2940](https://github.com/video-dev/hls.js/issues/2940) - `#EXT-X-I-FRAME-STREAM-INF` I-frame Media Playlist files - `SAMPLE-AES` with fmp4, aac, mp3, vtt... segments (MPEG-2 TS only) -- FairPlay DRM with MPEG-2 TS content -- PlayReady (See [#3779](https://github.com/video-dev/hls.js/issues/3779) and [issues labeled DRM](https://github.com/video-dev/hls.js/issues?q=is%3Aissue+is%3Aopen+label%3ADRM)) +- FairPlay, PlayReady, Widevine DRM with MPEG-2 TS segments - Advanced variant selection based on runtime media capabilities (See issues labeled [`media-capabilities`](https://github.com/video-dev/hls.js/labels/media-capabilities)) - MP3 elementary stream audio in IE and Edge (<=18) on Windows 10 (See [#1641](https://github.com/video-dev/hls.js/issues/1641) and [Microsoft answers forum](https://answers.microsoft.com/en-us/ie/forum/all/ie11-on-windows-10-cannot-play-hls-with-mp3/2da994b5-8dec-4ae9-9201-7d138ede49d9)) diff --git a/api-extractor/report/hls.js.api.md b/api-extractor/report/hls.js.api.md index 860bdfb2f68..0438d602dd1 100644 --- a/api-extractor/report/hls.js.api.md +++ b/api-extractor/report/hls.js.api.md @@ -313,6 +313,10 @@ export class DateRange { export type DRMSystemOptions = { audioRobustness?: string; videoRobustness?: string; + persistentState?: MediaKeysRequirement; + distinctiveIdentifier?: MediaKeysRequirement; + sessionTypes?: string[]; + sessionType?: string; }; // Warning: (ae-missing-release-tag) "ElementaryStreamInfo" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -352,9 +356,10 @@ export enum ElementaryStreamTypes { // // @public (undocumented) export type EMEControllerConfig = { - licenseXhrSetup?: (xhr: XMLHttpRequest, url: string, keySystem: KeySystems) => void | Promise; - licenseResponseCallback?: (xhr: XMLHttpRequest, url: string, keySystem: KeySystems) => ArrayBuffer; + licenseXhrSetup?: (this: Hls, xhr: XMLHttpRequest, url: string, keyContext: MediaKeySessionContext, licenseChallenge: Uint8Array) => void | Promise; + licenseResponseCallback?: (this: Hls, xhr: XMLHttpRequest, url: string, keyContext: MediaKeySessionContext) => ArrayBuffer; emeEnabled: boolean; + useEmeEncryptedEvent: boolean; widevineLicenseUrl?: string; drmSystems: DRMSystemsConfiguration; drmSystemOptions: DRMSystemOptions; @@ -464,6 +469,10 @@ export enum ErrorDetails { // (undocumented) KEY_SYSTEM_SESSION_UPDATE_FAILED = "keySystemSessionUpdateFailed", // (undocumented) + KEY_SYSTEM_STATUS_INTERNAL_ERROR = "keySystemStatusInternalError", + // (undocumented) + KEY_SYSTEM_STATUS_OUTPUT_RESTRICTED = "keySystemStatusOutputRestricted", + // (undocumented) LEVEL_EMPTY_ERROR = "levelEmptyError", // (undocumented) LEVEL_LOAD_ERROR = "levelLoadError", @@ -740,7 +749,6 @@ export class Fragment extends BaseSegment { cc: number; // (undocumented) clearElementaryStreamInfo(): void; - createInitializationVector(segmentNumber: number): Uint8Array; // (undocumented) data?: Uint8Array; // (undocumented) @@ -761,12 +769,16 @@ export class Fragment extends BaseSegment { endPTS?: number; // (undocumented) initSegment: Fragment | null; + // Warning: (ae-forgotten-export) The symbol "KeyLoaderContext" needs to be exported by the entry point hls.d.ts + // // (undocumented) keyLoader: Loader | null; // (undocumented) level: number; // (undocumented) - levelkey?: LevelKey; + levelkeys?: { + [key: string]: LevelKey; + }; // (undocumented) loader: Loader | null; // (undocumented) @@ -777,10 +789,11 @@ export class Fragment extends BaseSegment { programDateTime: number | null; // (undocumented) rawProgramDateTime: string | null; - setDecryptDataFromLevelKey(levelkey: LevelKey, segmentNumber: number): LevelKey; // (undocumented) setElementaryStreamInfo(type: ElementaryStreamTypes, startPTS: number, endPTS: number, startDTS: number, endDTS: number, partial?: boolean): void; // (undocumented) + setKeyFormat(keyFormat: KeySystemFormats): void; + // (undocumented) sn: number | 'initSegment'; // (undocumented) start: number; @@ -894,7 +907,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); @@ -1226,26 +1239,40 @@ 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 exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "KeyLoadingData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export interface KeyLoaderContext extends FragmentLoaderContext { +export interface KeyLoadingData { + // (undocumented) + frag: Fragment; } -// Warning: (ae-missing-release-tag) "KeyLoadingData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "KeySystemFormats" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export interface KeyLoadingData { +export enum KeySystemFormats { // (undocumented) - frag: Fragment; + CLEARKEY = "org.w3.clearkey", + // (undocumented) + FAIRPLAY = "com.apple.streamingkeydelivery", + // (undocumented) + PLAYREADY = "com.microsoft.playready", + // (undocumented) + WIDEVINE = "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed" } // Warning: (ae-missing-release-tag) "KeySystems" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export enum KeySystems { + // (undocumented) + CLEARKEY = "org.w3.clearkey", // (undocumented) FAIRPLAY = "com.apple.fps", // (undocumented) @@ -1478,28 +1505,36 @@ 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 exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export class LevelKey { +export class LevelKey implements DecryptData { + constructor(method: string, uri: string, format: string, formatversions?: number[], iv?: Uint8Array | null); + // (undocumented) + static clearKeyUriToKeyIdMap(): void; // (undocumented) - static fromURI(uri: string): LevelKey; + readonly encrypted: boolean; // (undocumented) - static fromURL(baseUrl: string, relativeUrl: string): LevelKey; + getDecryptData(sn: number | 'initSegment'): LevelKey | null; // (undocumented) - iv: Uint8Array | null; + readonly isCommonEncryption: boolean; + // (undocumented) + readonly 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 exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -2241,19 +2276,20 @@ export interface UserdataSample { // Warnings were encountered during analysis: // -// src/config.ts:84:3 - (ae-forgotten-export) The symbol "DRMSystemsConfiguration" needs to be exported by the entry point hls.d.ts -// src/config.ts:187:3 - (ae-forgotten-export) The symbol "ILogger" needs to be exported by the entry point hls.d.ts -// src/config.ts:197:3 - (ae-forgotten-export) The symbol "AudioStreamController" needs to be exported by the entry point hls.d.ts -// src/config.ts:198:3 - (ae-forgotten-export) The symbol "AudioTrackController" needs to be exported by the entry point hls.d.ts -// src/config.ts:200:3 - (ae-forgotten-export) The symbol "SubtitleStreamController" needs to be exported by the entry point hls.d.ts -// src/config.ts:201:3 - (ae-forgotten-export) The symbol "SubtitleTrackController" needs to be exported by the entry point hls.d.ts -// src/config.ts:202:3 - (ae-forgotten-export) The symbol "TimelineController" needs to be exported by the entry point hls.d.ts -// src/config.ts:204:3 - (ae-forgotten-export) The symbol "EMEController" needs to be exported by the entry point hls.d.ts -// src/config.ts:207:3 - (ae-forgotten-export) The symbol "CMCDController" needs to be exported by the entry point hls.d.ts -// src/config.ts:209:3 - (ae-forgotten-export) The symbol "AbrController" needs to be exported by the entry point hls.d.ts -// src/config.ts:210:3 - (ae-forgotten-export) The symbol "BufferController" needs to be exported by the entry point hls.d.ts -// src/config.ts:211:3 - (ae-forgotten-export) The symbol "CapLevelController" needs to be exported by the entry point hls.d.ts -// src/config.ts:212:3 - (ae-forgotten-export) The symbol "FPSController" needs to be exported by the entry point hls.d.ts +// src/config.ts:79:3 - (ae-forgotten-export) The symbol "MediaKeySessionContext" needs to be exported by the entry point hls.d.ts +// src/config.ts:95:3 - (ae-forgotten-export) The symbol "DRMSystemsConfiguration" needs to be exported by the entry point hls.d.ts +// src/config.ts:198:3 - (ae-forgotten-export) The symbol "ILogger" needs to be exported by the entry point hls.d.ts +// src/config.ts:208:3 - (ae-forgotten-export) The symbol "AudioStreamController" needs to be exported by the entry point hls.d.ts +// src/config.ts:209:3 - (ae-forgotten-export) The symbol "AudioTrackController" needs to be exported by the entry point hls.d.ts +// src/config.ts:211:3 - (ae-forgotten-export) The symbol "SubtitleStreamController" needs to be exported by the entry point hls.d.ts +// src/config.ts:212:3 - (ae-forgotten-export) The symbol "SubtitleTrackController" needs to be exported by the entry point hls.d.ts +// src/config.ts:213:3 - (ae-forgotten-export) The symbol "TimelineController" needs to be exported by the entry point hls.d.ts +// src/config.ts:215:3 - (ae-forgotten-export) The symbol "EMEController" needs to be exported by the entry point hls.d.ts +// src/config.ts:218:3 - (ae-forgotten-export) The symbol "CMCDController" needs to be exported by the entry point hls.d.ts +// src/config.ts:220:3 - (ae-forgotten-export) The symbol "AbrController" needs to be exported by the entry point hls.d.ts +// src/config.ts:221:3 - (ae-forgotten-export) The symbol "BufferController" needs to be exported by the entry point hls.d.ts +// src/config.ts:222:3 - (ae-forgotten-export) The symbol "CapLevelController" needs to be exported by the entry point hls.d.ts +// src/config.ts:223:3 - (ae-forgotten-export) The symbol "FPSController" needs to be exported by the entry point hls.d.ts // (No @packageDocumentation comment for this package) diff --git a/docs/API.md b/docs/API.md index 29195d9de27..cc7d9afacff 100644 --- a/docs/API.md +++ b/docs/API.md @@ -94,6 +94,7 @@ - [`abrMaxWithRealBitrate`](#abrmaxwithrealbitrate) - [`minAutoBitrate`](#minautobitrate) - [`emeEnabled`](#emeEnabled) + - [`useEmeEncryptedEvent`](#useEmeEncryptedEvent) - [`widevineLicenseUrl`](#widevineLicenseUrl) - [`licenseXhrSetup`](#licenseXhrSetup) - [`licenseResponseCallback`](#licenseResponseCallback) @@ -399,6 +400,7 @@ var config = { maxLoadingDelay: 4, minAutoBitrate: 0, emeEnabled: false, + useEmeEncryptedEvent: false, widevineLicenseUrl: undefined, licenseXhrSetup: undefined, drmSystems: {}, @@ -1192,6 +1194,12 @@ Useful when browser or tab of the browser is not in the focus and bandwidth drop Set to `true` to enable DRM key system access and license retrieval. +### `useEmeEncryptedEvent` + +(default: `false`) + +Set to `true` to use media "encrypted" event initData and ignore manifest DRM keys. + ### `widevineLicenseUrl` (default: `undefined`) @@ -1200,45 +1208,82 @@ The Widevine license server URL. ### `licenseXhrSetup` -(default: `undefined`, type `(xhr: XMLHttpRequest, url: string) => void`) +(default: `undefined`, type `(xhr: XMLHttpRequest, url: string, keyContext: MediaKeySessionContext, licenseChallenge: Uint8Array) => void`) -A pre-processor function for modifying the `XMLHttpRequest` and request url (using `xhr.open`) prior to sending the license request. +A pre-processor function for modifying license requests. The license request URL, request headers, and payload can all be modified prior to sending the license request, based on operating conditions, the current key-session, and key-system. ```js var config = { - licenseXhrSetup: function (xhr, url) { - xhr.withCredentials = true; // do send cookies - if (!xhr.readyState) { - // Call open to change the method (default is POST) or modify the url - xhr.open('GET', url, true); - // Append headers after opening + licenseXhrSetup: function (xhr, url, keyContext, licenseChallenge) { + let payload = licenseChallenge; + + // Send cookies with request + xhr.withCredentials = true; + + // Call open to change the method (default is POST), modify the url, or set request headers + xhr.open('POST', url, true); + + // call xhr.setRequestHeader after xhr.open otherwise licenseXhrSetup will throw and be called a second time after HLS.js call xhr.open + if (keyContext.keySystem === 'com.apple.fps') { + xhr.setRequestHeader('Content-Type', 'application/json'); + payload = JSON.stringify({ + keyData: base64Encode(keyContext.decryptdata?.keyId), + licenseChallenge: base64Encode(licenseChallenge), + }); + } else { xhr.setRequestHeader('Content-Type', 'application/octet-stream'); } + + // Return the desired payload or a Promise + // return Promise.resolve(payload); + return payload; }, }; ``` ### `licenseResponseCallback` -(default: `undefined`, type `(xhr: XMLHttpRequest, url: string) => data: ArrayBuffer`) +(default: `undefined`, type `(xhr: XMLHttpRequest, url: string, keyContext: MediaKeySessionContext) => data: ArrayBuffer`) A post-processor function for modifying the license response before passing it to the key-session (`MediaKeySession.update`). +```js +var config = { + licenseResponseCallback: function (xhr, url, keyContext) { + const keySystem = keyContext.keySystem; + const response = xhr.response; + if (keyContext.keySystem === 'com.apple.fps') { + try { + const responseObject = JSON.parse( + new TextDecoder().decode(response).trim(); + ); + const keyResponse = responseObject['fairplay-streaming-response']['streaming-keys'][0]; + return base64Decode(keyResponse.ckc); + } catch (error) { + console.error(error); + } + } + return response; + } +``` + ### `drmSystems` (default: `{}`) -Set `licenseUrl` and `serverCertificateUrl` for a given keySystem to your own DRM provider. `serverCertificateUrl` is not mandatory. Ex: +Set `licenseUrl` and `serverCertificateUrl` for a given key-system to your own DRM provider. `serverCertificateUrl` is not mandatory. Ex: ```js -{ +drmSystems: { 'com.widevine.alpha': { - licenseUrl: 'https://proxy.uat.widevine.com/proxy', - serverCertificateUrl: 'https://storage.googleapis.com/wvmedia/cert/cert_license_widevine_com_uat.bin' + licenseUrl: 'https://your-widevine-license-server/path', + serverCertificateUrl: 'https://optional-server-certificate/path/cert.bin' } } ``` +Supported key-systems include 'com.apple.fps', 'com.microsoft.playready', 'com.widevine.alpha', and 'org.w3.clearkey'. Mapping to other values in key-system access requests can be done by customizing [`requestMediaKeySystemAccessFunc`](#requestMediaKeySystemAccessFunc). + ### `drmSystemOptions` (default: `{}`) @@ -1258,7 +1303,18 @@ With the default argument, `''` will be specified for each option (_i.e. no spec (default: A function that returns the result of `window.navigator.requestMediaKeySystemAccess.bind(window.navigator)` or `null`) -Allows for the customization of `window.navigator.requestMediaKeySystemAccess`. +Allows for the customization of `window.navigator.requestMediaKeySystemAccess`. This can be used to map key-system access request to from a supported value to a custom one: + +```js +var hls new Hls({ + requestMediaKeySystemAccessFunc: (keySystem, supportedConfigurations) => { + if (keySystem === 'com.microsoft.playready') { + keySystem = 'com.microsoft.playready.recommendation'; + } + return navigator.requestMediaKeySystemAccess(keySystem, supportedConfigurations); + } +}); +``` ### `cmcd` diff --git a/src/config.ts b/src/config.ts index a2a45a70b79..df8f8a1091d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -7,7 +7,9 @@ import BufferController from './controller/buffer-controller'; import { TimelineController } from './controller/timeline-controller'; import CapLevelController from './controller/cap-level-controller'; import FPSController from './controller/fps-controller'; -import EMEController from './controller/eme-controller'; +import EMEController, { + MediaKeySessionContext, +} from './controller/eme-controller'; import CMCDController from './controller/cmcd-controller'; import XhrLoader from './utils/xhr-loader'; import FetchLoader, { fetchSupported } from './utils/fetch-loader'; @@ -15,6 +17,7 @@ import Cues from './utils/cues'; import { requestMediaKeySystemAccess } from './utils/mediakeys-helper'; import { ILogger, logger } from './utils/logger'; +import type Hls from './hls'; import type { CuesInterface } from './utils/cues'; import type { MediaKeyFunc, KeySystems } from './utils/mediakeys-helper'; import type { @@ -57,6 +60,10 @@ export type CMCDControllerConfig = { export type DRMSystemOptions = { audioRobustness?: string; videoRobustness?: string; + persistentState?: MediaKeysRequirement; + distinctiveIdentifier?: MediaKeysRequirement; + sessionTypes?: string[]; + sessionType?: string; }; export type DRMSystemConfiguration = { @@ -70,16 +77,20 @@ export type DRMSystemsConfiguration = Partial< export type EMEControllerConfig = { licenseXhrSetup?: ( + this: Hls, xhr: XMLHttpRequest, url: string, - keySystem: KeySystems - ) => void | Promise; + keyContext: MediaKeySessionContext, + licenseChallenge: Uint8Array + ) => void | Promise; licenseResponseCallback?: ( + this: Hls, xhr: XMLHttpRequest, url: string, - keySystem: KeySystems + keyContext: MediaKeySessionContext ) => ArrayBuffer; emeEnabled: boolean; + useEmeEncryptedEvent: boolean; widevineLicenseUrl?: string; drmSystems: DRMSystemsConfiguration; drmSystemOptions: DRMSystemOptions; @@ -300,6 +311,7 @@ export const hlsDefaultConfig: HlsConfig = { maxLoadingDelay: 4, // used by abr-controller minAutoBitrate: 0, // used by hls emeEnabled: false, // used by eme-controller + useEmeEncryptedEvent: false, // used by eme-controller widevineLicenseUrl: undefined, // used by eme-controller drmSystems: {}, // used by eme-controller drmSystemOptions: {}, // used by eme-controller diff --git a/src/controller/abr-controller.ts b/src/controller/abr-controller.ts index 082ad398292..b7fbd415c5a 100644 --- a/src/controller/abr-controller.ts +++ b/src/controller/abr-controller.ts @@ -1,7 +1,7 @@ import EwmaBandWidthEstimator from '../utils/ewma-bandwidth-estimator'; import { Events } from '../events'; import { BufferHelper } from '../utils/buffer-helper'; -import { ErrorDetails } from '../errors'; +import { ErrorDetails, ErrorTypes } from '../errors'; import { PlaylistLevelType } from '../types/loader'; import { logger } from '../utils/logger'; import type { Bufferable } from '../utils/buffer-helper'; @@ -277,13 +277,21 @@ class AbrController implements ComponentAPI { protected onError(event: Events.ERROR, data: ErrorData) { // stop timer in case of frag loading error - switch (data.details) { - case ErrorDetails.FRAG_LOAD_ERROR: - case ErrorDetails.FRAG_LOAD_TIMEOUT: + if (data.frag?.type === PlaylistLevelType.MAIN) { + if (data.type === ErrorTypes.KEY_SYSTEM_ERROR) { this.clearTimer(); - break; - default: - break; + return; + } + switch (data.details) { + case ErrorDetails.FRAG_LOAD_ERROR: + case ErrorDetails.FRAG_LOAD_TIMEOUT: + case ErrorDetails.KEY_LOAD_ERROR: + case ErrorDetails.KEY_LOAD_TIMEOUT: + this.clearTimer(); + break; + default: + break; + } } } diff --git a/src/controller/audio-stream-controller.ts b/src/controller/audio-stream-controller.ts index e509809473e..e3d663b49a4 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(); } @@ -625,6 +630,8 @@ class AudioStreamController case ErrorDetails.FRAG_LOAD_TIMEOUT: case ErrorDetails.KEY_LOAD_ERROR: case ErrorDetails.KEY_LOAD_TIMEOUT: + case ErrorDetails.KEY_SYSTEM_NO_SESSION: + case ErrorDetails.KEY_SYSTEM_STATUS_OUTPUT_RESTRICTED: // TODO: Skip fragments that do not belong to this.fragCurrent audio-group id this.onFragmentOrKeyLoadError(PlaylistLevelType.AUDIO, data); break; diff --git a/src/controller/base-stream-controller.ts b/src/controller/base-stream-controller.ts index fc132a603d7..d995a998741 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'; @@ -100,14 +100,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 as HlsEventEmitter, hls.config); @@ -206,6 +211,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(); @@ -559,7 +567,7 @@ export default class BaseStreamController this.state = State.KEY_LOADING; this.fragCurrent = frag; keyLoadingPromise = this.keyLoader.load(frag).then((keyLoadedData) => { - if (keyLoadedData && !this.fragContextChanged(keyLoadedData.frag)) { + if (!this.fragContextChanged(keyLoadedData.frag)) { this.hls.trigger(Events.KEY_LOADED, keyLoadedData); return keyLoadedData; } @@ -602,7 +610,7 @@ export default class BaseStreamController .then((keyLoadedData) => { if ( !keyLoadedData || - this.fragContextChanged(keyLoadedData?.frag) + this.fragContextChanged(keyLoadedData.frag) ) { return null; } @@ -720,11 +728,21 @@ export default class BaseStreamController ); } - private handleFragLoadError({ data }: LoadError) { - if (data && data.details === ErrorDetails.INTERNAL_ABORTED) { - this.handleFragLoadAborted(data.frag, data.part); + private handleFragLoadError(error: LoadError | Error) { + if ('data' in error) { + const data = error.data; + if (error.data && data.details === ErrorDetails.INTERNAL_ABORTED) { + this.handleFragLoadAborted(data.frag, data.part); + } else { + this.hls.trigger(Events.ERROR, data as ErrorData); + } } else { - this.hls.trigger(Events.ERROR, data as ErrorData); + this.hls.trigger(Events.ERROR, { + type: ErrorTypes.OTHER_ERROR, + details: ErrorDetails.INTERNAL_EXCEPTION, + err: error, + fatal: true, + }); } return null; } diff --git a/src/controller/eme-controller.ts b/src/controller/eme-controller.ts index 4cf5783c079..7683971d7ac 100644 --- a/src/controller/eme-controller.ts +++ b/src/controller/eme-controller.ts @@ -5,108 +5,50 @@ */ import { Events } from '../events'; import { ErrorTypes, ErrorDetails } from '../errors'; +import { logger } from '../utils/logger'; +import { + getKeySystemsForConfig, + getSupportedMediaKeySystemConfigurations, + keySystemDomainToKeySystemFormat as keySystemToKeySystemFormat, + KeySystemFormats, + keySystemFormatToKeySystemDomain, +} from '../utils/mediakeys-helper'; +import { + KeySystems, + requestMediaKeySystemAccess, +} from '../utils/mediakeys-helper'; +import { strToUtf8array } from '../utils/keysystem-util'; +import { utf8ArrayToStr } from '../demux/id3'; +import { base64Decode, base64Encode } from '../utils/numeric-encoding-utils'; +import { LevelKey } from '../loader/level-key'; -import { logger, enableLogs } from '../utils/logger'; -import type { - DRMSystemOptions, - DRMSystemsConfiguration, - EMEControllerConfig, -} from '../config'; -import type { MediaKeyFunc } from '../utils/mediakeys-helper'; -import { KeySystems } from '../utils/mediakeys-helper'; import type Hls from '../hls'; import type { ComponentAPI } from '../types/component-api'; -import type { MediaAttachedData, ManifestParsedData } from '../types/events'; - -enableLogs(true); +import type { + MediaAttachedData, + KeyLoadedData, + ErrorData, +} from '../types/events'; +import type { EMEControllerConfig } from '../config'; +import type { Fragment } from '../loader/fragment'; +import Hex from '../utils/hex'; const MAX_LICENSE_REQUEST_FAILURES = 3; +const LOGGER_PREFIX = '[eme]'; -/** - * @see https://developer.mozilla.org/en-US/docs/Web/API/MediaKeySystemConfiguration - * @param {Array} audioCodecs List of required audio codecs to support - * @param {Array} videoCodecs List of required video codecs to support - * @param {object} drmSystemOptions Optional parameters/requirements for the key-system - * @returns {Array} An array of supported configurations - */ - -const createMediaKeySystemConfigurations = function ( - initDataType: string, - audioCodecs: string[], - videoCodecs: string[], - drmSystemOptions: DRMSystemOptions -): MediaKeySystemConfiguration[] { - /* jshint ignore:line */ - const baseConfig: MediaKeySystemConfiguration = { - initDataTypes: [initDataType], - // label: "", - persistentState: 'not-allowed', // or "required" ? - distinctiveIdentifier: 'not-allowed', // or "required" ? - sessionTypes: ['temporary'], - audioCapabilities: [], // { contentType: 'audio/mp4; codecs="mp4a.40.2"' } - videoCapabilities: [], // { contentType: 'video/mp4; codecs="avc1.42E01E"' } - }; - - audioCodecs.forEach((codec) => { - baseConfig.audioCapabilities!.push({ - contentType: `audio/mp4; codecs="${codec}"`, - robustness: drmSystemOptions.audioRobustness || '', - }); - }); - videoCodecs.forEach((codec) => { - baseConfig.videoCapabilities!.push({ - contentType: `video/mp4; codecs="${codec}"`, - robustness: drmSystemOptions.videoRobustness || '', - }); - }); - - return [baseConfig]; -}; - -/** - * The idea here is to handle key-system (and their respective platforms) specific configuration differences - * in order to work with the local requestMediaKeySystemAccess method. - * - * We can also rule-out platform-related key-system support at this point by throwing an error. - * - * @param {string} keySystem Identifier for the key-system, see `KeySystems` enum - * @param {Array} audioCodecs List of required audio codecs to support - * @param {Array} videoCodecs List of required video codecs to support - * @throws will throw an error if a unknown key system is passed - * @returns {Array} A non-empty Array of MediaKeySystemConfiguration objects - */ -const getSupportedMediaKeySystemConfigurations = function ( - keySystem: KeySystems, - audioCodecs: string[], - videoCodecs: string[], - drmSystemOptions: DRMSystemOptions -): MediaKeySystemConfiguration[] { - switch (keySystem) { - case KeySystems.WIDEVINE: - return createMediaKeySystemConfigurations( - 'cenc', - audioCodecs, - videoCodecs, - drmSystemOptions - ); - case KeySystems.FAIRPLAY: - return createMediaKeySystemConfigurations( - 'sinf', - audioCodecs, - videoCodecs, - drmSystemOptions - ); - default: - throw new Error(`Unknown key-system: ${keySystem}`); - } -}; +interface KeySystemAccessPromises { + keySystemAccess: Promise; + mediaKeys?: Promise; + certificate?: Promise; +} -interface MediaKeysListItem { - mediaKeys?: MediaKeys; - mediaKeysSession?: MediaKeySession; - mediaKeysSessionInitialized: boolean; - mediaKeySystemAccess: MediaKeySystemAccess; - mediaKeySystemDomain: KeySystems; +export interface MediaKeySessionContext { + keySystem: KeySystems; + mediaKeys: MediaKeys; + decryptdata: LevelKey; + mediaKeysSession: MediaKeySession; + keyStatus: MediaKeyStatus; + licenseXhr?: XMLHttpRequest; } /** @@ -117,31 +59,29 @@ interface MediaKeysListItem { * @constructor */ class EMEController implements ComponentAPI { - private hls: Hls; - private _widevineLicenseUrl?: string; - private _drmSystems: DRMSystemsConfiguration; - private _licenseXhrSetup?: ( - xhr: XMLHttpRequest, - url: string, - keySystem: KeySystems - ) => void | Promise; - private _licenseResponseCallback?: ( - xhr: XMLHttpRequest, - url: string, - keySystem: KeySystems - ) => ArrayBuffer; - private _emeEnabled: boolean; - private _requestMediaKeySystemAccess: MediaKeyFunc | null; - private _drmSystemOptions: DRMSystemOptions; - - private _config: EMEControllerConfig; - private _mediaKeysList: MediaKeysListItem[] = []; - private _media: HTMLMediaElement | null = null; - private _hasSetMediaKeys: boolean = false; + public static CDMCleanupPromise: Promise | void; + + private readonly hls: Hls; + private readonly config: EMEControllerConfig; + private media: HTMLMediaElement | null = null; + private keyFormatPromise: Promise | null = null; + private keySystemAccessPromises: { + [keysystem: string]: KeySystemAccessPromises; + } = {}; private _requestLicenseFailureCount: number = 0; - - private mediaKeysPromise: Promise | null = null; - private _onMediaEncrypted = this.onMediaEncrypted.bind(this); + private mediaKeySessions: MediaKeySessionContext[] = []; + private keyUriToKeySessionPromise: { + [keyuri: string]: Promise; + } = {}; + private setMediaKeysQueue: Promise[] = EMEController.CDMCleanupPromise + ? [EMEController.CDMCleanupPromise] + : []; + private onMediaEncrypted = this._onMediaEncrypted.bind(this); + private onWaitingForKey = this._onWaitingForKey.bind(this); + + private log: (msg: any) => void = logger.log.bind(logger, LOGGER_PREFIX); + private warn: (msg: any) => void = logger.warn.bind(logger, LOGGER_PREFIX); + private error: (msg: any) => void = logger.error.bind(logger, LOGGER_PREFIX); /** * @constructs @@ -149,54 +89,42 @@ class EMEController implements ComponentAPI { */ constructor(hls: Hls) { this.hls = hls; - this._config = hls.config; - - this._widevineLicenseUrl = this._config.widevineLicenseUrl; - this._drmSystems = this._config.drmSystems; - this._licenseXhrSetup = this._config.licenseXhrSetup; - this._licenseResponseCallback = this._config.licenseResponseCallback; - this._emeEnabled = this._config.emeEnabled; - this._requestMediaKeySystemAccess = - this._config.requestMediaKeySystemAccessFunc; - this._drmSystemOptions = this._config.drmSystemOptions; - - this._registerListeners(); + this.config = hls.config; + this.registerListeners(); } public destroy() { - this._unregisterListeners(); + this.unregisterListeners(); + this.onMediaDetached(); // @ts-ignore - this.hls = this._onMediaEncrypted = null; - this._requestMediaKeySystemAccess = null; + this.hls = + this.onMediaEncrypted = + this.onWaitingForKey = + this.keyUriToKeySessionPromise = + null as any; } - private _registerListeners() { + private registerListeners() { this.hls.on(Events.MEDIA_ATTACHED, this.onMediaAttached, this); this.hls.on(Events.MEDIA_DETACHED, this.onMediaDetached, this); - this.hls.on(Events.MANIFEST_PARSED, this.onManifestParsed, this); } - private _unregisterListeners() { + private unregisterListeners() { this.hls.off(Events.MEDIA_ATTACHED, this.onMediaAttached, this); this.hls.off(Events.MEDIA_DETACHED, this.onMediaDetached, this); - this.hls.off(Events.MANIFEST_PARSED, this.onManifestParsed, this); } - /** - * @param {string} keySystem Identifier for the key-system, see `KeySystems` enum - * @returns {string} License server URL for key-system (if any configured, otherwise causes error) - * @throws if a unsupported keysystem is passed - */ - getLicenseServerUrl(keySystem: KeySystems): string { - const keySystemConfiguration = this._drmSystems[keySystem]; + private getLicenseServerUrl(keySystem: KeySystems): string | never { + const { drmSystems, widevineLicenseUrl } = this.config; + const keySystemConfiguration = drmSystems[keySystem]; if (keySystemConfiguration) { return keySystemConfiguration.licenseUrl; } // For backward compatibility - if (keySystem === KeySystems.WIDEVINE && this._widevineLicenseUrl) { - return this._widevineLicenseUrl; + if (keySystem === KeySystems.WIDEVINE && widevineLicenseUrl) { + return widevineLicenseUrl; } throw new Error( @@ -204,662 +132,980 @@ class EMEController implements ComponentAPI { ); } - getServerCertificateUrl(keySystem: KeySystems): string | undefined { - const keySystemConfiguration = this._drmSystems[keySystem]; + private getServerCertificateUrl(keySystem: KeySystems): string | void { + const { drmSystems } = this.config; + const keySystemConfiguration = drmSystems[keySystem]; if (keySystemConfiguration) { return keySystemConfiguration.serverCertificateUrl; + } else { + this.log(`No Server Certificate in config.drmSystems["${keySystem}"]`); + } + } + + private attemptKeySystemAccess( + keySystemsToAttempt: KeySystems[] + ): Promise<{ keySystem: KeySystems; mediaKeys: MediaKeys }> { + const levels = this.hls.levels; + const uniqueCodec = (value: string | undefined, i, a): value is string => + !!value && a.indexOf(value) === i; + const audioCodecs = levels + .map((level) => level.audioCodec) + .filter(uniqueCodec); + const videoCodecs = levels + .map((level) => level.videoCodec) + .filter(uniqueCodec); + if (audioCodecs.length + videoCodecs.length === 0) { + videoCodecs.push('avc1.42e01e'); } - return undefined; + return new Promise( + ( + resolve: (result: { + keySystem: KeySystems; + mediaKeys: MediaKeys; + }) => void, + reject: (Error) => void + ) => { + let attempts = 0; + const catchAll = (error) => { + attempts++; + if (attempts === keySystemsToAttempt.length) { + if (error instanceof EMEKeyError) { + reject(error); + } else { + reject( + new EMEKeyError( + { + type: ErrorTypes.KEY_SYSTEM_ERROR, + details: ErrorDetails.KEY_SYSTEM_NO_ACCESS, + error, + fatal: true, + }, + error.message + ) + ); + } + } + }; + keySystemsToAttempt.forEach((keySystem) => { + this.getMediaKeysPromise(keySystem, audioCodecs, videoCodecs) + .then((mediaKeys) => resolve({ keySystem, mediaKeys })) + .catch(catchAll); + }); + } + ); } - /** - * Requests access object and adds it to our list upon success - * @private - * @param {string} keySystem System ID (see `KeySystems`) - * @param {Array} audioCodecs List of required audio codecs to support - * @param {Array} videoCodecs List of required video codecs to support - * @throws When a unsupported KeySystem is passed - */ - private _attemptKeySystemAccess( + private requestMediaKeySystemAccess( + keySystem: KeySystems, + supportedConfigurations: MediaKeySystemConfiguration[] + ): Promise { + const { requestMediaKeySystemAccessFunc } = this.config; + if (!(typeof requestMediaKeySystemAccessFunc === 'function')) { + let errMessage = `Configured requestMediaKeySystemAccess is not a function ${requestMediaKeySystemAccessFunc}`; + if ( + requestMediaKeySystemAccess === null && + self.location.protocol === 'http:' + ) { + errMessage = `navigator.requestMediaKeySystemAccess is not available over insecure protocol ${location.protocol}`; + } + return Promise.reject(new Error(errMessage)); + } + + return requestMediaKeySystemAccessFunc(keySystem, supportedConfigurations); + } + + private getMediaKeysPromise( keySystem: KeySystems, audioCodecs: string[], videoCodecs: string[] - ) { + ): Promise { // This can throw, but is caught in event handler callpath const mediaKeySystemConfigs = getSupportedMediaKeySystemConfigurations( keySystem, audioCodecs, videoCodecs, - this._drmSystemOptions - ); - - logger.log('Requesting encrypted media key-system access'); - - // expecting interface like window.navigator.requestMediaKeySystemAccess - const keySystemAccessPromise = this.requestMediaKeySystemAccess( - keySystem, - mediaKeySystemConfigs + this.config.drmSystemOptions ); + const keySystemAccessPromises: KeySystemAccessPromises = + this.keySystemAccessPromises[keySystem]; + let keySystemAccess = keySystemAccessPromises?.keySystemAccess; + if (!keySystemAccess) { + this.log( + `Requesting encrypted media "${keySystem}" key-system access with config: ${JSON.stringify( + mediaKeySystemConfigs + )}` + ); + keySystemAccess = this.requestMediaKeySystemAccess( + keySystem, + mediaKeySystemConfigs + ); + const keySystemAccessPromises: KeySystemAccessPromises = + (this.keySystemAccessPromises[keySystem] = { + keySystemAccess, + }); + keySystemAccess.catch((error) => { + this.log( + `Failed to obtain access to key-system "${keySystem}": ${error}` + ); + }); + return keySystemAccess.then((mediaKeySystemAccess) => { + this.log( + `Access for key-system "${mediaKeySystemAccess.keySystem}" obtained` + ); - this.mediaKeysPromise = keySystemAccessPromise.then( - (mediaKeySystemAccess) => - this._onMediaKeySystemAccessObtained(keySystem, mediaKeySystemAccess) - ); + const certificateRequest = this.fetchServerCertificate(keySystem); + + this.log(`Create media-keys for "${keySystem}"`); + keySystemAccessPromises.mediaKeys = mediaKeySystemAccess + .createMediaKeys() + .then((mediaKeys) => { + this.log(`Media-keys created for "${keySystem}"`); + return certificateRequest.then((certificate) => { + if (certificate) { + return this.setMediaKeysServerCertificate( + mediaKeys, + keySystem, + certificate + ); + } + return mediaKeys; + }); + }); - keySystemAccessPromise.catch((err) => { - logger.error(`Failed to obtain key-system "${keySystem}" access:`, err); - }); - } + keySystemAccessPromises.mediaKeys.catch((error) => { + this.error( + `Failed to create media-keys for "${keySystem}"}: ${error}` + ); + }); - get requestMediaKeySystemAccess() { - if (!this._requestMediaKeySystemAccess) { - throw new Error('No requestMediaKeySystemAccess function configured'); + return keySystemAccessPromises.mediaKeys; + }); } - - return this._requestMediaKeySystemAccess; + return keySystemAccess.then(() => keySystemAccessPromises.mediaKeys!); } - /** - * Handles obtaining access to a key-system - * @private - * @param {string} keySystem - * @param {MediaKeySystemAccess} mediaKeySystemAccess https://developer.mozilla.org/en-US/docs/Web/API/MediaKeySystemAccess - */ - private _onMediaKeySystemAccessObtained( - keySystem: KeySystems, - mediaKeySystemAccess: MediaKeySystemAccess - ): Promise { - logger.log(`Access for key-system "${keySystem}" obtained`); + private createMediaKeySessionContext({ + decryptdata, + keySystem, + mediaKeys, + }: { + decryptdata: LevelKey; + keySystem: KeySystems; + mediaKeys: MediaKeys; + }): MediaKeySessionContext { + console.assert(!!mediaKeys, 'mediaKeys is defined'); + + this.log( + `Creating key-system session "${keySystem}" uri: ${decryptdata.uri}` + ); + + const mediaKeysSession = mediaKeys.createSession(); - const mediaKeysListItem: MediaKeysListItem = { - mediaKeysSessionInitialized: false, - mediaKeySystemAccess: mediaKeySystemAccess, - mediaKeySystemDomain: keySystem, + const mediaKeySessionContext: MediaKeySessionContext = { + decryptdata, + keySystem, + mediaKeys, + mediaKeysSession, + keyStatus: 'status-pending', }; - this._mediaKeysList.push(mediaKeysListItem); + this.mediaKeySessions.push(mediaKeySessionContext); - const mediaKeysPromise = Promise.resolve() - .then(() => mediaKeySystemAccess.createMediaKeys()) - .then((mediaKeys) => { - mediaKeysListItem.mediaKeys = mediaKeys; + return mediaKeySessionContext; + } - logger.log(`Media-keys created for key-system "${keySystem}"`); + private renewKeySession(mediaKeySessionContext: MediaKeySessionContext) { + const decryptdata = mediaKeySessionContext.decryptdata; + this.keyUriToKeySessionPromise[decryptdata.uri] = + this.generateRequestWithPreferredKeySession( + mediaKeySessionContext, + 'cenc', + decryptdata.pssh + ); + } - return this._fetchAndSetServerCertificate(mediaKeysListItem).then( - () => { - this._onMediaKeysCreated(); + private handleParsedKeyResponse( + mediaKeySessionContext: MediaKeySessionContext, + licenseResponse: ArrayBuffer + ): Uint8Array { + switch (mediaKeySessionContext.keySystem) { + case KeySystems.FAIRPLAY: { + const responseStr = JSON.stringify([ + { + keyID: base64Encode( + mediaKeySessionContext.decryptdata?.keyId as Uint8Array + ), + payload: base64Encode(new Uint8Array(licenseResponse)), + }, + ]); + this.log(`processLicense msg=${responseStr}`); + return strToUtf8array(responseStr); + } + } + return new Uint8Array(licenseResponse); + } + + private updateKeySession( + mediaKeySessionContext: MediaKeySessionContext, + data: Uint8Array + ): Promise { + const keySession = mediaKeySessionContext.mediaKeysSession; + this.log( + `Updating key-session "${keySession.sessionId}" for ${ + mediaKeySessionContext.decryptdata?.uri + } (data length: ${data ? data.byteLength : data})` + ); + return keySession.update(data); + } - return mediaKeys; + public selectKeySystemFormat(frag: Fragment): Promise { + const keyFormats = Object.keys(frag.levelkeys || {}); + if (!this.keyFormatPromise) { + this.log( + `Selecting key-system from fragment (sn: ${frag.sn} ${frag.type}: ${ + frag.level + }) key formats ${keyFormats.join(', ')}` + ); + this.keyFormatPromise = new Promise((resolve, reject) => { + const keySystemsToAttempt = keyFormats + .map(keySystemFormatToKeySystemDomain) + .filter((value) => !!value) as any as KeySystems[]; + return this.getKeySystemSelectionPromise(keySystemsToAttempt).then( + ({ keySystem }) => { + const keySystemFormat = keySystemToKeySystemFormat(keySystem); + if (keySystemFormat) { + resolve(keySystemFormat); + } else { + reject( + new Error(`Unable to find format for key-system "${keySystem}"`) + ); + } } ); }); - - mediaKeysPromise.catch((err) => { - logger.error('Failed to create media-keys:', err); - }); - - return mediaKeysPromise; + } + return this.keyFormatPromise; } - /** - * Handles key-creation (represents access to CDM). We are going to create key-sessions upon this - * for all existing keys where no session exists yet. - * - * @private - */ - private _onMediaKeysCreated() { - // check for all key-list items if a session exists, otherwise, create one - this._mediaKeysList.forEach((mediaKeysListItem) => { - if (!mediaKeysListItem.mediaKeysSession) { - // mediaKeys is definitely initialized here - mediaKeysListItem.mediaKeysSession = - mediaKeysListItem.mediaKeys!.createSession(); - this._onNewMediaKeySession(mediaKeysListItem.mediaKeysSession); - } - }); - } + public loadKey(data: KeyLoadedData): Promise { + const decryptdata = data.keyInfo.decryptdata; - /** - * @private - * @param {*} keySession - */ - private _onNewMediaKeySession(keySession: MediaKeySession) { - logger.log(`New key-system session ${keySession.sessionId}`); - - keySession.addEventListener( - 'message', - (event: MediaKeyMessageEvent) => { - this._onKeySessionMessage(keySession, event.message); - }, - false + this.log( + `Starting session for key ${decryptdata.keyFormat} ${decryptdata.uri}` ); - } - /** - * @private - * @param {MediaKeySession} keySession - * @param {ArrayBuffer} message - */ - private _onKeySessionMessage( - keySession: MediaKeySession, - message: ArrayBuffer - ) { - logger.log('Got EME message event, creating license request'); + if (this.media && !this.config.useEmeEncryptedEvent) { + this.media.removeEventListener('encrypted', this.onMediaEncrypted); + } + + let keySessionContextPromise = + this.keyUriToKeySessionPromise[decryptdata.uri]; + if (!keySessionContextPromise) { + keySessionContextPromise = this.keyUriToKeySessionPromise[ + decryptdata.uri + ] = this.getKeySystemForKeyPromise(decryptdata).then( + ({ keySystem, mediaKeys }) => { + this.throwIfDestroyed(); + this.log( + `Handle encrypted media sn: ${data.frag.sn} ${data.frag.type}: ${data.frag.level} using key (method: ${decryptdata.method} format: "${decryptdata.keyFormat}" uri: ${decryptdata.uri})` + ); - this._requestLicense(message, (data: ArrayBuffer) => { - logger.log( - `Received license data (length: ${ - data ? data.byteLength : data - }), updating key-session` + return this.attemptSetMediaKeys(keySystem, mediaKeys).then(() => { + this.throwIfDestroyed(); + const keySessionContext = this.createMediaKeySessionContext({ + keySystem, + mediaKeys, + decryptdata, + }); + if (this.config.useEmeEncryptedEvent) { + // Use 'encrypted' event initData and type rather than 'cenc' pssh from level-key + return keySessionContext; + } + return this.generateRequestWithPreferredKeySession( + keySessionContext, + 'cenc', + decryptdata.pssh + ); + }); + } ); - keySession.update(data).catch((error) => { - logger.error('Fatal: KeySession rejected data update', error); + keySessionContextPromise.catch((error) => this.handleError(error)); + } - this.hls.trigger(Events.ERROR, { - type: ErrorTypes.KEY_SYSTEM_ERROR, - details: ErrorDetails.KEY_SYSTEM_SESSION_UPDATE_FAILED, - fatal: true, - error, - }); - }); - }); + return keySessionContextPromise; } - /** - * @private - * @param e {MediaEncryptedEvent} - */ - private onMediaEncrypted(e: MediaEncryptedEvent) { - logger.log(`Media is encrypted using "${e.initDataType}" init data type`); + private throwIfDestroyed(message = 'Invalid state'): void | never { + if (!this.hls) { + throw new Error('invalid state'); + } + } - if (!this.mediaKeysPromise) { - logger.error( - 'Fatal: Media is encrypted but no CDM access or no keys have been requested' - ); + private handleError(error: EMEKeyError | Error) { + if (!this.hls) { + return; + } + this.error(error.message); + if (error instanceof EMEKeyError) { + this.hls.trigger(Events.ERROR, error.data); + } else { this.hls.trigger(Events.ERROR, { type: ErrorTypes.KEY_SYSTEM_ERROR, details: ErrorDetails.KEY_SYSTEM_NO_KEYS, + error, fatal: true, }); - return; } - - const finallySetKeyAndStartSession = (mediaKeys) => { - if (!this._media) { - return; - } - this._attemptSetMediaKeys(mediaKeys); - this._generateRequestWithPreferredKeySession(e.initDataType, e.initData); - }; - - // Could use `Promise.finally` but some Promise polyfills are missing it - this.mediaKeysPromise - .then(finallySetKeyAndStartSession) - .catch(finallySetKeyAndStartSession); } - /** - * @private - */ - private _attemptSetMediaKeys(mediaKeys?: MediaKeys) { - if (!this._media) { - throw new Error( - 'Attempted to set mediaKeys without first attaching a media element' + private getKeySystemForKeyPromise( + decryptdata: LevelKey + ): Promise<{ keySystem: KeySystems; mediaKeys: MediaKeys }> { + const mediaKeySessionContext = + this.keyUriToKeySessionPromise[decryptdata.uri]; + if (!mediaKeySessionContext) { + const keySystem = keySystemFormatToKeySystemDomain( + decryptdata.keyFormat as KeySystemFormats ); + const keySystemsToAttempt = keySystem + ? [keySystem] + : getKeySystemsForConfig(this.config); + return this.attemptKeySystemAccess(keySystemsToAttempt); } + return mediaKeySessionContext; + } - if (!this._hasSetMediaKeys) { - // FIXME: see if we can/want/need-to really to deal with several potential key-sessions? - const keysListItem = this._mediaKeysList[0]; - if (!keysListItem || !keysListItem.mediaKeys) { - logger.error( - 'Fatal: Media is encrypted but no CDM access or no keys have been obtained yet' - ); - this.hls.trigger(Events.ERROR, { - type: ErrorTypes.KEY_SYSTEM_ERROR, - details: ErrorDetails.KEY_SYSTEM_NO_KEYS, - fatal: true, - }); - return; - } - - logger.log('Setting keys for encrypted media'); - - this._media.setMediaKeys(keysListItem.mediaKeys); - this._hasSetMediaKeys = true; + private getKeySystemSelectionPromise( + keySystemsToAttempt?: KeySystems[] + ): Promise<{ keySystem: KeySystems; mediaKeys: MediaKeys }> { + if (!keySystemsToAttempt || !keySystemsToAttempt.length) { + keySystemsToAttempt = getKeySystemsForConfig(this.config); } + return this.attemptKeySystemAccess(keySystemsToAttempt); } - /** - * @private - */ - private _generateRequestWithPreferredKeySession( - initDataType: string, - initData: ArrayBuffer | null - ) { - // FIXME: see if we can/want/need-to really to deal with several potential key-sessions? - const keysListItem = this._mediaKeysList[0]; - if (!keysListItem) { - logger.error( - 'Fatal: Media is encrypted but not any key-system access has been obtained yet' - ); - this.hls.trigger(Events.ERROR, { - type: ErrorTypes.KEY_SYSTEM_ERROR, - details: ErrorDetails.KEY_SYSTEM_NO_ACCESS, - fatal: true, - }); - return; - } + private _onMediaEncrypted(event: MediaEncryptedEvent) { + this.log(`"${event.type}" event: init data type: "${event.initDataType}"`); - if (keysListItem.mediaKeysSessionInitialized) { - logger.warn('Key-Session already initialized but requested again'); + if (!this.config.useEmeEncryptedEvent) { return; } - const keySession = keysListItem.mediaKeysSession; - if (!keySession) { - logger.error('Fatal: Media is encrypted but no key-session existing'); - this.hls.trigger(Events.ERROR, { - type: ErrorTypes.KEY_SYSTEM_ERROR, - details: ErrorDetails.KEY_SYSTEM_NO_SESSION, - fatal: true, - }); - return; + let keySessionContextPromise = this.keyUriToKeySessionPromise.encrypted; + if (!keySessionContextPromise) { + keySessionContextPromise = this.keyUriToKeySessionPromise.encrypted = + this.getKeySystemSelectionPromise().then(({ keySystem, mediaKeys }) => { + this.throwIfDestroyed(); + const sessionParameters = { + decryptdata: new LevelKey( + 'UNKNOWN', + 'encrypted', + keySystemToKeySystemFormat(keySystem) ?? '' + ), + keySystem, + mediaKeys, + }; + return this.attemptSetMediaKeys(keySystem, mediaKeys).then(() => { + this.throwIfDestroyed(); + const keySessionContext = + this.createMediaKeySessionContext(sessionParameters); + return this.generateRequestWithPreferredKeySession( + keySessionContext, + event.initDataType, + event.initData + ); + }); + }); } + keySessionContextPromise.catch((error) => this.handleError(error)); + } + + private _onWaitingForKey(event: Event) { + this.log(`"${event.type}" event`); + } + + private attemptSetMediaKeys( + keySystem: KeySystems, + mediaKeys: MediaKeys + ): Promise { + const queue = this.setMediaKeysQueue.slice(); + + this.log(`Setting media-keys for "${keySystem}"`); + // Only one setMediaKeys() can run at one time, and multiple setMediaKeys() operations + // can be queued for execution for multiple key sessions. + const setMediaKeysPromise = Promise.all(queue).then(() => { + if (!this.media) { + throw new Error( + 'Attempted to set mediaKeys without media element attached' + ); + } + return this.media.setMediaKeys(mediaKeys); + }); + this.setMediaKeysQueue.push(setMediaKeysPromise); + return setMediaKeysPromise.then(() => { + this.log(`Media-keys set for "${keySystem}"`); + queue.push(setMediaKeysPromise!); + this.setMediaKeysQueue = this.setMediaKeysQueue.filter( + (p) => queue.indexOf(p) === -1 + ); + }); + } - // initData is null if the media is not CORS-same-origin + private generateRequestWithPreferredKeySession( + context: MediaKeySessionContext, + initDataType: string, + initData: ArrayBuffer | null + ): Promise | never { if (!initData) { - logger.warn( + throw new EMEKeyError( + { + type: ErrorTypes.KEY_SYSTEM_ERROR, + details: ErrorDetails.KEY_SYSTEM_NO_INIT_DATA, + fatal: true, + }, 'Fatal: initData required for generating a key session is null' ); - this.hls.trigger(Events.ERROR, { - type: ErrorTypes.KEY_SYSTEM_ERROR, - details: ErrorDetails.KEY_SYSTEM_NO_INIT_DATA, - fatal: true, - }); - return; } - logger.log( - `Generating key-session request for "${initDataType}" init data type` + this.log( + `Generating key-session request for ${ + context.decryptdata?.uri + } (init data type: ${initDataType} length: ${ + initData ? initData.byteLength : null + })` ); - keysListItem.mediaKeysSessionInitialized = true; - keySession + const licensedPromise = new Promise((resolve, reject) => { + context.mediaKeysSession.onmessage = (event: MediaKeyMessageEvent) => { + if (!context.mediaKeysSession) { + return reject(new Error('invalid state')); + } + const { messageType, message } = event; + this.log( + `"${messageType}" message event for session "${context.mediaKeysSession.sessionId}" message size: ${message.byteLength}` + ); + if ( + messageType === 'license-request' || + messageType === 'license-renewal' + ) { + this.renewLicense(context, message).then(resolve).catch(reject); + } else { + this.warn(`unhandled media key message type "${messageType}"`); + } + }; + }); + + const keyUsablePromise = new Promise( + (resolve: (value: MediaKeySessionContext) => void, reject) => { + context.mediaKeysSession.onkeystatuseschange = ( + event: MediaKeyMessageEvent + ) => { + if (!context.mediaKeysSession) { + return reject(new Error('invalid state')); + } + this.onKeyStatusChange(context); + const keyStatus = context.keyStatus; + if (keyStatus.startsWith('usable')) { + resolve(context); + } else if (keyStatus === 'output-restricted') { + reject( + new EMEKeyError( + { + type: ErrorTypes.KEY_SYSTEM_ERROR, + details: ErrorDetails.KEY_SYSTEM_STATUS_OUTPUT_RESTRICTED, + fatal: false, + }, + 'HDCP level output restricted' + ) + ); + } else if (keyStatus === 'internal-error') { + reject( + new EMEKeyError( + { + type: ErrorTypes.KEY_SYSTEM_ERROR, + details: ErrorDetails.KEY_SYSTEM_STATUS_INTERNAL_ERROR, + fatal: true, + }, + `key status changed to "${keyStatus}"` + ) + ); + } else if (keyStatus === 'expired') { + reject(new Error('key expired while generating request')); + } else { + this.warn(`unhandled key status change "${keyStatus}"`); + } + }; + } + ); + + return context.mediaKeysSession .generateRequest(initDataType, initData) .then(() => { - logger.debug('Key-session generation succeeded'); + this.log( + `Key-session generation succeeded for "${context.mediaKeysSession?.sessionId}" ${context.decryptdata?.uri}` + ); + return context; }) - .catch((err) => { - logger.error('Error generating key-session request:', err); - this.hls.trigger(Events.ERROR, { - type: ErrorTypes.KEY_SYSTEM_ERROR, - details: ErrorDetails.KEY_SYSTEM_NO_SESSION, - fatal: false, - }); + .catch((error) => { + throw new EMEKeyError( + { + type: ErrorTypes.KEY_SYSTEM_ERROR, + details: ErrorDetails.KEY_SYSTEM_NO_SESSION, + error, + fatal: false, + }, + `Error generating key-session request: ${error}` + ); + }) + .then(() => licensedPromise) + .then(() => keyUsablePromise) + .catch((error) => { + this.removeSession(context); + throw error; + }) + .then((mediaKeySessionContext) => { + context.mediaKeysSession.onmessage = (event: MediaKeyMessageEvent) => { + const keySession = mediaKeySessionContext.mediaKeysSession; + if (keySession) { + const { messageType, message } = event; + this.log( + `"${messageType}" message event for session "${mediaKeySessionContext.mediaKeysSession.sessionId}" message size: ${message.byteLength}` + ); + if ( + messageType === 'license-request' || + messageType === 'license-renewal' + ) { + this.renewLicense(context, message).catch((error) => { + if ('data' in error) { + // We can fail to retrieve a new license and still continue, future key requests may succeed. + error.data.fatal = false; + } + this.handleError(error); + }); + } else if (messageType === 'license-release') { + if (mediaKeySessionContext.keySystem === KeySystems.FAIRPLAY) { + this.updateKeySession( + mediaKeySessionContext, + strToUtf8array('acknowledged') + ); + this.removeSession(mediaKeySessionContext); + } + } else { + this.warn(`unhandled media key message type "${messageType}"`); + } + } + }; + mediaKeySessionContext.mediaKeysSession.onkeystatuseschange = ( + event: MediaKeyMessageEvent + ) => { + const keySession = mediaKeySessionContext.mediaKeysSession; + if (keySession) { + this.onKeyStatusChange(mediaKeySessionContext); + const keyStatus = mediaKeySessionContext.keyStatus; + if (keyStatus === 'expired') { + this.warn( + `${mediaKeySessionContext.keySystem} expired for key ${mediaKeySessionContext.decryptdata.uri}` + ); + this.renewKeySession(mediaKeySessionContext); + } + } + }; + return mediaKeySessionContext; }); } - /** - * @private - * @param {MediaKeysListItem} mediaKeysListItem - * @returns Promise - */ - private _fetchAndSetServerCertificate( - mediaKeysListItem: MediaKeysListItem - ): Promise { - const url = this.getServerCertificateUrl( - mediaKeysListItem.mediaKeySystemDomain - ); - - if (!url) { - return Promise.resolve(); - } - logger.log( - `Fetching serverCertificate for ${mediaKeysListItem.mediaKeySystemDomain} keySystem` + private onKeyStatusChange(mediaKeySessionContext: MediaKeySessionContext) { + mediaKeySessionContext.mediaKeysSession.keyStatuses.forEach( + (status: MediaKeyStatus, keyId: BufferSource) => { + this.log( + `key status change "${status}" for keyStatuses keyId: ${Hex.hexDump( + keyId + )} session keyId: ${Hex.hexDump( + mediaKeySessionContext.decryptdata.keyId + )} uri: ${mediaKeySessionContext.decryptdata.uri}` + ); + mediaKeySessionContext.keyStatus = status; + } ); + } + private fetchServerCertificate( + keySystem: KeySystems + ): Promise { return new Promise((resolve, reject) => { + const url = this.getServerCertificateUrl(keySystem); + if (!url) { + return resolve(); + } + this.log(`Fetching serverCertificate for "${keySystem}"`); const xhr = new XMLHttpRequest(); - xhr.open('GET', url, true); xhr.responseType = 'arraybuffer'; - xhr.onreadystatechange = () => { - switch (xhr.readyState) { - case XMLHttpRequest.DONE: - if (xhr.status === 200) { - mediaKeysListItem.mediaKeys - ?.setServerCertificate(xhr.response) - .then(() => { - logger.log('serverCertificate successfully fetched and set'); - - resolve(); - }) - .catch((error) => { - this.hls.trigger(Events.ERROR, { - type: ErrorTypes.KEY_SYSTEM_ERROR, - details: - ErrorDetails.KEY_SYSTEM_SERVER_CERTIFICATE_UPDATE_FAILED, - fatal: true, - error, - }); - - reject(error); - }); - } else { - logger.error( + if (xhr.readyState === XMLHttpRequest.DONE) { + if (xhr.status === 200) { + resolve(xhr.response); + } else { + reject( + new EMEKeyError( + { + type: ErrorTypes.KEY_SYSTEM_ERROR, + details: + ErrorDetails.KEY_SYSTEM_SERVER_CERTIFICATE_REQUEST_FAILED, + fatal: true, + networkDetails: xhr, + }, `HTTP error ${xhr.status} happened while fetching server certificate` - ); + ) + ); + } + } + }; + xhr.send(); + }); + } - this.hls.trigger(Events.ERROR, { + private setMediaKeysServerCertificate( + mediaKeys: MediaKeys, + keySystem: KeySystems, + cert: BufferSource + ): Promise { + return new Promise((resolve, reject) => { + mediaKeys + .setServerCertificate(cert) + .then((success) => { + this.log( + `setServerCertificate ${ + success ? 'success' : 'not supported by CDM' + } (${cert?.byteLength}) on "${keySystem}"` + ); + resolve(mediaKeys); + }) + .catch((error) => { + reject( + new EMEKeyError( + { type: ErrorTypes.KEY_SYSTEM_ERROR, details: - ErrorDetails.KEY_SYSTEM_SERVER_CERTIFICATE_REQUEST_FAILED, + ErrorDetails.KEY_SYSTEM_SERVER_CERTIFICATE_UPDATE_FAILED, + error, fatal: true, - }); - - reject(new Error(xhr.response)); - } - break; - } - }; - - xhr.send(); + }, + error.message + ) + ); + }); }); } - /** - * @private - * @param {XMLHttpRequest} xhr - * @param {string} url - * @returns Promise - */ - private _setupLicenseXHR = ( + private renewLicense( + context: MediaKeySessionContext, + keyMessage: ArrayBuffer + ): Promise { + const licenseChallenge = this.generateLicenseRequestChallenge( + context, + keyMessage + ); + return this.requestLicense(context, licenseChallenge).then( + (data: ArrayBuffer) => { + const licenseResponse: Uint8Array = this.handleParsedKeyResponse( + context, + data + ); + return this.updateKeySession(context, licenseResponse).catch( + (error) => { + throw new EMEKeyError( + { + type: ErrorTypes.KEY_SYSTEM_ERROR, + details: ErrorDetails.KEY_SYSTEM_SESSION_UPDATE_FAILED, + error, + fatal: true, + }, + error.message + ); + } + ); + } + ); + } + + private setupLicenseXHR( xhr: XMLHttpRequest, url: string, - keysListItem: MediaKeysListItem - ): Promise => { - const licenseXhrSetup = this._licenseXhrSetup; + keysListItem: MediaKeySessionContext, + licenseChallenge: Uint8Array + ): Promise<{ xhr: XMLHttpRequest; licenseChallenge: Uint8Array }> { + const licenseXhrSetup = this.config.licenseXhrSetup; if (!licenseXhrSetup) { xhr.open('POST', url, true); - return Promise.resolve(); + return Promise.resolve({ xhr, licenseChallenge }); } - return Promise.resolve( - licenseXhrSetup(xhr, url, keysListItem.mediaKeySystemDomain) - ) + return Promise.resolve() + .then(() => { + return licenseXhrSetup.call( + this.hls, + xhr, + url, + keysListItem, + licenseChallenge + ); + }) .catch(() => { // let's try to open before running setup xhr.open('POST', url, true); - return licenseXhrSetup(xhr, url, keysListItem.mediaKeySystemDomain); + return licenseXhrSetup.call( + this.hls, + xhr, + url, + keysListItem, + licenseChallenge + ); }) - .then(() => { + .then((licenseXhrSetupResult) => { // if licenseXhrSetup did not yet call open, let's do it now if (!xhr.readyState) { xhr.open('POST', url, true); } - }) - .catch((e) => { - // IE11 throws an exception on xhr.open if attempting to access an HTTP resource over HTTPS - return Promise.reject( - new Error(`issue setting up KeySystem license XHR ${e}`) - ); + const finalLicenseChallenge = licenseXhrSetupResult + ? licenseXhrSetupResult + : licenseChallenge; + return { xhr, licenseChallenge: finalLicenseChallenge }; }); - }; - - /** - * @private - * @param {string} url License server URL - * @param {ArrayBuffer} keyMessage Message data issued by key-system - * @param {function} callback Called when XHR has succeeded - * @returns {XMLHttpRequest} Unsent (but opened state) XHR object - * @throws if XMLHttpRequest construction failed - */ - private _createLicenseXhr( - keysListItem: MediaKeysListItem, - keyMessage: ArrayBuffer, - callback: (data: ArrayBuffer) => void - ): Promise { - const url = this.getLicenseServerUrl(keysListItem.mediaKeySystemDomain); - - logger.log(`Sending license request to URL: ${url}`); - - const xhr = new XMLHttpRequest(); - xhr.responseType = 'arraybuffer'; - xhr.onreadystatechange = this._onLicenseRequestReadyStageChange.bind( - this, - xhr, - url, - keysListItem, - keyMessage, - callback - ); - - return this._setupLicenseXHR(xhr, url, keysListItem).then(() => xhr); } - /** - * @private - * @param {XMLHttpRequest} xhr - * @param {string} url License server URL - * @param {ArrayBuffer} keyMessage Message data issued by key-system - * @param {function} callback Called when XHR has succeeded - */ - private _onLicenseRequestReadyStageChange( - xhr: XMLHttpRequest, - url: string, - keysListItem: MediaKeysListItem, - keyMessage: ArrayBuffer, - callback: (data: ArrayBuffer) => void - ) { - switch (xhr.readyState) { - case 4: - if (xhr.status === 200) { - this._requestLicenseFailureCount = 0; - logger.log('License request succeeded'); - let data: ArrayBuffer = xhr.response; - const licenseResponseCallback = this._licenseResponseCallback; - if (licenseResponseCallback) { - try { - data = licenseResponseCallback.call( - this.hls, - xhr, - url, - keysListItem.mediaKeySystemDomain + private requestLicense( + keySessionContext: MediaKeySessionContext, + licenseChallenge: Uint8Array + ): Promise { + return new Promise((resolve, reject) => { + const url = this.getLicenseServerUrl(keySessionContext.keySystem); + this.log(`Sending license request to URL: ${url}`); + const xhr = new XMLHttpRequest(); + xhr.responseType = 'arraybuffer'; + xhr.onreadystatechange = () => { + if (!this.hls || !keySessionContext.mediaKeysSession) { + return reject(new Error('invalid state')); + } + if (xhr.readyState === 4) { + if (xhr.status === 200) { + this._requestLicenseFailureCount = 0; + let data = xhr.response; + this.log( + `License received ${ + data instanceof ArrayBuffer ? data.byteLength : data + }` + ); + const licenseResponseCallback = this.config.licenseResponseCallback; + if (licenseResponseCallback) { + try { + data = licenseResponseCallback.call( + this.hls, + xhr, + url, + keySessionContext + ); + } catch (error) { + this.error(error); + } + } + resolve(data); + } else { + const error = new Error( + `License Request XHR failed (${url}). Status: ${xhr.status} (${xhr.statusText})` + ); + this._requestLicenseFailureCount++; + if ( + this._requestLicenseFailureCount > MAX_LICENSE_REQUEST_FAILURES + ) { + reject( + new EMEKeyError( + { + type: ErrorTypes.KEY_SYSTEM_ERROR, + details: ErrorDetails.KEY_SYSTEM_LICENSE_REQUEST_FAILED, + error, + fatal: true, + networkDetails: xhr, + }, + error.message + ) + ); + } else { + const attemptsLeft = + MAX_LICENSE_REQUEST_FAILURES - + this._requestLicenseFailureCount + + 1; + this.warn( + `Retrying license request, ${attemptsLeft} attempts left` + ); + this.requestLicense(keySessionContext, licenseChallenge).then( + resolve, + reject ); - } catch (e) { - logger.error(e); } } - callback(data); - } else { - logger.error( - `License Request XHR failed (${url}). Status: ${xhr.status} (${xhr.statusText})` - ); - this._requestLicenseFailureCount++; - if (this._requestLicenseFailureCount > MAX_LICENSE_REQUEST_FAILURES) { - this.hls.trigger(Events.ERROR, { - type: ErrorTypes.KEY_SYSTEM_ERROR, - details: ErrorDetails.KEY_SYSTEM_LICENSE_REQUEST_FAILED, - fatal: true, - }); - return; - } + } + }; + if ( + keySessionContext.licenseXhr && + keySessionContext.licenseXhr.readyState !== XMLHttpRequest.DONE + ) { + keySessionContext.licenseXhr.abort(); + } + keySessionContext.licenseXhr = xhr; - const attemptsLeft = - MAX_LICENSE_REQUEST_FAILURES - this._requestLicenseFailureCount + 1; - logger.warn( - `Retrying license request, ${attemptsLeft} attempts left` - ); - this._requestLicense(keyMessage, callback); + this.setupLicenseXHR(xhr, url, keySessionContext, licenseChallenge).then( + ({ xhr, licenseChallenge }) => { + xhr.send(licenseChallenge); } - break; - } + ); + }); } - /** - * @private - * @param {MediaKeysListItem} keysListItem - * @param {ArrayBuffer} keyMessage - * @returns {ArrayBuffer} Challenge data posted to license server - * @throws if KeySystem is unsupported - */ - private _generateLicenseRequestChallenge( - keysListItem: MediaKeysListItem, + private generateLicenseRequestChallenge( + keySessionContext: MediaKeySessionContext, keyMessage: ArrayBuffer - ): ArrayBuffer { - switch (keysListItem.mediaKeySystemDomain) { - // case KeySystems.PLAYREADY: - // from https://github.com/MicrosoftEdge/Demos/blob/master/eme/scripts/demo.js - /* - if (this.licenseType !== this.LICENSE_TYPE_WIDEVINE) { - // For PlayReady CDMs, we need to dig the Challenge out of the XML. - var keyMessageXml = new DOMParser().parseFromString(String.fromCharCode.apply(null, new Uint16Array(keyMessage)), 'application/xml'); - if (keyMessageXml.getElementsByTagName('Challenge')[0]) { - challenge = atob(keyMessageXml.getElementsByTagName('Challenge')[0].childNodes[0].nodeValue); - } else { - throw 'Cannot find in key message'; - } - var headerNames = keyMessageXml.getElementsByTagName('name'); - var headerValues = keyMessageXml.getElementsByTagName('value'); - if (headerNames.length !== headerValues.length) { - throw 'Mismatched header / pair in key message'; - } - for (var i = 0; i < headerNames.length; i++) { - xhr.setRequestHeader(headerNames[i].childNodes[0].nodeValue, headerValues[i].childNodes[0].nodeValue); + ): Uint8Array | never { + const message = new Uint8Array(keyMessage); + switch (keySessionContext.keySystem) { + case KeySystems.FAIRPLAY: { + if (keySessionContext.decryptdata?.keyId) { + const messageJson = utf8ArrayToStr(message); + try { + const spcArray = JSON.parse(messageJson); + const keyID = base64Encode(keySessionContext.decryptdata.keyId); + // this.log(`License challenge message with key IDs: ${spcArray.map(p => p.keyID).join(', ')}`); + for (let i = 0; i < spcArray.length; i++) { + const payload = spcArray[i]; + if (payload.keyID === keyID) { + this.log( + `Generateing license challenge with ID ${payload.keyID}` + ); + const spc = base64Decode(payload.payload); + return spc; + } + } + } catch (error) { + this.warn('got unexpected license-request format'); + } } + return message; } - break; - */ - // For Widevine and Fairplay CDMs, the challenge is the keyMessage. - case KeySystems.FAIRPLAY: case KeySystems.WIDEVINE: - return keyMessage; + case KeySystems.PLAYREADY: + case KeySystems.CLEARKEY: + return message; + default: + throw new Error( + `unsupported key-system: ${keySessionContext.keySystem}` + ); } - - throw new Error( - `unsupported key-system: ${keysListItem.mediaKeySystemDomain}` - ); } - private _onLicenseRequestError(error) { - logger.error(`Failure requesting DRM license: ${error}`); - - this.hls.trigger(Events.ERROR, { - type: ErrorTypes.KEY_SYSTEM_ERROR, - details: ErrorDetails.KEY_SYSTEM_LICENSE_REQUEST_FAILED, - fatal: true, - }); - } - - /** - * @private - * @param keyMessage - * @param callback - */ - private _requestLicense( - keyMessage: ArrayBuffer, - callback: (data: ArrayBuffer) => void + private onMediaAttached( + event: Events.MEDIA_ATTACHED, + data: MediaAttachedData ) { - logger.log('Requesting content license for key-system'); - - const keysListItem = this._mediaKeysList[0]; - if (!keysListItem) { - logger.error( - 'Fatal error: Media is encrypted but no key-system access has been obtained yet' - ); - this.hls.trigger(Events.ERROR, { - type: ErrorTypes.KEY_SYSTEM_ERROR, - details: ErrorDetails.KEY_SYSTEM_NO_ACCESS, - fatal: true, - }); - return; - } - - try { - this._createLicenseXhr(keysListItem, keyMessage, callback) - .then((xhr) => { - const challenge = this._generateLicenseRequestChallenge( - keysListItem, - keyMessage - ); - xhr.send(challenge); - }) - .catch((error) => { - this._onLicenseRequestError(error); - }); - } catch (e) { - this._onLicenseRequestError(e); - } - } - - onMediaAttached(event: Events.MEDIA_ATTACHED, data: MediaAttachedData) { - if (!this._emeEnabled) { + if (!this.config.emeEnabled) { return; } const media = data.media; // keep reference of media - this._media = media; + this.media = media; - media.addEventListener('encrypted', this._onMediaEncrypted); + if (this.config.useEmeEncryptedEvent) { + media.addEventListener('encrypted', this.onMediaEncrypted); + } + media.addEventListener('waitingforkey', this.onWaitingForKey); } - onMediaDetached() { - const media = this._media; - const mediaKeysList = this._mediaKeysList; - if (!media) { - return; + private onMediaDetached() { + const media = this.media; + const mediaKeysList = this.mediaKeySessions; + if (media) { + media.removeEventListener('encrypted', this.onMediaEncrypted); + media.removeEventListener('waitingforkey', this.onWaitingForKey); + this.media = null; } - media.removeEventListener('encrypted', this._onMediaEncrypted); - this._media = null; - this._mediaKeysList = []; + + this._requestLicenseFailureCount = 0; + this.setMediaKeysQueue = []; + this.mediaKeySessions = []; + this.keyUriToKeySessionPromise = {}; + LevelKey.clearKeyUriToKeyIdMap(); + // Close all sessions and remove media keys from the video element. - Promise.all( - mediaKeysList.map((mediaKeysListItem) => { - if (mediaKeysListItem.mediaKeysSession) { - return mediaKeysListItem.mediaKeysSession.close().catch(() => { - // Ignore errors when closing the sessions. Closing a session that - // generated no key requests will throw an error. - }); - } - }) + EMEController.CDMCleanupPromise = Promise.all( + mediaKeysList + .map((mediaKeySessionContext) => + this.removeSession(mediaKeySessionContext) + ) + .concat( + media?.setMediaKeys(null).catch((error) => { + this.log( + `Could not clear media keys: ${error}. media.src: ${media?.src}` + ); + }) + ) ) .then(() => { - return media.setMediaKeys(null); + if (mediaKeysList.length) { + this.log('finished closing key sessions and clearing media keys'); + mediaKeysList.length = 0; + } }) - .catch(() => { - // Ignore any failures while removing media keys from the video element. + .catch((error) => { + this.log( + `Could not close sessions and clear media keys: ${error}. media.src: ${media?.src}` + ); }); } - onManifestParsed(event: Events.MANIFEST_PARSED, data: ManifestParsedData) { - if (!this._emeEnabled) { - return; - } - - const audioCodecs = data.levels - .map((level) => level.audioCodec) - .filter( - (audioCodec: string | undefined): audioCodec is string => !!audioCodec - ); - const videoCodecs = data.levels - .map((level) => level.videoCodec) - .filter( - (videoCodec: string | undefined): videoCodec is string => !!videoCodec + removeSession( + mediaKeySessionContext: MediaKeySessionContext + ): Promise | void { + const { mediaKeysSession, licenseXhr } = mediaKeySessionContext; + if (mediaKeysSession) { + this.log( + `Remove licenses and keys and close session ${mediaKeysSession.sessionId}` ); + mediaKeysSession.onmessage = null; + mediaKeysSession.onkeystatuseschange = null; + if (licenseXhr && licenseXhr.readyState !== XMLHttpRequest.DONE) { + licenseXhr.abort(); + } + mediaKeySessionContext.mediaKeysSession = + mediaKeySessionContext.decryptdata = + mediaKeySessionContext.licenseXhr = + undefined!; + return mediaKeysSession + .remove() + .catch((error) => { + this.log(`Could not remove session: ${error}`); + }) + .then(() => { + return mediaKeysSession.close(); + }) + .catch((error) => { + this.log(`Could not close session: ${error}`); + }); + } + } +} - // TBD We should try a keySystem based on manifest information - this._attemptKeySystemAccess(KeySystems.FAIRPLAY, audioCodecs, videoCodecs); +class EMEKeyError extends Error { + public readonly data: ErrorData; + constructor(data: ErrorData, message: string) { + super(message); + this.data = data; } } diff --git a/src/controller/level-controller.ts b/src/controller/level-controller.ts index fe0e67192dd..018549e3549 100644 --- a/src/controller/level-controller.ts +++ b/src/controller/level-controller.ts @@ -362,6 +362,14 @@ export default class LevelController extends BasePlaylistController { } } break; + case ErrorDetails.KEY_SYSTEM_NO_SESSION: + case ErrorDetails.KEY_SYSTEM_STATUS_OUTPUT_RESTRICTED: + levelIndex = + data.frag?.type === PlaylistLevelType.MAIN + ? data.frag.level + : this.currentLevelIndex; + levelError = true; + break; case ErrorDetails.LEVEL_LOAD_ERROR: case ErrorDetails.LEVEL_LOAD_TIMEOUT: // Do not perform level switch if an error occurred using delivery directives diff --git a/src/controller/stream-controller.ts b/src/controller/stream-controller.ts index 3e47ad198de..6c54534f3c6 100644 --- a/src/controller/stream-controller.ts +++ b/src/controller/stream-controller.ts @@ -1,20 +1,21 @@ import BaseStreamController, { State } from './base-stream-controller'; import { changeTypeSupported } from '../is-supported'; -import type { NetworkComponentAPI } from '../types/component-api'; import { Events } from '../events'; import { BufferHelper, BufferInfo } from '../utils/buffer-helper'; -import type { FragmentTracker } from './fragment-tracker'; import { FragmentState } from './fragment-tracker'; -import type { Level } from '../types/level'; import { PlaylistLevelType } from '../types/loader'; import { ElementaryStreamTypes, Fragment } from '../loader/fragment'; import TransmuxerInterface from '../demux/transmuxer-interface'; -import type { TransmuxerResult } from '../types/transmuxer'; import { ChunkMetadata } from '../types/transmuxer'; import GapController from './gap-controller'; import { ErrorDetails } from '../errors'; +import type { NetworkComponentAPI } from '../types/component-api'; import type Hls from '../hls'; +import type { Level } from '../types/level'; import type { LevelDetails } from '../loader/level-details'; +import type { FragmentTracker } from './fragment-tracker'; +import type KeyLoader from '../loader/key-loader'; +import type { TransmuxerResult } from '../types/transmuxer'; import type { TrackSet } from '../types/track'; import type { SourceBufferName } from '../types/buffer'; import type { @@ -56,8 +57,12 @@ export default class StreamController private audioCodecSwitch: boolean = false; private videoBuffer: any | null = null; - constructor(hls: Hls, fragmentTracker: FragmentTracker) { - super(hls, fragmentTracker, '[stream-controller]'); + constructor( + hls: Hls, + fragmentTracker: FragmentTracker, + keyLoader: KeyLoader + ) { + super(hls, fragmentTracker, keyLoader, '[stream-controller]'); this._registerListeners(); } @@ -849,6 +854,8 @@ export default class StreamController case ErrorDetails.FRAG_LOAD_TIMEOUT: case ErrorDetails.KEY_LOAD_ERROR: case ErrorDetails.KEY_LOAD_TIMEOUT: + case ErrorDetails.KEY_SYSTEM_NO_SESSION: + case ErrorDetails.KEY_SYSTEM_STATUS_OUTPUT_RESTRICTED: this.onFragmentOrKeyLoadError(PlaylistLevelType.MAIN, data); break; case ErrorDetails.LEVEL_LOAD_ERROR: diff --git a/src/controller/subtitle-stream-controller.ts b/src/controller/subtitle-stream-controller.ts index 2bd46ca3c0f..5f129d11714 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 { @@ -40,8 +41,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(); } @@ -371,7 +376,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; @@ -391,12 +396,11 @@ export class SubtitleStreamController } else { foundFrag = fragments[fragLen - 1]; } - - foundFrag = this.mapToInitFragWhenRequired(foundFrag); if (!foundFrag) { return; } + foundFrag = this.mapToInitFragWhenRequired(foundFrag) as Fragment; if ( this.fragmentTracker.getState(foundFrag) === FragmentState.NOT_LOADED ) { diff --git a/src/controller/timeline-controller.ts b/src/controller/timeline-controller.ts index c560a7aa844..dcade334439 100644 --- a/src/controller/timeline-controller.ts +++ b/src/controller/timeline-controller.ts @@ -497,12 +497,7 @@ export class TimelineController implements ComponentAPI { // fragment after decryption has a stats object const decrypted = 'stats' in data; // If the subtitles are not encrypted, parse VTTs now. Otherwise, we need to wait. - if ( - decryptData == null || - decryptData.key == null || - decryptData.method !== 'AES-128' || - decrypted - ) { + if (decryptData == null || !decryptData.encrypted || decrypted) { const trackPlaylistMedia = this.tracks[frag.level]; const vttCCs = this.vttCCs; if (!vttCCs[frag.cc]) { diff --git a/src/demux/transmuxer.ts b/src/demux/transmuxer.ts index a6d21b31b62..c1e92c8dd3b 100644 --- a/src/demux/transmuxer.ts +++ b/src/demux/transmuxer.ts @@ -13,7 +13,7 @@ import type { Demuxer, DemuxerResult, KeyData } from '../types/demuxer'; import type { Remuxer } from '../types/remuxer'; import type { TransmuxerResult, ChunkMetadata } from '../types/transmuxer'; import type { HlsConfig } from '../config'; -import type { LevelKey } from '../loader/level-key'; +import type { DecryptData } from '../loader/level-key'; import type { PlaylistLevelType } from '../types/loader'; let now; @@ -75,7 +75,7 @@ export default class Transmuxer { push( data: ArrayBuffer, - decryptdata: LevelKey | null, + decryptdata: DecryptData | null, chunkMeta: ChunkMetadata, state?: TransmuxState ): TransmuxerResult | Promise { @@ -104,19 +104,6 @@ export default class Transmuxer { initSegmentData, } = transmuxConfig; - // Reset muxers before probing to ensure that their state is clean, even if flushing occurs before a successful probe - if (discontinuity || trackSwitch || initSegmentChange) { - this.resetInitSegment(initSegmentData, audioCodec, videoCodec, duration); - } - - if (discontinuity || initSegmentChange) { - this.resetInitialTimestamp(defaultInitPts); - } - - if (!contiguous) { - this.resetContiguity(); - } - const keyData = getEncryptionType(uintData, decryptdata); if (keyData && keyData.method === 'AES-128') { const decrypter = this.getDecrypter(); @@ -152,8 +139,27 @@ export default class Transmuxer { } } - if (this.needsProbing(uintData, discontinuity, trackSwitch)) { - this.configureTransmuxer(uintData, transmuxConfig); + const resetMuxers = this.needsProbing(discontinuity, trackSwitch); + if (resetMuxers) { + this.configureTransmuxer(uintData); + } + + if (discontinuity || trackSwitch || initSegmentChange || resetMuxers) { + this.resetInitSegment( + initSegmentData, + audioCodec, + videoCodec, + duration, + decryptdata + ); + } + + if (discontinuity || initSegmentChange || resetMuxers) { + this.resetInitialTimestamp(defaultInitPts); + } + + if (!contiguous) { + this.resetContiguity(); } const result = this.transmux( @@ -283,7 +289,8 @@ export default class Transmuxer { initSegmentData: Uint8Array | undefined, audioCodec: string | undefined, videoCodec: string | undefined, - trackDuration: number + trackDuration: number, + decryptdata: DecryptData | null ) { const { demuxer, remuxer } = this; if (!demuxer || !remuxer) { @@ -295,7 +302,12 @@ export default class Transmuxer { videoCodec, trackDuration ); - remuxer.resetInitSegment(initSegmentData, audioCodec, videoCodec); + remuxer.resetInitSegment( + initSegmentData, + audioCodec, + videoCodec, + decryptdata + ); } destroy(): void { @@ -388,18 +400,8 @@ export default class Transmuxer { }); } - private configureTransmuxer( - data: Uint8Array, - transmuxConfig: TransmuxConfig - ) { + private configureTransmuxer(data: Uint8Array) { const { config, observer, typeSupported, vendor } = this; - const { - audioCodec, - defaultInitPts, - duration, - initSegmentData, - videoCodec, - } = transmuxConfig; // probe for content type let mux; for (let i = 0, len = muxConfig.length; i < len; i++) { @@ -427,16 +429,9 @@ export default class Transmuxer { this.demuxer = new Demuxer(observer, config, typeSupported); this.probe = Demuxer.probe; } - // Ensure that muxers are always initialized with an initSegment - this.resetInitSegment(initSegmentData, audioCodec, videoCodec, duration); - this.resetInitialTimestamp(defaultInitPts); } - private needsProbing( - data: Uint8Array, - discontinuity: boolean, - trackSwitch: boolean - ): boolean { + private needsProbing(discontinuity: boolean, trackSwitch: boolean): boolean { // in case of continuity change, or track switch // we might switch from content type (AAC container to TS container, or TS to fmp4 for example) return !this.demuxer || !this.remuxer || discontinuity || trackSwitch; @@ -453,7 +448,7 @@ export default class Transmuxer { function getEncryptionType( data: Uint8Array, - decryptData: LevelKey | null + decryptData: DecryptData | null ): KeyData | null { let encryptionType: KeyData | null = null; if ( diff --git a/src/errors.ts b/src/errors.ts index be459182628..3271d8e3836 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -24,6 +24,8 @@ export enum ErrorDetails { KEY_SYSTEM_SERVER_CERTIFICATE_UPDATE_FAILED = 'keySystemServerCertificateUpdateFailed', KEY_SYSTEM_NO_INIT_DATA = 'keySystemNoInitData', KEY_SYSTEM_SESSION_UPDATE_FAILED = 'keySystemSessionUpdateFailed', + KEY_SYSTEM_STATUS_OUTPUT_RESTRICTED = 'keySystemStatusOutputRestricted', + KEY_SYSTEM_STATUS_INTERNAL_ERROR = 'keySystemStatusInternalError', // Identifier for a manifest load error - data: { url : faulty URL, response : { code: error code, text: error text }} MANIFEST_LOAD_ERROR = 'manifestLoadError', // Identifier for a manifest load timeout - data: { url : faulty URL, response : { code: error code, text: error text }} diff --git a/src/events.ts b/src/events.ts index 41da5f0783b..c341414d67e 100644 --- a/src/events.ts +++ b/src/events.ts @@ -160,7 +160,7 @@ export enum Events { DESTROYING = 'hlsDestroying', // fired when a decrypt key loading starts - data: { frag : fragment object } KEY_LOADING = 'hlsKeyLoading', - // fired when a decrypt key loading is completed - data: { frag : fragment object, payload : key payload, stats : LoaderStats } + // fired when a decrypt key loading is completed - data: { frag : fragment object, keyInfo : KeyLoaderInfo } KEY_LOADED = 'hlsKeyLoaded', // deprecated; please use BACK_BUFFER_REACHED - data : { bufferEnd: number } LIVE_BACK_BUFFER_REACHED = 'hlsLiveBackBufferReached', diff --git a/src/hls.ts b/src/hls.ts index b1552adbb4e..05b966e4bf5 100644 --- a/src/hls.ts +++ b/src/hls.ts @@ -4,6 +4,7 @@ import ID3TrackController from './controller/id3-track-controller'; import LatencyController from './controller/latency-controller'; import LevelController from './controller/level-controller'; import { FragmentTracker } from './controller/fragment-tracker'; +import KeyLoader from './loader/key-loader'; import StreamController from './controller/stream-controller'; import { isSupported } from './is-supported'; import { logger, enableLogs } from './utils/logger'; @@ -129,9 +130,11 @@ export default class Hls implements HlsEventEmitter { const levelController = (this.levelController = new LevelController(this)); // FragmentTracker must be defined before StreamController because the order of event handling is important const fragmentTracker = new FragmentTracker(this); + const keyLoader = new KeyLoader(this.config); const streamController = (this.streamController = new StreamController( this, - fragmentTracker + fragmentTracker, + keyLoader )); // Cap level controller uses streamController to flush the buffer @@ -139,14 +142,14 @@ export default class Hls implements HlsEventEmitter { // fpsController uses streamController to switch when frames are being dropped fpsController.setStreamController(streamController); - const networkControllers = [ + const networkControllers: NetworkComponentAPI[] = [ playListLoader, levelController, streamController, ]; this.networkControllers = networkControllers; - const coreComponents = [ + const coreComponents: ComponentAPI[] = [ abrController, bufferController, capLevelController, @@ -157,50 +160,45 @@ export default class Hls implements HlsEventEmitter { this.audioTrackController = this.createController( config.audioTrackController, - null, networkControllers ); - this.createController( - config.audioStreamController, - fragmentTracker, - networkControllers - ); - // subtitleTrackController must be defined before because the order of event handling is important + const AudioStreamControllerClass = config.audioStreamController; + if (AudioStreamControllerClass) { + networkControllers.push( + new AudioStreamControllerClass(this, fragmentTracker, keyLoader) + ); + } + // subtitleTrackController must be defined before subtitleStreamController because the order of event handling is important this.subtitleTrackController = this.createController( config.subtitleTrackController, - null, networkControllers ); - this.createController( - config.subtitleStreamController, - fragmentTracker, - networkControllers - ); - this.createController(config.timelineController, null, coreComponents); - this.emeController = this.createController( + const SubtitleStreamControllerClass = config.subtitleStreamController; + if (SubtitleStreamControllerClass) { + networkControllers.push( + new SubtitleStreamControllerClass(this, fragmentTracker, keyLoader) + ); + } + this.createController(config.timelineController, coreComponents); + keyLoader.emeController = this.emeController = this.createController( config.emeController, - null, coreComponents ); this.cmcdController = this.createController( config.cmcdController, - null, coreComponents ); this.latencyController = this.createController( LatencyController, - null, coreComponents ); this.coreComponents = coreComponents; } - createController(ControllerClass, fragmentTracker, components) { + createController(ControllerClass, components) { if (ControllerClass) { - const controllerInstance = fragmentTracker - ? new ControllerClass(this, fragmentTracker) - : new ControllerClass(this); + const controllerInstance = new ControllerClass(this); if (components) { components.push(controllerInstance); } @@ -869,7 +867,11 @@ export type { TSDemuxerConfig, } from './config'; export type { CuesInterface } from './utils/cues'; -export type { MediaKeyFunc, KeySystems } from './utils/mediakeys-helper'; +export type { + MediaKeyFunc, + KeySystems, + KeySystemFormats, +} from './utils/mediakeys-helper'; export type { DateRange } from './loader/date-range'; export type { LoadStats } from './loader/load-stats'; export type { LevelKey } from './loader/level-key'; @@ -893,7 +895,6 @@ export type { PlaylistContextType, PlaylistLoaderContext, FragmentLoaderContext, - KeyLoaderContext, Loader, LoaderStats, LoaderContext, diff --git a/src/loader/fragment.ts b/src/loader/fragment.ts index 71674b58bf1..8e7d65681f5 100644 --- a/src/loader/fragment.ts +++ b/src/loader/fragment.ts @@ -1,14 +1,14 @@ import { buildAbsoluteURL } from 'url-toolkit'; -import { logger } from '../utils/logger'; import { LevelKey } from './level-key'; import { LoadStats } from './load-stats'; import { AttrList } from '../utils/attr-list'; import type { - KeyLoaderContext, FragmentLoaderContext, + KeyLoaderContext, Loader, PlaylistLevelType, } from '../types/loader'; +import type { KeySystemFormats } from '../utils/mediakeys-helper'; export enum ElementaryStreamTypes { AUDIO = 'audio', @@ -102,10 +102,10 @@ export class Fragment extends BaseSegment { public duration: number = 0; // sn notates the sequence number for a segment, and if set to a string can be 'initSegment' public sn: number | 'initSegment' = 0; - // levelkey is the EXT-X-KEY that applies to this segment for decryption + // levelkeys are the EXT-X-KEY tags that apply to this segment for decryption // core difference from the private field _decryptdata is the lack of the initialized IV // _decryptdata will set the IV for this segment based on the segment number in the fragment - public levelkey?: LevelKey; + public levelkeys?: { [key: string]: LevelKey }; // A string representing the fragment type public readonly type: PlaylistLevelType; // A reference to the loader. Set while the fragment is loading, and removed afterwards. Used to abort fragment loading @@ -151,36 +151,25 @@ export class Fragment extends BaseSegment { } get decryptdata(): LevelKey | null { - if (!this.levelkey && !this._decryptdata) { + const { levelkeys } = this; + if (!levelkeys && !this._decryptdata) { return null; } - if (!this._decryptdata && this.levelkey) { - let sn = this.sn; - if (typeof sn !== 'number') { - // We are fetching decryption data for a initialization segment - // If the segment was encrypted with AES-128 - // It must have an IV defined. We cannot substitute the Segment Number in. - if ( - this.levelkey && - this.levelkey.method === 'AES-128' && - !this.levelkey.iv - ) { - logger.warn( - `missing IV for initialization segment with method="${this.levelkey.method}" - compliance issue` - ); + if (!this._decryptdata && this.levelkeys && !this.levelkeys.NONE) { + const key = this.levelkeys.identity; + if (key) { + this._decryptdata = key.getDecryptData(this.sn); + } else { + const keyFormats = Object.keys(this.levelkeys); + if (keyFormats.length === 1) { + return (this._decryptdata = this.levelkeys[ + keyFormats[0] + ].getDecryptData(this.sn)); + } else { + // Multiple keys. key-loader to call Fragment.setKeyFormat based on selected key-system. } - - /* - Be converted to a Number. - 'initSegment' will become NaN. - NaN, which when converted through ToInt32() -> +0. - --- - Explicitly set sn to resulting value from implicit conversions 'initSegment' values for IV generation. - */ - sn = 0; } - this._decryptdata = this.setDecryptDataFromLevelKey(this.levelkey, sn); } return this._decryptdata; @@ -208,53 +197,31 @@ export class Fragment extends BaseSegment { // At the m3u8-parser level we need to add support for manifest signalled keyformats // when we want the fragment to start reporting that it is encrypted. // Currently, keyFormat will only be set for identity keys - if (this.decryptdata?.keyFormat && this.decryptdata.uri) { + if (this._decryptdata?.encrypted) { return true; + } else if (this.levelkeys) { + const keyFormats = Object.keys(this.levelkeys); + const len = keyFormats.length; + if (len > 1 || (len === 1 && this.levelkeys[keyFormats[0]].encrypted)) { + return true; + } } return false; } - abortRequests(): void { - this.loader?.abort(); - this.keyLoader?.abort(); - } - - /** - * Utility method for parseLevelPlaylist to create an initialization vector for a given segment - * @param {number} segmentNumber - segment number to generate IV with - * @returns {Uint8Array} - */ - createInitializationVector(segmentNumber: number): Uint8Array { - const uint8View = new Uint8Array(16); - - for (let i = 12; i < 16; i++) { - uint8View[i] = (segmentNumber >> (8 * (15 - i))) & 0xff; + setKeyFormat(keyFormat: KeySystemFormats) { + if (this.levelkeys) { + const key = this.levelkeys[keyFormat]; + if (key) { + this._decryptdata = key.getDecryptData(this.sn); + } } - - return uint8View; } - /** - * Utility method for parseLevelPlaylist to get a fragment's decryption data from the currently parsed encryption key data - * @param levelkey - a playlist's encryption info - * @param segmentNumber - the fragment's segment number - * @returns {LevelKey} - an object to be applied as a fragment's decryptdata - */ - setDecryptDataFromLevelKey( - levelkey: LevelKey, - segmentNumber: number - ): LevelKey { - let decryptdata = levelkey; - - if (levelkey?.method === 'AES-128' && levelkey.uri && !levelkey.iv) { - decryptdata = LevelKey.fromURI(levelkey.uri); - decryptdata.method = levelkey.method; - decryptdata.iv = this.createInitializationVector(segmentNumber); - decryptdata.keyFormat = 'identity'; - } - - return decryptdata; + abortRequests(): void { + this.loader?.abort(); + this.keyLoader?.abort(); } setElementaryStreamInfo( diff --git a/src/loader/key-loader.ts b/src/loader/key-loader.ts index 64273a344a4..6c6a2cce354 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,308 @@ 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'; +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(); - } - - destroy(): void { - if (this.loader) { - this.loader.destroy(); - this.loader = null; + abort() { + for (const uri in this.keyUriToKeyInfo) { + const loader = this.keyUriToKeyInfo[uri].loader; + if (loader) { + loader.abort(); + } } } - 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'); + 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 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; + destroy() { + this.detach(); + for (const uri in this.keyUriToKeyInfo) { + const loader = this.keyUriToKeyInfo[uri].loader; if (loader) { - logger.warn(`abort previous key loader for type:${type}`); - loader.abort(); - } - if (!uri) { - throw new Error('key uri is falsy'); + loader.destroy(); } - 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 - ); + } + this.keyUriToKeyInfo = {}; + } + + 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, + }); + } - // 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, + load(frag: Fragment): Promise { + const decryptdata = frag.decryptdata; + if (!decryptdata) { + if (frag.encrypted && this.emeController) { + // Multiple keys, but none selected, resolve in eme-controller + return this.emeController + .selectKeySystemFormat(frag) + .then((keySystemFormat) => { + frag.setKeyFormat(keySystemFormat); + const decryptdata = frag.decryptdata; + if (decryptdata) { + return this.loadInternal(frag, decryptdata); + } + return Promise.reject( + this.createKeyLoadError( frag, - networkDetails, - }) + ErrorDetails.KEY_LOAD_ERROR, + null, + `Expected frag.decryptdata to be defined after setting format ${keySystemFormat}` + ) ); - }, - - 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, + }); + } else { + return Promise.reject( + this.createKeyLoadError( + frag, + ErrorDetails.KEY_LOAD_ERROR, + null, + 'Missing decryption data on fragment in onKeyLoading' + ) + ); + } + } + + return this.loadInternal(frag, decryptdata); + } + + loadInternal(frag: Fragment, decryptdata: LevelKey): Promise { + const uri = decryptdata.uri; + let keyInfo = this.keyUriToKeyInfo[uri]; + + if (keyInfo?.decryptdata.key) { + decryptdata.key = keyInfo.decryptdata.key; + return Promise.resolve({ frag, keyInfo }); + } + // Return key load promise as long as it does not have a mediakey session with an unusable key status + if (keyInfo?.keyLoadPromise) { + switch (keyInfo.mediaKeySessionContext?.keyStatus) { + case undefined: + case 'status-pending': + case 'usable': + case 'usable-in-future': + return keyInfo.keyLoadPromise; + } + // If we have a key session and status and it is not pending or usable, continue + // This will go back to the eme-controller for expired keys to get a new keyLoadPromise + } + + // Load the key or return the loading promise + if (!uri) { + return Promise.reject( + this.createKeyLoadError( + frag, + ErrorDetails.KEY_LOAD_ERROR, + null, + `Invalid key URI: "${uri}"` + ) + ); + } + keyInfo = this.keyUriToKeyInfo[uri] = { + decryptdata, + keyLoadPromise: null, + loader: null, + mediaKeySessionContext: null, + }; + + switch (decryptdata.method) { + case 'ISO-23001-7': + case 'SAMPLE-AES': + case 'SAMPLE-AES-CENC': + case 'SAMPLE-AES-CTR': + if (decryptdata.keyFormat === 'identity') { + // loadKeyHTTP handles data URLs + return this.loadKeyHTTP(keyInfo, frag); + } + return this.loadKeyEME(keyInfo, frag); + case 'AES-128': + return this.loadKeyHTTP(keyInfo, frag); + default: + return Promise.reject( + this.createKeyLoadError( + frag, + ErrorDetails.KEY_LOAD_ERROR, + null, + `Key supplied with unsupported METHOD: "${decryptdata.method}"` + ) + ); + } + } + + loadKeyEME(keyInfo: KeyLoaderInfo, frag: Fragment): Promise { + const keyLoadedData: KeyLoadedData = { frag, keyInfo }; + if (this.emeController && this.config.emeEnabled) { + const keySessionContextPromise = + this.emeController.loadKey(keyLoadedData); + if (keySessionContextPromise) { + return (keyInfo.keyLoadPromise = keySessionContextPromise.then( + (keySessionContext) => { + keyInfo.mediaKeySessionContext = keySessionContext; + return keyLoadedData; + } + )).catch((error) => { + // Remove promise for license renewal or retry + keyInfo.keyLoadPromise = null; + throw error; + }); + } + } + 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-key.ts b/src/loader/level-key.ts index 90fcf253b1b..acd2db69687 100644 --- a/src/loader/level-key.ts +++ b/src/loader/level-key.ts @@ -1,33 +1,244 @@ -import { buildAbsoluteURL } from 'url-toolkit'; - -export class LevelKey { - private _uri: string | null = null; - public method: string | null = null; - public keyFormat: string | null = null; - public keyFormatVersions: string | null = null; - public keyID: string | null = null; +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 iv: Uint8Array | null = null; + public readonly encrypted: boolean; + public readonly isCommonEncryption: boolean; public key: Uint8Array | null = null; - public iv: Uint8Array | null = null; + public keyId: Uint8Array | null = null; + public pssh: Uint8Array | null = null; - static fromURL(baseUrl: string, relativeUrl: string): LevelKey { - return new LevelKey(baseUrl, relativeUrl); + static clearKeyUriToKeyIdMap() { + keyUriToKeyIdMap = {}; } - static fromURI(uri: string): LevelKey { - return new LevelKey(uri); + constructor( + method: string, + uri: string, + format: string, + formatversions: number[] = [1], + iv: Uint8Array | null = null + ) { + this.method = method; + this.uri = uri; + this.keyFormat = format; + this.keyFormatVersions = formatversions; + this.iv = iv; + this.encrypted = method ? method !== 'NONE' : false; + this.isCommonEncryption = this.encrypted && method !== 'AES-128'; } - private constructor(absoluteOrBaseURI: string, relativeURL?: string) { - if (relativeURL) { - this._uri = buildAbsoluteURL(absoluteOrBaseURI, relativeURL, { - alwaysNormalize: true, - }); - } else { - this._uri = absoluteOrBaseURI; + public getDecryptData(sn: number | 'initSegment'): LevelKey | null { + if (!this.encrypted || !this.uri) { + return null; + } + + if (this.method === 'AES-128' && this.uri && !this.iv) { + if (typeof sn !== 'number') { + // We are fetching decryption data for a initialization segment + // If the segment was encrypted with AES-128 + // It must have an IV defined. We cannot substitute the Segment Number in. + if (this.method === 'AES-128' && !this.iv) { + logger.warn( + `missing IV for initialization segment with method="${this.method}" - compliance issue` + ); + } + // Explicitly set sn to resulting value from implicit conversions 'initSegment' values for IV generation. + sn = 0; + } + const iv = createInitializationVector(sn); + const decryptdata = new LevelKey( + this.method, + this.uri, + 'identity', + this.keyFormatVersions, + iv + ); + return decryptdata; + } + + // Initialize keyId if possible + const keyBytes = convertDataUriToArrayBytes(this.uri); + if (keyBytes) { + switch (this.keyFormat) { + case KeySystemFormats.WIDEVINE: + this.pssh = keyBytes; + // In case of widevine keyID is embedded in PSSH box. Read Key ID. + if (keyBytes.length >= 22) { + this.keyId = keyBytes.subarray( + keyBytes.length - 22, + keyBytes.length - 6 + ); + } + break; + case KeySystemFormats.FAIRPLAY: { + let keydata = keyBytes.subarray(0, 16); + if (keydata.length !== 16) { + const padded = new Uint8Array(16); + padded.set(keydata, 16 - keydata.length); + keydata = padded; + } + this.keyId = keydata; + break; + } + case KeySystemFormats.PLAYREADY: { + const PlayReadyKeySystemUUID = new Uint8Array([ + 0x9a, 0x04, 0xf0, 0x79, 0x98, 0x40, 0x42, 0x86, 0xab, 0x92, 0xe6, + 0x5b, 0xe0, 0x88, 0x5f, 0x95, + ]); + + this.pssh = mp4pssh(PlayReadyKeySystemUUID, null, keyBytes); + + const keyBytesUtf16 = new Uint16Array( + keyBytes.buffer, + keyBytes.byteOffset, + keyBytes.byteLength / 2 + ); + const keyByteStr = String.fromCharCode.apply( + null, + Array.from(keyBytesUtf16) + ); + + // Parse Playready WRMHeader XML + const xmlKeyBytes = keyByteStr.substring( + keyByteStr.indexOf('<'), + keyByteStr.length + ); + const parser = new DOMParser(); + const xmlDoc = parser.parseFromString(xmlKeyBytes, 'text/xml'); + const keyData = xmlDoc.getElementsByTagName('KID')[0]; + if (keyData) { + const keyId = keyData.childNodes[0] + ? keyData.childNodes[0].nodeValue + : keyData.getAttribute('VALUE'); + if (keyId) { + const keyIdArray = base64Decode(keyId).subarray(0, 16); + // KID value in PRO is a base64-encoded little endian GUID interpretation of UUID + // KID value in ‘tenc’ is a big endian UUID GUID interpretation of UUID + changeEndianness(keyIdArray); + this.keyId = keyIdArray; + } + } + break; + } + } } + + // Default behavior: assign a new keyId for each uri + if (!this.keyId || this.keyId.byteLength !== 16) { + let keyId = keyUriToKeyIdMap[this.uri]; + if (!keyId) { + const val = + Object.keys(keyUriToKeyIdMap).length % Number.MAX_SAFE_INTEGER; + keyId = new Uint8Array(16); + const dv = new DataView(keyId.buffer, 12, 4); // Just set the last 4 bytes + dv.setUint32(0, val); + keyUriToKeyIdMap[this.uri] = keyId; + } + this.keyId = keyId; + } + + if (this.keyFormat === KeySystemFormats.FAIRPLAY) { + this.pssh = getFairPlayV3Pssh( + this.keyId, + this.method, + this.keyFormatVersions + ); + } + + return this; } +} - get uri() { - return this._uri; +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 args = [ + FpsBoxTypes.fpsd, + makeFpsKeySystemInfoBox(scheme), + makeFpsKeyRequestBox(keyId, keyFormatVersions), + ]; + const data = mp4Box.apply(null, args as [ArrayLike, Uint8Array]); + const kFairPlayStreamingKeySystemUUID = new Uint8Array([ + 0x94, 0xce, 0x86, 0xfb, 0x07, 0xff, 0x4f, 0x43, 0xad, 0xb8, 0x93, 0xd2, + 0xfa, 0x96, 0x8c, 0xa2, + ]); + return mp4pssh(kFairPlayStreamingKeySystemUUID, null, data); } diff --git a/src/loader/m3u8-parser.ts b/src/loader/m3u8-parser.ts index df6c4fb7632..76b0466d356 100644 --- a/src/loader/m3u8-parser.ts +++ b/src/loader/m3u8-parser.ts @@ -4,6 +4,7 @@ import { DateRange } from './date-range'; import { Fragment, Part } from './fragment'; import { LevelDetails } from './level-details'; import { LevelKey } from './level-key'; +import { KeySystemFormats } from '../utils/mediakeys-helper'; import { AttrList } from '../utils/attr-list'; import { logger } from '../utils/logger'; @@ -207,7 +208,7 @@ export default class M3U8Parser { let frag: Fragment = new Fragment(type, baseurl); let result: RegExpExecArray | RegExpMatchArray | null; let i: number; - let levelkey: LevelKey | undefined; + let levelkeys: { [key: string]: LevelKey } | undefined; let firstPdtIndex = -1; let createNextFrag = false; @@ -242,8 +243,8 @@ export default class M3U8Parser { // url if (Number.isFinite(frag.duration)) { frag.start = totalduration; - if (levelkey) { - frag.levelkey = levelkey; + if (levelkeys) { + frag.levelkeys = levelkeys; } frag.sn = currentSN; frag.level = id; @@ -372,60 +373,52 @@ export default class M3U8Parser { const decryptiv = keyAttrs.hexadecimalInteger('IV'); const decryptkeyformatversions = keyAttrs.enumeratedString('KEYFORMATVERSIONS'); - const decryptkeyid = keyAttrs.enumeratedString('KEYID'); // From RFC: This attribute is OPTIONAL; its absence indicates an implicit value of "identity". const decryptkeyformat = keyAttrs.enumeratedString('KEYFORMAT') ?? 'identity'; - // TBD we might need to check if we try to MSE playback mp2t content - // with com.apple.streamingkeydelivery keyformat (not supported) - - const unsupportedKnownKeyformatsInManifest = [ - 'com.microsoft.playready', - 'urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed', // widevine (v2) - 'com.widevine', // earlier widevine (v1) - ]; - if ( - unsupportedKnownKeyformatsInManifest.indexOf(decryptkeyformat) > - -1 + !decryptmethod || + [ + 'NONE', + 'AES-128', + 'ISO-23001-7', + 'SAMPLE-AES', + 'SAMPLE-AES-CENC', + 'SAMPLE-AES-CTR', + ].indexOf(decryptmethod) === -1 ) { - logger.warn( - `Keyformat ${decryptkeyformat} is not supported from the manifest` - ); - continue; - } else if (decryptkeyformat !== 'identity') { - // We are supposed to skip keys we don't understand. - // As we currently only officially support identity keys - // from the manifest we shouldn't save any other key. - continue; - } - - // TODO: multiple keys can be defined on a fragment, and we need to support this - // for clients that support both playready and widevine - if (decryptmethod) { - // TODO: need to determine if the level key is actually a relative URL - // if it isn't, then we should instead construct the LevelKey using fromURI. - levelkey = LevelKey.fromURL(baseurl, decrypturi); - if ( - decrypturi && - ['AES-128', 'SAMPLE-AES', 'SAMPLE-AES-CENC'].indexOf( - decryptmethod - ) >= 0 - ) { - levelkey.method = decryptmethod; - levelkey.keyFormat = decryptkeyformat; - - if (decryptkeyid) { - levelkey.keyID = decryptkeyid; + logger.warn(`[Keys] Ignoring invalid EXT-X-KEY tag: "${value1}"`); + } else { + if (decrypturi && keyAttrs.IV && !decryptiv) { + logger.error(`Invalid IV: ${keyAttrs.IV}`); + } + // If decrypturi is a URI with a scheme, then baseurl will be ignored + // No uri is allowed when METHOD is NONE + const resolvedUri = decrypturi + ? M3U8Parser.resolve(decrypturi, baseurl) + : ''; + const keyFormatVersions = ( + decryptkeyformatversions ? decryptkeyformatversions : '1' + ) + .split('/') + .map(Number) + .filter(Number.isFinite); + + if (isKeyTagSupported(decryptkeyformat, decryptmethod)) { + if (decryptmethod === 'NONE' || !levelkeys) { + levelkeys = {}; } - - if (decryptkeyformatversions) { - levelkey.keyFormatVersions = decryptkeyformatversions; + if (levelkeys[decryptkeyformat]) { + levelkeys = Object.assign({}, levelkeys); } - - // Initialization Vector (IV) - levelkey.iv = decryptiv; + levelkeys[decryptkeyformat] = new LevelKey( + decryptmethod, + resolvedUri, + decryptkeyformat, + keyFormatVersions, + decryptiv + ); } } break; @@ -447,7 +440,7 @@ export default class M3U8Parser { // #EXTINF: 6.0 // #EXT-X-MAP:URI="init.mp4 const init = new Fragment(type, baseurl); - setInitSegment(init, mapAttrs, id, levelkey); + setInitSegment(init, mapAttrs, id, levelkeys); currentInitSegment = init; frag.initSegment = currentInitSegment; if ( @@ -458,7 +451,7 @@ export default class M3U8Parser { } } else { // Initial segment tag is before segment duration tag - setInitSegment(frag, mapAttrs, id, levelkey); + setInitSegment(frag, mapAttrs, id, levelkeys); currentInitSegment = frag; createNextFrag = true; } @@ -591,6 +584,27 @@ export default class M3U8Parser { } } +function isKeyTagSupported( + decryptformat: string, + decryptmethod: string +): boolean { + // If it's Segment encryption or No encryption, just select that key system + if ('AES-128' === decryptmethod || 'NONE' === decryptmethod) { + return true; + } + switch (decryptformat) { + case 'identity': + // maintain support for clear SAMPLE-AES with MPEG-3 TS + return true; + case KeySystemFormats.FAIRPLAY: + case KeySystemFormats.WIDEVINE: + case KeySystemFormats.PLAYREADY: + case KeySystemFormats.CLEARKEY: + return true; + } + return false; +} + function setCodecs(codecs: Array, level: LevelParsed) { ['video', 'audio', 'text'].forEach((type: CodecType) => { const filtered = codecs.filter((codec) => isCodecType(codec, type)); @@ -652,7 +666,7 @@ function setInitSegment( frag: Fragment, mapAttrs: AttrList, id: number, - levelkey: LevelKey | undefined + levelkeys: { [key: string]: LevelKey } | undefined ) { frag.relurl = mapAttrs.URI; if (mapAttrs.BYTERANGE) { @@ -660,8 +674,8 @@ function setInitSegment( } frag.level = id; frag.sn = 'initSegment'; - if (levelkey) { - frag.levelkey = levelkey; + if (levelkeys) { + frag.levelkeys = levelkeys; } frag.initSegment = null; } diff --git a/src/loader/playlist-loader.ts b/src/loader/playlist-loader.ts index ed496050557..406e829a2e9 100644 --- a/src/loader/playlist-loader.ts +++ b/src/loader/playlist-loader.ts @@ -565,7 +565,10 @@ class PlaylistLoader implements NetworkComponentAPI { sidxReferences.forEach((segmentRef, index) => { const segRefInfo = segmentRef.info; const frag = levelDetails.fragments[index]; - + if (!frag) { + logger.error(`no fragment for sidx index ${index}`); + return; + } if (frag.byteRange.length === 0) { frag.setByteRange( String(1 + segRefInfo.end - segRefInfo.start) + diff --git a/src/remux/passthrough-remuxer.ts b/src/remux/passthrough-remuxer.ts index 4b8f276ae9d..ee28c7cb257 100644 --- a/src/remux/passthrough-remuxer.ts +++ b/src/remux/passthrough-remuxer.ts @@ -2,7 +2,11 @@ import { flushTextTrackMetadataCueSamples, flushTextTrackUserdataCueSamples, } from './mp4-remuxer'; -import type { InitData, InitDataTrack } from '../utils/mp4-tools'; +import { + InitData, + InitDataTrack, + patchEncyptionData, +} from '../utils/mp4-tools'; import { getDuration, getStartDTS, @@ -24,6 +28,7 @@ import type { DemuxedUserdataTrack, PassthroughTrack, } from '../types/demuxer'; +import type { DecryptData } from '../loader/level-key'; class PassThroughRemuxer implements Remuxer { private emitInitSegment: boolean = false; @@ -48,11 +53,12 @@ class PassThroughRemuxer implements Remuxer { public resetInitSegment( initSegment: Uint8Array | undefined, audioCodec: string | undefined, - videoCodec: string | undefined + videoCodec: string | undefined, + decryptdata: DecryptData | null ) { this.audioCodec = audioCodec; this.videoCodec = videoCodec; - this.generateInitSegment(initSegment); + this.generateInitSegment(patchEncyptionData(initSegment, decryptdata)); this.emitInitSegment = true; } diff --git a/src/types/events.ts b/src/types/events.ts index a59acda90bd..6c1d0d70845 100644 --- a/src/types/events.ts +++ b/src/types/events.ts @@ -21,6 +21,7 @@ import type { ErrorDetails, ErrorTypes } from '../errors'; import type { MetadataSample, UserdataSample } from './demuxer'; import type { AttrList } from '../utils/attr-list'; import type { HlsListeners } from '../events'; +import { KeyLoaderInfo } from '../loader/key-loader'; export interface MediaAttachingData { media: HTMLMediaElement; @@ -338,6 +339,7 @@ export interface KeyLoadingData { export interface KeyLoadedData { frag: Fragment; + keyInfo: KeyLoaderInfo; } export interface BackBufferData { diff --git a/src/types/loader.ts b/src/types/loader.ts index f13ef56374a..006482611ed 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'; @@ -23,7 +24,10 @@ export interface FragmentLoaderContext extends LoaderContext { part: Part | null; } -export interface KeyLoaderContext extends FragmentLoaderContext {} +export interface KeyLoaderContext extends LoaderContext { + keyInfo: KeyLoaderInfo; + frag: Fragment; +} export interface LoaderConfiguration { // Max number of load retries diff --git a/src/types/remuxer.ts b/src/types/remuxer.ts index 63f944e984e..d5e78c70227 100644 --- a/src/types/remuxer.ts +++ b/src/types/remuxer.ts @@ -9,6 +9,7 @@ import { } from './demuxer'; import type { SourceBufferName } from './buffer'; import type { PlaylistLevelType } from './loader'; +import type { DecryptData } from '../loader/level-key'; export interface Remuxer { remux( @@ -24,7 +25,8 @@ export interface Remuxer { resetInitSegment( initSegment: Uint8Array | undefined, audioCodec: string | undefined, - videoCodec: string | undefined + videoCodec: string | undefined, + decryptdata: DecryptData | null ): void; resetTimeStamp(defaultInitPTS): void; resetNextTimestamp(): void; diff --git a/src/utils/keysystem-util.ts b/src/utils/keysystem-util.ts new file mode 100644 index 00000000000..fc7f33fc888 --- /dev/null +++ b/src/utils/keysystem-util.ts @@ -0,0 +1,48 @@ +import { base64Decode } from './numeric-encoding-utils'; + +function getKeyIdBytes(str: string): Uint8Array { + const keyIdbytes = strToUtf8array(str).subarray(0, 16); + const paddedkeyIdbytes = new Uint8Array(16); + paddedkeyIdbytes.set(keyIdbytes, 16 - keyIdbytes.length); + return paddedkeyIdbytes; +} + +export function changeEndianness(keyId: Uint8Array) { + const swap = function (array: Uint8Array, from: number, to: number) { + const cur = array[from]; + array[from] = array[to]; + array[to] = cur; + }; + + swap(keyId, 0, 3); + swap(keyId, 1, 2); + swap(keyId, 4, 5); + swap(keyId, 6, 7); +} + +export function convertDataUriToArrayBytes(uri: string): Uint8Array | null { + // data:[ + const colonsplit = uri.split(':'); + let keydata: Uint8Array | null = null; + if (colonsplit[0] === 'data' && colonsplit.length === 2) { + const semicolonsplit = colonsplit[1].split(';'); + const commasplit = semicolonsplit[semicolonsplit.length - 1].split(','); + if (commasplit.length === 2) { + const isbase64 = commasplit[0] === 'base64'; + const data = commasplit[1]; + if (isbase64) { + semicolonsplit.splice(-1, 1); // remove from processing + keydata = base64Decode(data); + } else { + keydata = getKeyIdBytes(data); + } + } + } + return keydata; +} + +export function strToUtf8array(str: string): Uint8Array { + return Uint8Array.from(unescape(encodeURIComponent(str)), (c) => + c.charCodeAt(0) + ); +} diff --git a/src/utils/mediakeys-helper.ts b/src/utils/mediakeys-helper.ts index fbad2414b3c..ca18c3f5736 100644 --- a/src/utils/mediakeys-helper.ts +++ b/src/utils/mediakeys-helper.ts @@ -1,17 +1,81 @@ +import type { DRMSystemOptions, EMEControllerConfig } from '../config'; + /** * @see https://developer.mozilla.org/en-US/docs/Web/API/Navigator/requestMediaKeySystemAccess */ export enum KeySystems { + CLEARKEY = 'org.w3.clearkey', + FAIRPLAY = 'com.apple.fps', + PLAYREADY = 'com.microsoft.playready', WIDEVINE = 'com.widevine.alpha', +} + +// Playlist parser +export enum KeySystemFormats { + CLEARKEY = 'org.w3.clearkey', + FAIRPLAY = 'com.apple.streamingkeydelivery', PLAYREADY = 'com.microsoft.playready', - FAIRPLAY = 'com.apple.fps', + WIDEVINE = 'urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed', +} + +export function keySystemFormatToKeySystemDomain( + format: KeySystemFormats +): KeySystems | undefined { + if (format === KeySystemFormats.FAIRPLAY) { + return KeySystems.FAIRPLAY; + } else if (format === KeySystemFormats.PLAYREADY) { + return KeySystems.PLAYREADY; + } else if (format === KeySystemFormats.WIDEVINE) { + return KeySystems.WIDEVINE; + } else if (format === KeySystemFormats.CLEARKEY) { + return KeySystems.CLEARKEY; + } +} + +export function keySystemDomainToKeySystemFormat( + keySystem: KeySystems +): KeySystemFormats | undefined { + if (keySystem === KeySystems.FAIRPLAY) { + return KeySystemFormats.FAIRPLAY; + } else if (keySystem === KeySystems.PLAYREADY) { + return KeySystemFormats.PLAYREADY; + } else if (keySystem === KeySystems.WIDEVINE) { + return KeySystemFormats.WIDEVINE; + } else if (keySystem === KeySystems.CLEARKEY) { + return KeySystemFormats.CLEARKEY; + } +} + +export function getKeySystemsForConfig( + config: EMEControllerConfig +): KeySystems[] { + const { drmSystems, widevineLicenseUrl } = config; + const keySystemsToAttempt: KeySystems[] = []; + [KeySystems.FAIRPLAY, KeySystems.PLAYREADY, KeySystems.CLEARKEY].forEach( + (keySystem) => { + if (drmSystems?.[keySystem]) { + keySystemsToAttempt.push(keySystem); + } + } + ); + if (widevineLicenseUrl || drmSystems?.[KeySystems.WIDEVINE]) { + keySystemsToAttempt.push(KeySystems.WIDEVINE); + } else if (keySystemsToAttempt.length === 0) { + keySystemsToAttempt.push( + KeySystems.WIDEVINE, + KeySystems.FAIRPLAY, + KeySystems.PLAYREADY + ); + } + return keySystemsToAttempt; } export type MediaKeyFunc = ( keySystem: KeySystems, supportedConfigurations: MediaKeySystemConfiguration[] ) => Promise; -const requestMediaKeySystemAccess = (function (): MediaKeyFunc | null { + +export const requestMediaKeySystemAccess = (function (): MediaKeyFunc | null { if ( typeof self !== 'undefined' && self.navigator && @@ -23,4 +87,68 @@ const requestMediaKeySystemAccess = (function (): MediaKeyFunc | null { } })(); -export { requestMediaKeySystemAccess }; +/** + * @see https://developer.mozilla.org/en-US/docs/Web/API/MediaKeySystemConfiguration + */ +export function getSupportedMediaKeySystemConfigurations( + keySystem: KeySystems, + audioCodecs: string[], + videoCodecs: string[], + drmSystemOptions: DRMSystemOptions +): MediaKeySystemConfiguration[] { + let initDataTypes: string[]; + switch (keySystem) { + case KeySystems.FAIRPLAY: + initDataTypes = ['cenc', 'sinf', 'skd']; + break; + case KeySystems.WIDEVINE: + case KeySystems.CLEARKEY: + initDataTypes = ['cenc', 'keyids']; + break; + case KeySystems.PLAYREADY: + initDataTypes = ['cenc']; + break; + default: + throw new Error(`Unknown key-system: ${keySystem}`); + } + return createMediaKeySystemConfigurations( + initDataTypes, + audioCodecs, + videoCodecs, + drmSystemOptions + ); +} + +function createMediaKeySystemConfigurations( + initDataTypes: string[], + audioCodecs: string[], + videoCodecs: string[], + drmSystemOptions: DRMSystemOptions +): MediaKeySystemConfiguration[] { + const baseConfig: MediaKeySystemConfiguration = { + initDataTypes: initDataTypes, + persistentState: drmSystemOptions.persistentState || 'not-allowed', + distinctiveIdentifier: + drmSystemOptions.distinctiveIdentifier || 'not-allowed', + sessionTypes: drmSystemOptions.sessionTypes || [ + drmSystemOptions.sessionType || 'temporary', + ], + audioCapabilities: [], + videoCapabilities: [], + }; + + audioCodecs.forEach((codec) => { + baseConfig.audioCapabilities!.push({ + contentType: `audio/mp4; codecs="${codec}"`, + robustness: drmSystemOptions.audioRobustness || '', + }); + }); + videoCodecs.forEach((codec) => { + baseConfig.videoCapabilities!.push({ + contentType: `video/mp4; codecs="${codec}"`, + robustness: drmSystemOptions.videoRobustness || '', + }); + }); + + return [baseConfig]; +} diff --git a/src/utils/mp4-tools.ts b/src/utils/mp4-tools.ts index f38b1c303a0..9fadb196ae4 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 @@ -962,3 +1018,77 @@ export function parseEmsg(data: Uint8Array): IEmsgParsingData { payload, }; } + +export function mp4Box(type: ArrayLike, ...params: Uint8Array[]) { + const payload = Array.prototype.slice.call(arguments, 1); + const len = payload.length; + let size = 8; + let i = len; + while (i--) { + size += payload[i].byteLength; + } + const result = new Uint8Array(size); + result[0] = (size >> 24) & 0xff; + result[1] = (size >> 16) & 0xff; + result[2] = (size >> 8) & 0xff; + result[3] = size & 0xff; + result.set(type, 4); + for (i = 0, size = 8; i < len; i++) { + result.set(payload[i], size); + size += payload[i].byteLength; + } + return result; +} + +export function mp4pssh( + systemId: Uint8Array, + keyids: Array | null, + data: Uint8Array +) { + if (systemId.byteLength !== 16) { + throw new RangeError('Invalid system id'); + } + let version; + let kids; + if (keyids) { + version = 1; + kids = new Uint8Array(keyids.length * 16); + for (let ix = 0; ix < keyids.length; ix++) { + const k = keyids[ix]; // uint8array + if (k.byteLength !== 16) { + throw new RangeError('Invalid key'); + } + kids.set(k, ix * 16); + } + } else { + version = 0; + kids = new Uint8Array(); + } + let kidCount; + if (version > 0) { + kidCount = new Uint8Array(4); + if (keyids!.length > 0) { + new DataView(kidCount.buffer).setUint32(0, keyids!.length, false); + } + } else { + kidCount = new Uint8Array(); + } + const dataSize = new Uint8Array(4); + if (data && data.byteLength > 0) { + new DataView(dataSize.buffer).setUint32(0, data.byteLength, false); + } + return mp4Box( + [112, 115, 115, 104], + new Uint8Array([ + version, + 0x00, + 0x00, + 0x00, // Flags + ]), + systemId, // 16 bytes + kidCount, + kids, + dataSize, + data || new Uint8Array() + ); +} diff --git a/src/utils/numeric-encoding-utils.ts b/src/utils/numeric-encoding-utils.ts new file mode 100644 index 00000000000..f3318e190da --- /dev/null +++ b/src/utils/numeric-encoding-utils.ts @@ -0,0 +1,26 @@ +export function base64ToBase64Url(base64encodedStr: string): string { + return base64encodedStr + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +export function strToBase64Encode(str: string): string { + return btoa(str); +} + +export function base64DecodeToStr(str: string): string { + return atob(str); +} + +export function base64Encode(input: Uint8Array): string { + return btoa(String.fromCharCode(...input)); +} + +export function base64UrlEncode(input: Uint8Array): string { + return base64ToBase64Url(base64Encode(input)); +} + +export function base64Decode(base64encodedStr: string): Uint8Array { + return Uint8Array.from(atob(base64encodedStr), (c) => c.charCodeAt(0)); +} diff --git a/tests/unit/controller/audio-stream-controller.js b/tests/unit/controller/audio-stream-controller.js index ddc10858ca5..26f349b321a 100644 --- a/tests/unit/controller/audio-stream-controller.js +++ b/tests/unit/controller/audio-stream-controller.js @@ -1,6 +1,8 @@ import AudioStreamController from '../../../src/controller/audio-stream-controller'; import Hls from '../../../src/hls'; import { Events } from '../../../src/events'; +import { FragmentTracker } from '../../../src/controller/fragment-tracker'; +import KeyLoader from '../../../src/loader/key-loader'; describe('AudioStreamController', function () { const tracks = [ @@ -41,11 +43,19 @@ describe('AudioStreamController', function () { ]; let hls; + let fragmentTracker; + let keyLoader; let audioStreamController; beforeEach(function () { hls = new Hls(); - audioStreamController = new AudioStreamController(hls); + fragmentTracker = new FragmentTracker(hls); + keyLoader = new KeyLoader({}); + audioStreamController = new AudioStreamController( + hls, + fragmentTracker, + keyLoader + ); }); afterEach(function () { diff --git a/tests/unit/controller/eme-controller.js b/tests/unit/controller/eme-controller.js deleted file mode 100644 index 2abbc189362..00000000000 --- a/tests/unit/controller/eme-controller.js +++ /dev/null @@ -1,355 +0,0 @@ -import EMEController from '../../../src/controller/eme-controller'; -import HlsMock from '../../mocks/hls.mock'; -import { EventEmitter } from 'eventemitter3'; -import { ErrorDetails } from '../../../src/errors'; -import { Events } from '../../../src/events'; - -const sinon = require('sinon'); - -const MediaMock = function () { - const media = new EventEmitter(); - media.setMediaKeys = sinon.spy(); - media.addEventListener = media.addListener.bind(media); - media.removeEventListener = media.removeListener.bind(media); - return media; -}; - -const fakeLevels = [ - { - audioCodec: 'audio/foo', - }, - { - videoCodec: 'video/foo', - }, -]; - -let emeController; -let media; - -const setupEach = function (config) { - media = new MediaMock(); - - emeController = new EMEController(new HlsMock(config)); -}; - -const setupXHRMock = function () { - const xhr = { - readyState: 0, - - open: () => {}, - send: () => {}, - }; - - self.XMLHttpRequest = function () { - return xhr; - }; - - self.XMLHttpRequest.DONE = 4; - - return xhr; -}; - -describe('EMEController', function () { - beforeEach(function () { - setupEach(); - }); - - it('should not do anything when `emeEnabled` is false (default)', function () { - const reqMediaKsAccessSpy = sinon.spy(); - - setupEach({ - requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy, - }); - - emeController.onMediaAttached(Events.MEDIA_ATTACHED, { media }); - emeController.onManifestParsed(Events.MANIFEST_PARSED, { media }); - - expect(media.setMediaKeys.callCount).to.equal(0); - expect(reqMediaKsAccessSpy.callCount).to.equal(0); - }); - - it('should request keys when `emeEnabled` is true (but not set them)', function (done) { - const reqMediaKsAccessSpy = sinon.spy(function () { - return Promise.resolve({ - // Media-keys mock - }); - }); - - setupEach({ - emeEnabled: true, - requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy, - }); - - emeController.onMediaAttached(Events.MEDIA_ATTACHED, { media }); - - expect(media.setMediaKeys.callCount).to.equal(0); - expect(reqMediaKsAccessSpy.callCount).to.equal(0); - - emeController.onManifestParsed(Events.MANIFEST_PARSED, { - levels: fakeLevels, - }); - - self.setTimeout(function () { - expect(media.setMediaKeys.callCount).to.equal(0); - expect(reqMediaKsAccessSpy.callCount).to.equal(1); - done(); - }, 0); - }); - - it('should request keys with specified robustness options when `emeEnabled` is true', function (done) { - const reqMediaKsAccessSpy = sinon.spy(function () { - return Promise.resolve({ - // Media-keys mock - }); - }); - - setupEach({ - emeEnabled: true, - drmSystemOptions: { - audioRobustness: 'HW_SECURE_ALL', - videoRobustness: 'HW_SECURE_ALL', - }, - requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy, - }); - - emeController.onMediaAttached(Events.MEDIA_ATTACHED, { media }); - - expect(media.setMediaKeys.callCount).to.equal(0); - expect(reqMediaKsAccessSpy.callCount).to.equal(0); - - emeController.onManifestParsed(Events.MANIFEST_PARSED, { - levels: fakeLevels, - }); - - self.setTimeout(function () { - expect(reqMediaKsAccessSpy.callCount).to.equal(1); - const baseConfig = reqMediaKsAccessSpy.getCall(0).args[1][0]; - expect(baseConfig.audioCapabilities[0]).to.have.property( - 'robustness', - 'HW_SECURE_ALL' - ); - expect(baseConfig.videoCapabilities[0]).to.have.property( - 'robustness', - 'HW_SECURE_ALL' - ); - done(); - }, 0); - }); - - it('should trigger key system error(s) when bad encrypted data is received', function (done) { - const reqMediaKsAccessSpy = sinon.spy(function () { - return Promise.resolve({ - // Media-keys mock - }); - }); - - setupEach({ - emeEnabled: true, - requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy, - }); - - const badData = { - initDataType: 'cenc', - initData: 'bad data', - }; - - emeController.onMediaAttached(Events.MEDIA_ATTACHED, { media }); - emeController.onManifestParsed(Events.MANIFEST_PARSED, { - levels: fakeLevels, - }); - - media.emit('encrypted', badData); - - self.setTimeout(function () { - expect(emeController.hls.trigger).to.have.been.calledTwice; - expect(emeController.hls.trigger.args[0][1].details).to.equal( - ErrorDetails.KEY_SYSTEM_NO_KEYS - ); - expect(emeController.hls.trigger.args[1][1].details).to.equal( - ErrorDetails.KEY_SYSTEM_NO_SESSION - ); - done(); - }, 0); - }); - - it('should fetch the server certificate and set it into the session', function (done) { - const xhr = setupXHRMock(); - - const mediaKeysSetServerCertificateSpy = sinon.spy(() => Promise.resolve()); - - const reqMediaKsAccessSpy = sinon.spy(function () { - return Promise.resolve({ - // Media-keys mock - createMediaKeys: sinon.spy(() => - Promise.resolve({ - setServerCertificate: mediaKeysSetServerCertificateSpy, - createSession: () => ({ - addEventListener: () => {}, - }), - }) - ), - }); - }); - - setupEach({ - emeEnabled: true, - requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy, - drmSystems: { - 'com.apple.fps': { - serverCertificateUrl: 'https://example.com/certificate.cer', - }, - }, - }); - - emeController.onMediaAttached(Events.MEDIA_ATTACHED, { media }); - emeController.onManifestParsed(Events.MANIFEST_PARSED, { levels: [] }); - - self.setTimeout(() => { - xhr.status = 200; - xhr.readyState = XMLHttpRequest.DONE; - xhr.response = new Uint8Array(); - xhr.onreadystatechange(); - }, 0); - - emeController.mediaKeysPromise.finally(() => { - expect(mediaKeysSetServerCertificateSpy).to.have.been.calledOnce; - expect(mediaKeysSetServerCertificateSpy.args[0][0]).to.equal( - xhr.response - ); - - done(); - }); - }); - - it('should fetch the server certificate and trigger update failed error', function (done) { - const xhr = setupXHRMock(); - - const mediaKeysSetServerCertificateSpy = sinon.spy(() => - Promise.reject(new Error('Failed')) - ); - - const reqMediaKsAccessSpy = sinon.spy(function () { - return Promise.resolve({ - // Media-keys mock - createMediaKeys: sinon.spy(() => - Promise.resolve({ - setServerCertificate: mediaKeysSetServerCertificateSpy, - createSession: () => ({ - addEventListener: () => {}, - }), - }) - ), - }); - }); - - setupEach({ - emeEnabled: true, - requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy, - drmSystems: { - 'com.apple.fps': { - serverCertificateUrl: 'https://example.com/certificate.cer', - }, - }, - }); - - emeController.onMediaAttached(Events.MEDIA_ATTACHED, { media }); - emeController.onManifestParsed(Events.MANIFEST_PARSED, { levels: [] }); - - self.setTimeout(() => { - xhr.status = 200; - xhr.readyState = XMLHttpRequest.DONE; - xhr.response = new Uint8Array(); - xhr.onreadystatechange(); - }, 0); - - emeController.mediaKeysPromise.finally(() => { - expect(mediaKeysSetServerCertificateSpy).to.have.been.calledOnce; - expect(mediaKeysSetServerCertificateSpy.args[0][0]).to.equal( - xhr.response - ); - - expect(emeController.hls.trigger).to.have.been.calledOnce; - expect(emeController.hls.trigger.args[0][1].details).to.equal( - ErrorDetails.KEY_SYSTEM_SERVER_CERTIFICATE_UPDATE_FAILED - ); - - done(); - }); - }); - - it('should fetch the server certificate and trigger request failed error', function (done) { - const xhr = setupXHRMock(); - - const reqMediaKsAccessSpy = sinon.spy(function () { - return Promise.resolve({ - // Media-keys mock - createMediaKeys: sinon.spy(() => - Promise.resolve({ - createSession: () => ({ - addEventListener: () => {}, - }), - }) - ), - }); - }); - - setupEach({ - emeEnabled: true, - requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy, - drmSystems: { - 'com.apple.fps': { - serverCertificateUrl: 'https://example.com/certificate.cer', - }, - }, - }); - - emeController.onMediaAttached(Events.MEDIA_ATTACHED, { media }); - emeController.onManifestParsed(Events.MANIFEST_PARSED, { levels: [] }); - - self.setTimeout(() => { - xhr.status = 400; - xhr.readyState = XMLHttpRequest.DONE; - xhr.onreadystatechange(); - }, 0); - - emeController.mediaKeysPromise.finally(() => { - expect(emeController.hls.trigger).to.have.been.calledOnce; - expect(emeController.hls.trigger.args[0][1].details).to.equal( - ErrorDetails.KEY_SYSTEM_SERVER_CERTIFICATE_REQUEST_FAILED - ); - - done(); - }); - }); - - it('should close all media key sessions and remove media keys when media is detached', function (done) { - const reqMediaKsAccessSpy = sinon.spy(function () { - return Promise.resolve({ - // Media-keys mock - }); - }); - const keySessionCloseSpy = sinon.spy(() => Promise.resolve()); - - setupEach({ - emeEnabled: true, - requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy, - }); - - emeController.onMediaAttached(Events.MEDIA_ATTACHED, { media }); - emeController._mediaKeysList = [ - { - mediaKeysSession: { - close: keySessionCloseSpy, - }, - }, - ]; - emeController.onMediaDetached(Events.MEDIA_DETACHED); - - self.setTimeout(function () { - expect(keySessionCloseSpy.callCount).to.equal(1); - expect(emeController._mediaKeysList.length).to.equal(0); - expect(media.setMediaKeys.calledWith(null)).to.be.true; - done(); - }, 0); - }); -}); diff --git a/tests/unit/controller/eme-controller.ts b/tests/unit/controller/eme-controller.ts new file mode 100644 index 00000000000..18b0441d607 --- /dev/null +++ b/tests/unit/controller/eme-controller.ts @@ -0,0 +1,591 @@ +import EMEController, { + MediaKeySessionContext, +} from '../../../src/controller/eme-controller'; +import HlsMock from '../../mocks/hls.mock'; +import { EventEmitter } from 'eventemitter3'; +import { ErrorDetails } from '../../../src/errors'; +import { Events } from '../../../src/events'; + +import * as sinon from 'sinon'; +import * as chai from 'chai'; +import * as sinonChai from 'sinon-chai'; +import { MediaAttachedData } from '../../../src/types/events'; + +chai.use(sinonChai); +const expect = chai.expect; + +type EMEControllerTestable = Omit< + EMEController, + 'hls' | 'keyUriToKeySessionPromise' | 'mediaKeySessions' +> & { + hls: HlsMock; + keyUriToKeySessionPromise: { + [keyuri: string]: Promise; + }; + mediaKeySessions: MediaKeySessionContext[]; + onMediaAttached: ( + event: Events.MEDIA_ATTACHED, + data: MediaAttachedData + ) => void; + onMediaDetached: () => void; +}; + +class MediaMock extends EventEmitter { + setMediaKeys: sinon.SinonSpy<[mediaKeys: MediaKeys | null], Promise>; + addEventListener: any; + removeEventListener: any; + constructor() { + super(); + this.setMediaKeys = sinon.spy((mediaKeys: MediaKeys | null) => + Promise.resolve() + ); + this.addEventListener = this.addListener.bind(this); + this.removeEventListener = this.removeListener.bind(this); + } +} + +let emeController: EMEControllerTestable; +let media: MediaMock; +let sinonFakeXMLHttpRequestStatic: sinon.SinonFakeXMLHttpRequestStatic; + +const setupEach = function (config) { + const hls = new HlsMock(config); + hls.levels = [ + { + audioCodec: 'audio/foo', + }, + { + videoCodec: 'video/foo', + }, + ]; + media = new MediaMock(); + emeController = new EMEController(hls) as any as EMEControllerTestable; + sinonFakeXMLHttpRequestStatic = sinon.useFakeXMLHttpRequest(); +}; + +describe('EMEController', function () { + beforeEach(function () { + setupEach({}); + }); + + afterEach(function () { + sinonFakeXMLHttpRequestStatic.restore(); + }); + + it('should request keysystem access based on key format when `emeEnabled` is true', function () { + const reqMediaKsAccessSpy = sinon.spy(function () { + return Promise.resolve({ + // Media-keys mock + keySystem: 'com.apple.fps', + createMediaKeys: sinon.spy(() => + Promise.resolve({ + setServerCertificate: () => Promise.resolve(), + createSession: (): Partial => ({ + addEventListener: () => {}, + onmessage: null, + onkeystatuseschange: null, + generateRequest() { + return Promise.resolve().then(() => { + this.onmessage({ + messageType: 'license-request', + message: new Uint8Array(0), + }); + this.keyStatuses.set(new Uint8Array(0), 'usable'); + this.onkeystatuseschange({}); + }); + }, + remove: () => Promise.resolve(), + update: () => Promise.resolve(), + keyStatuses: new Map(), + }), + }) + ), + }); + }); + + setupEach({ + emeEnabled: true, + drmSystems: { + 'com.apple.fps': { + licenseUrl: 'http://noop', + }, + }, + requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy, + // useEmeEncryptedEvent: true, // skip generate request, license exchange, and key status "usable" + }); + + sinonFakeXMLHttpRequestStatic.onCreate = ( + xhr: sinon.SinonFakeXMLHttpRequest + ) => { + self.setTimeout(() => { + (xhr as any).response = new Uint8Array(); + xhr.respond(200, {}, ''); + }, 0); + }; + + emeController.onMediaAttached(Events.MEDIA_ATTACHED, { + media: media as any as HTMLMediaElement, + }); + + expect(media.setMediaKeys).callCount(0); + expect(reqMediaKsAccessSpy).callCount(0); + + const emePromise = emeController.loadKey({ + frag: {}, + keyInfo: { + decryptdata: { + encrypted: true, + method: 'SAMPLE-AES', + keyFormat: 'com.apple.streamingkeydelivery', + uri: 'data://key-uri', + keyId: new Uint8Array(16), + pssh: new Uint8Array(16), + }, + }, + } as any); + + expect(emePromise).to.be.a('Promise'); + if (!emePromise) { + return; + } + return emePromise.finally(() => { + expect(media.setMediaKeys).callCount(1); + expect(reqMediaKsAccessSpy).callCount(1); + }); + }); + + it('should request keys with specified robustness options when `emeEnabled` is true', function () { + const reqMediaKsAccessSpy = sinon.spy(function () { + return Promise.resolve({ + // Media-keys mock + keySystem: 'com.apple.fps', + createMediaKeys: sinon.spy(() => + Promise.resolve({ + setServerCertificate: () => Promise.resolve(), + createSession: (): Partial => ({ + addEventListener: () => {}, + onmessage: null, + onkeystatuseschange: null, + generateRequest() { + return Promise.resolve().then(() => { + this.onmessage({ + messageType: 'license-request', + message: new Uint8Array(0), + }); + this.keyStatuses.set(new Uint8Array(0), 'usable'); + this.onkeystatuseschange({}); + }); + }, + remove: () => Promise.resolve(), + update: () => Promise.resolve(), + keyStatuses: new Map(), + }), + }) + ), + }); + }); + + setupEach({ + emeEnabled: true, + drmSystems: { + 'com.apple.fps': { + licenseUrl: 'http://noop', + }, + }, + drmSystemOptions: { + audioRobustness: 'HW_SECURE_ALL', + videoRobustness: 'HW_SECURE_ALL', + }, + requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy, + }); + + sinonFakeXMLHttpRequestStatic.onCreate = ( + xhr: sinon.SinonFakeXMLHttpRequest + ) => { + self.setTimeout(() => { + (xhr as any).response = new Uint8Array(); + xhr.respond(200, {}, ''); + }, 0); + }; + + emeController.onMediaAttached(Events.MEDIA_ATTACHED, { + media: media as any as HTMLMediaElement, + }); + + expect(media.setMediaKeys).callCount(0); + expect(reqMediaKsAccessSpy).callCount(0); + + const emePromise = emeController.loadKey({ + frag: {}, + keyInfo: { + decryptdata: { + encrypted: true, + method: 'SAMPLE-AES', + keyFormat: 'com.apple.streamingkeydelivery', + uri: 'data://key-uri', + keyId: new Uint8Array(16), + pssh: new Uint8Array(16), + }, + }, + } as any); + + expect(emePromise).to.be.a('Promise'); + if (!emePromise) { + return; + } + return emePromise.finally(() => { + expect(reqMediaKsAccessSpy).callCount(1); + const args = reqMediaKsAccessSpy.getCall(0) + .args as MediaKeySystemConfiguration[][]; + const baseConfig: MediaKeySystemConfiguration = args[1][0]; + expect(baseConfig.audioCapabilities) + .to.be.an('array') + .with.property('0') + .with.property('robustness', 'HW_SECURE_ALL'); + expect(baseConfig.videoCapabilities) + .to.be.an('array') + .with.property('0') + .with.property('robustness', 'HW_SECURE_ALL'); + }); + }); + + it('should trigger key system error(s) when bad encrypted data is received', function () { + const reqMediaKsAccessSpy = sinon.spy(function () { + return Promise.resolve({ + // Media-keys mock + keySystem: 'com.apple.fps', + createMediaKeys: sinon.spy(() => + Promise.resolve({ + setServerCertificate: () => Promise.resolve(), + createSession: () => ({ + addEventListener: () => {}, + generateRequest: () => Promise.reject(new Error('bad data')), + remove: () => Promise.resolve(), + update: () => Promise.resolve(), + keyStatuses: new Map(), + }), + }) + ), + }); + }); + + setupEach({ + emeEnabled: true, + useEmeEncryptedEvent: true, + requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy, + }); + + const badData = { + initDataType: 'cenc', + initData: 'bad data', + }; + + emeController.onMediaAttached(Events.MEDIA_ATTACHED, { + media: media as any as HTMLMediaElement, + }); + + media.emit('encrypted', badData); + + expect(emeController.keyUriToKeySessionPromise.encrypted).to.be.a( + 'Promise' + ); + if (!emeController.keyUriToKeySessionPromise.encrypted) { + return; + } + return emeController.keyUriToKeySessionPromise.encrypted + .catch(() => {}) + .finally(() => { + expect(emeController.hls.trigger).callCount(1); + expect(emeController.hls.trigger.args[0][1].details).to.equal( + ErrorDetails.KEY_SYSTEM_NO_SESSION + ); + }); + }); + + it('should fetch the server certificate and set it into the session', function () { + const mediaKeysSetServerCertificateSpy = sinon.spy(() => Promise.resolve()); + + const reqMediaKsAccessSpy = sinon.spy(function () { + return Promise.resolve({ + // Media-keys mock + keySystem: 'com.apple.fps', + createMediaKeys: sinon.spy(() => + Promise.resolve({ + setServerCertificate: mediaKeysSetServerCertificateSpy, + createSession: () => ({ + addEventListener: () => {}, + onmessage: null, + onkeystatuseschange: null, + generateRequest() { + return Promise.resolve().then(() => { + this.onmessage({ + messageType: 'license-request', + message: new Uint8Array(0), + }); + this.keyStatuses.set(new Uint8Array(0), 'usable'); + this.onkeystatuseschange({}); + }); + }, + remove: () => Promise.resolve(), + update: () => Promise.resolve(), + keyStatuses: new Map(), + }), + }) + ), + }); + }); + + setupEach({ + emeEnabled: true, + requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy, + drmSystems: { + 'com.apple.fps': { + serverCertificateUrl: 'https://example.com/certificate.cer', + }, + }, + }); + + let xhrInstance; + sinonFakeXMLHttpRequestStatic.onCreate = ( + xhr: sinon.SinonFakeXMLHttpRequest + ) => { + xhrInstance = xhr; + self.setTimeout(() => { + (xhr as any).response = new Uint8Array(); + xhr.respond(200, {}, ''); + }, 0); + }; + + emeController.onMediaAttached(Events.MEDIA_ATTACHED, { + media: media as any as HTMLMediaElement, + }); + emeController.loadKey({ + frag: {}, + keyInfo: { + decryptdata: { + encrypted: true, + method: 'SAMPLE-AES', + uri: 'data://key-uri', + keyFormatVersions: [1], + keyId: new Uint8Array(16), + pssh: new Uint8Array(16), + }, + }, + } as any); + + expect(emeController.keyUriToKeySessionPromise['data://key-uri']).to.be.a( + 'Promise' + ); + if (!emeController.keyUriToKeySessionPromise['data://key-uri']) { + return; + } + return emeController.keyUriToKeySessionPromise['data://key-uri'].finally( + () => { + expect(mediaKeysSetServerCertificateSpy).to.have.been.calledOnce; + expect(mediaKeysSetServerCertificateSpy).to.have.been.calledWith( + xhrInstance.response + ); + } + ); + }); + + it('should fetch the server certificate and trigger update failed error', function () { + const mediaKeysSetServerCertificateSpy = sinon.spy(() => + Promise.reject(new Error('Failed')) + ); + + const reqMediaKsAccessSpy = sinon.spy(function () { + return Promise.resolve({ + // Media-keys mock + keySystem: 'com.apple.fps', + createMediaKeys: sinon.spy(() => + Promise.resolve({ + setServerCertificate: mediaKeysSetServerCertificateSpy, + createSession: () => ({ + addEventListener: () => {}, + generateRequest: () => Promise.resolve(), + remove: () => Promise.resolve(), + update: () => Promise.resolve(), + keyStatuses: new Map(), + }), + }) + ), + }); + }); + + setupEach({ + emeEnabled: true, + requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy, + drmSystems: { + 'com.apple.fps': { + serverCertificateUrl: 'https://example.com/certificate.cer', + }, + }, + }); + + let xhrInstance; + sinonFakeXMLHttpRequestStatic.onCreate = ( + xhr: sinon.SinonFakeXMLHttpRequest + ) => { + xhrInstance = xhr; + self.setTimeout(() => { + (xhr as any).response = new Uint8Array(); + xhr.respond(200, {}, ''); + }, 0); + }; + + emeController.onMediaAttached(Events.MEDIA_ATTACHED, { + media: media as any as HTMLMediaElement, + }); + emeController.loadKey({ + frag: {}, + keyInfo: { + decryptdata: { + encrypted: true, + method: 'SAMPLE-AES', + uri: 'data://key-uri', + }, + }, + } as any); + + expect( + emeController.keyUriToKeySessionPromise['data://key-uri'] + ).to.not.equal(null); + if (!emeController.keyUriToKeySessionPromise['data://key-uri']) { + return; + } + return emeController.keyUriToKeySessionPromise['data://key-uri'] + .catch(() => {}) + .finally(() => { + expect(mediaKeysSetServerCertificateSpy).to.have.been.calledOnce; + expect((mediaKeysSetServerCertificateSpy.args[0] as any)[0]).to.equal( + xhrInstance.response + ); + + expect(emeController.hls.trigger).to.have.been.calledOnce; + expect(emeController.hls.trigger.args[0][1].details).to.equal( + ErrorDetails.KEY_SYSTEM_SERVER_CERTIFICATE_UPDATE_FAILED + ); + }); + }); + + it('should fetch the server certificate and trigger request failed error', function () { + const reqMediaKsAccessSpy = sinon.spy(function () { + return Promise.resolve({ + // Media-keys mock + keySystem: 'com.apple.fps', + createMediaKeys: sinon.spy(() => + Promise.resolve({ + createSession: () => ({ + addEventListener: () => {}, + generateRequest: () => Promise.resolve(), + remove: () => Promise.resolve(), + update: () => Promise.resolve(), + keyStatuses: new Map(), + }), + }) + ), + }); + }); + + setupEach({ + emeEnabled: true, + requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy, + drmSystems: { + 'com.apple.fps': { + serverCertificateUrl: 'https://example.com/certificate.cer', + }, + }, + }); + + sinonFakeXMLHttpRequestStatic.onCreate = ( + xhr: sinon.SinonFakeXMLHttpRequest + ) => { + self.setTimeout(() => { + xhr.status = 400; + xhr.error(); + }, 0); + }; + + emeController.onMediaAttached(Events.MEDIA_ATTACHED, { + media: media as any as HTMLMediaElement, + }); + emeController.loadKey({ + frag: {}, + keyInfo: { + decryptdata: { + encrypted: true, + method: 'SAMPLE-AES', + uri: 'data://key-uri', + }, + }, + } as any); + + expect( + emeController.keyUriToKeySessionPromise['data://key-uri'] + ).to.not.equal(null); + if (!emeController.keyUriToKeySessionPromise['data://key-uri']) { + return; + } + return emeController.keyUriToKeySessionPromise['data://key-uri'] + .catch(() => {}) + .finally(() => { + expect(emeController.hls.trigger).to.have.been.calledOnce; + expect(emeController.hls.trigger.args[0][1].details).to.equal( + ErrorDetails.KEY_SYSTEM_SERVER_CERTIFICATE_REQUEST_FAILED + ); + }); + }); + + it('should close all media key sessions and remove media keys when media is detached', function () { + const reqMediaKsAccessSpy = sinon.spy(function () { + return Promise.resolve({ + // Media-keys mock + keySystem: 'com.apple.fps', + createMediaKeys: sinon.spy(() => + Promise.resolve({ + setServerCertificate: () => Promise.resolve(), + createSession: () => ({ + addEventListener: () => {}, + generateRequest: () => Promise.resolve(), + remove: () => Promise.resolve(), + update: () => Promise.resolve(), + keyStatuses: new Map(), + }), + }) + ), + }); + }); + const keySessionRemoveSpy = sinon.spy(() => Promise.resolve()); + const keySessionCloseSpy = sinon.spy(() => Promise.resolve()); + + setupEach({ + emeEnabled: true, + requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy, + }); + + emeController.onMediaAttached(Events.MEDIA_ATTACHED, { + media: media as any as HTMLMediaElement, + }); + emeController.mediaKeySessions = [ + { + mediaKeysSession: { + remove: keySessionRemoveSpy, + close: keySessionCloseSpy, + }, + } as any, + ]; + emeController.onMediaDetached(); + + expect(EMEController.CDMCleanupPromise).to.be.a('Promise'); + if (!EMEController.CDMCleanupPromise) { + return; + } + return EMEController.CDMCleanupPromise.then(() => { + expect(keySessionRemoveSpy).callCount(1); + expect(keySessionCloseSpy).callCount(1); + expect(emeController.mediaKeySessions.length).to.equal(0); + expect(media.setMediaKeys).calledWith(null); + }); + }); +}); diff --git a/tests/unit/controller/subtitle-stream-controller.js b/tests/unit/controller/subtitle-stream-controller.js index 44a2964dffe..55c026c742a 100644 --- a/tests/unit/controller/subtitle-stream-controller.js +++ b/tests/unit/controller/subtitle-stream-controller.js @@ -3,6 +3,7 @@ import sinon from 'sinon'; import Hls from '../../../src/hls'; import { Events } from '../../../src/events'; import { FragmentTracker } from '../../../src/controller/fragment-tracker'; +import KeyLoader from '../../../src/loader/key-loader'; import { SubtitleStreamController } from '../../../src/controller/subtitle-stream-controller'; const mediaMock = { @@ -16,15 +17,19 @@ const tracksMock = [{ id: 0, details: { url: '' } }, { id: 1 }]; describe('SubtitleStreamController', function () { let hls; let fragmentTracker; + let keyLoader; let subtitleStreamController; beforeEach(function () { hls = new Hls({}); mediaMock.currentTime = 0; fragmentTracker = new FragmentTracker(hls); + keyLoader = new KeyLoader({}); + subtitleStreamController = new SubtitleStreamController( hls, - fragmentTracker + fragmentTracker, + keyLoader ); subtitleStreamController.onMediaAttached(Events.MEDIA_ATTACHED, { diff --git a/tests/unit/loader/fragment-loader.ts b/tests/unit/loader/fragment-loader.ts index 3f7d9ab1b49..bee9c8071df 100644 --- a/tests/unit/loader/fragment-loader.ts +++ b/tests/unit/loader/fragment-loader.ts @@ -1,5 +1,6 @@ import FragmentLoader, { LoadError } from '../../../src/loader/fragment-loader'; import { Fragment } from '../../../src/loader/fragment'; +import { LevelDetails } from '../../../src/loader/level-details'; import { ErrorDetails, ErrorTypes } from '../../../src/errors'; import { LoadStats } from '../../../src/loader/load-stats'; import { @@ -15,7 +16,6 @@ import type { HlsConfig } from '../../../src/config'; import * as sinon from 'sinon'; import * as chai from 'chai'; import * as sinonChai from 'sinon-chai'; -import { LevelDetails } from '../../../src/loader/level-details'; chai.use(sinonChai); const expect = chai.expect; diff --git a/tests/unit/loader/fragment.js b/tests/unit/loader/fragment.ts similarity index 60% rename from tests/unit/loader/fragment.js rename to tests/unit/loader/fragment.ts index ab222fe62bd..54566ce448a 100644 --- a/tests/unit/loader/fragment.js +++ b/tests/unit/loader/fragment.ts @@ -2,11 +2,17 @@ import { Fragment } from '../../../src/loader/fragment'; import { LevelKey } from '../../../src/loader/level-key'; import { PlaylistLevelType } from '../../../src/types/loader'; +import * as chai from 'chai'; +import * as sinonChai from 'sinon-chai'; + +chai.use(sinonChai); +const expect = chai.expect; + describe('Fragment class tests', function () { /** * @type {Fragment} */ - let frag; + let frag: Fragment; beforeEach(function () { frag = new Fragment(PlaylistLevelType.MAIN, ''); }); @@ -15,51 +21,37 @@ describe('Fragment class tests', function () { it('returns true if an EXT-X-KEY is associated with the fragment', function () { // From https://docs.microsoft.com/en-us/azure/media-services/previous/media-services-protect-with-aes128 - const key = LevelKey.fromURL( - 'https://wamsbayclus001kd-hs.cloudapp.net', - './HlsHandler.ashx?kid=da3813af-55e6-48e7-aa9f-a4d6031f7b4d' - ); - key.method = 'AES-128'; - key.iv = '0XD7D7D7D7D7D7D7D7D7D7D7D7D7D7D7D7'; - key.keyFormat = 'identity'; - frag.levelkey = key; - expect(frag.decryptdata.uri).to.equal( - 'https://wamsbayclus001kd-hs.cloudapp.net/HlsHandler.ashx?kid=da3813af-55e6-48e7-aa9f-a4d6031f7b4d' + const key = new LevelKey( + 'AES-128', + 'https://wamsbayclus001kd-hs.cloudapp.net/HlsHandler.ashx?kid=da3813af-55e6-48e7-aa9f-a4d6031f7b4d', + 'identity' ); + frag.levelkeys = { identity: key }; expect(frag.encrypted).to.equal(true); }); - it('returns true for widevine v2 manifest signalled encryption', function () { - // #EXT-X-KEY:METHOD=SAMPLE-AES,URI=”data:text/plain;base64,AAAAPXBzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAAB0aDXdpZGV2aW5lX3Rlc3QiDHRlc3QgY29udGVudA==”,KEYID=0x112233445566778899001122334455,KEYFORMAT=”urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed”,KEYFORMATVERSION=”1” - // From https://www.academia.edu/36030972/Widevine_DRM_for_HLS - - const key = LevelKey.fromURI( - 'data:text/plain;base64,AAAAPXBzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAAB0aDXdpZGV2aW5lX3Rlc3QiDHRlc3QgY29udGVudA==' - ); - key.method = 'SAMPLE-AES'; - key.keyFormat = 'urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed'; - key.keyFormatVersions = '1'; - frag.levelkey = key; - expect(frag.decryptdata.uri).to.equal( - 'data:text/plain;base64,AAAAPXBzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAAB0aDXdpZGV2aW5lX3Rlc3QiDHRlc3QgY29udGVudA==' + it('returns true for fairplay manifest signalled encryption', function () { + const key = new LevelKey( + 'SAMPLE-AES', + 'skd://one', + 'com.apple.streamingkeydelivery', + [1] ); + frag.levelkeys = { 'com.apple.streamingkeydelivery': key }; expect(frag.encrypted).to.equal(true); }); - it('returns true for widevine v1 manifest signalled encryption', function () { - // #EXT-X-KEY:METHOD=SAMPLE-AES,URI=”data:text/plain;base64,eyAKICAgInByb3ZpZGVyIjoibWxiYW1oYm8iLAogICAiY29udGVudF9pZCI6Ik1qQXhOVjlVWldGeWN3PT0iLAogICAia2V5X2lkcyI6CiAgIFsKICAgICAgIjM3MWUxMzVlMWE5ODVkNzVkMTk4YTdmNDEwMjBkYzIzIgogICBdCn0=",IV=0x6df49213a781e338628d0e9c812d328e,KEYFORMAT=”com.widevine”,KEYFORMATVERSIONS=”1” + it('returns true for widevine v2 manifest signalled encryption', function () { + // #EXT-X-KEY:METHOD=SAMPLE-AES,URI=”data:text/plain;base64,AAAAPXBzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAAB0aDXdpZGV2aW5lX3Rlc3QiDHRlc3QgY29udGVudA==”,KEYID=0x112233445566778899001122334455,KEYFORMAT=”urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed”,KEYFORMATVERSION=”1” // From https://www.academia.edu/36030972/Widevine_DRM_for_HLS - const key = LevelKey.fromURI( - 'data:text/plain;base64,eyAKICAgInByb3ZpZGVyIjoibWxiYW1oYm8iLAogICAiY29udGVudF9pZCI6Ik1qQXhOVjlVWldGeWN3PT0iLAogICAia2V5X2lkcyI6CiAgIFsKICAgICAgIjM3MWUxMzVlMWE5ODVkNzVkMTk4YTdmNDEwMjBkYzIzIgogICBdCn0=' - ); - key.method = 'SAMPLE-AES'; - key.keyFormat = 'com.widevine'; - key.keyFormatVersions = '1'; - frag.levelkey = key; - expect(frag.decryptdata.uri).to.equal( - 'data:text/plain;base64,eyAKICAgInByb3ZpZGVyIjoibWxiYW1oYm8iLAogICAiY29udGVudF9pZCI6Ik1qQXhOVjlVWldGeWN3PT0iLAogICAia2V5X2lkcyI6CiAgIFsKICAgICAgIjM3MWUxMzVlMWE5ODVkNzVkMTk4YTdmNDEwMjBkYzIzIgogICBdCn0=' + const key = new LevelKey( + 'SAMPLE-AES', + 'data:text/plain;base64,AAAAPXBzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAAB0aDXdpZGV2aW5lX3Rlc3QiDHRlc3QgY29udGVudA==', + 'urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed', + [1] ); + frag.levelkeys = { 'urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed': key }; expect(frag.encrypted).to.equal(true); }); @@ -67,18 +59,21 @@ describe('Fragment class tests', function () { // #EXT-X-KEY:METHOD=SAMPLE-AES,KEYFORMAT="com.microsoft.playready",KEYFORMATVERSIONS="1",URI="data:text/plain;charset=UTF-16;base64,xAEAAAEAAQC6ATwAVwBSAE0ASABFAEEARABFAFIAIAB4AG0AbABuAHMAPQAiAGgAdAB0AHAAOgAvAC8AcwBjAGgAZQBtAGEAcwAuAG0AaQBjAHIAbwBzAG8AZgB0AC4AYwBvAG0ALwBEAFIATQAvADIAMAAwADcALwAwADMALwBQAGwAYQB5AFIAZQBhAGQAeQBIAGUAYQBkAGUAcgAiACAAdgBlAHIAcwBpAG8AbgA9ACIANAAuADAALgAwAC4AMAAiAD4APABEAEEAVABBAD4APABQAFIATwBUAEUAQwBUAEkATgBGAE8APgA8AEsARQBZAEwARQBOAD4AMQA2ADwALwBLAEUAWQBMAEUATgA+ADwAQQBMAEcASQBEAD4AQQBFAFMAQwBUAFIAPAAvAEEATABHAEkARAA+ADwALwBQAFIATwBUAEUAQwBUAEkATgBGAE8APgA8AEsASQBEAD4AdgBHAFYAagBOAEsAZwBZAE0ARQBxAHAATwBMAGgAMQBWAGQAUgBUADAAQQA9AD0APAAvAEsASQBEAD4APAAvAEQAQQBUAEEAPgA8AC8AVwBSAE0ASABFAEEARABFAFIAPgA=" // From https://docs.microsoft.com/en-us/playready/packaging/mp4-based-formats-supported-by-playready-clients?tabs=case4 - const key = LevelKey.fromURI( - 'data:text/plain;charset=UTF-16;base64,xAEAAAEAAQC6ATwAVwBSAE0ASABFAEEARABFAFIAIAB4AG0AbABuAHMAPQAiAGgAdAB0AHAAOgAvAC8AcwBjAGgAZQBtAGEAcwAuAG0AaQBjAHIAbwBzAG8AZgB0AC4AYwBvAG0ALwBEAFIATQAvADIAMAAwADcALwAwADMALwBQAGwAYQB5AFIAZQBhAGQAeQBIAGUAYQBkAGUAcgAiACAAdgBlAHIAcwBpAG8AbgA9ACIANAAuADAALgAwAC4AMAAiAD4APABEAEEAVABBAD4APABQAFIATwBUAEUAQwBUAEkATgBGAE8APgA8AEsARQBZAEwARQBOAD4AMQA2ADwALwBLAEUAWQBMAEUATgA+ADwAQQBMAEcASQBEAD4AQQBFAFMAQwBUAFIAPAAvAEEATABHAEkARAA+ADwALwBQAFIATwBUAEUAQwBUAEkATgBGAE8APgA8AEsASQBEAD4AdgBHAFYAagBOAEsAZwBZAE0ARQBxAHAATwBMAGgAMQBWAGQAUgBUADAAQQA9AD0APAAvAEsASQBEAD4APAAvAEQAQQBUAEEAPgA8AC8AVwBSAE0ASABFAEEARABFAFIAPgA=' - ); - key.method = 'SAMPLE-AES'; - key.keyFormat = 'com.microsoft.playready'; - key.keyFormatVersions = '1'; - frag.levelkey = key; - expect(frag.decryptdata.uri).to.equal( - 'data:text/plain;charset=UTF-16;base64,xAEAAAEAAQC6ATwAVwBSAE0ASABFAEEARABFAFIAIAB4AG0AbABuAHMAPQAiAGgAdAB0AHAAOgAvAC8AcwBjAGgAZQBtAGEAcwAuAG0AaQBjAHIAbwBzAG8AZgB0AC4AYwBvAG0ALwBEAFIATQAvADIAMAAwADcALwAwADMALwBQAGwAYQB5AFIAZQBhAGQAeQBIAGUAYQBkAGUAcgAiACAAdgBlAHIAcwBpAG8AbgA9ACIANAAuADAALgAwAC4AMAAiAD4APABEAEEAVABBAD4APABQAFIATwBUAEUAQwBUAEkATgBGAE8APgA8AEsARQBZAEwARQBOAD4AMQA2ADwALwBLAEUAWQBMAEUATgA+ADwAQQBMAEcASQBEAD4AQQBFAFMAQwBUAFIAPAAvAEEATABHAEkARAA+ADwALwBQAFIATwBUAEUAQwBUAEkATgBGAE8APgA8AEsASQBEAD4AdgBHAFYAagBOAEsAZwBZAE0ARQBxAHAATwBMAGgAMQBWAGQAUgBUADAAQQA9AD0APAAvAEsASQBEAD4APAAvAEQAQQBUAEEAPgA8AC8AVwBSAE0ASABFAEEARABFAFIAPgA=' + const key = new LevelKey( + 'SAMPLE-AES', + 'data:text/plain;charset=UTF-16;base64,xAEAAAEAAQC6ATwAVwBSAE0ASABFAEEARABFAFIAIAB4AG0AbABuAHMAPQAiAGgAdAB0AHAAOgAvAC8AcwBjAGgAZQBtAGEAcwAuAG0AaQBjAHIAbwBzAG8AZgB0AC4AYwBvAG0ALwBEAFIATQAvADIAMAAwADcALwAwADMALwBQAGwAYQB5AFIAZQBhAGQAeQBIAGUAYQBkAGUAcgAiACAAdgBlAHIAcwBpAG8AbgA9ACIANAAuADAALgAwAC4AMAAiAD4APABEAEEAVABBAD4APABQAFIATwBUAEUAQwBUAEkATgBGAE8APgA8AEsARQBZAEwARQBOAD4AMQA2ADwALwBLAEUAWQBMAEUATgA+ADwAQQBMAEcASQBEAD4AQQBFAFMAQwBUAFIAPAAvAEEATABHAEkARAA+ADwALwBQAFIATwBUAEUAQwBUAEkATgBGAE8APgA8AEsASQBEAD4AdgBHAFYAagBOAEsAZwBZAE0ARQBxAHAATwBMAGgAMQBWAGQAUgBUADAAQQA9AD0APAAvAEsASQBEAD4APAAvAEQAQQBUAEEAPgA8AC8AVwBSAE0ASABFAEEARABFAFIAPgA=', + 'com.microsoft.playready', + [1] ); + frag.levelkeys = { 'com.microsoft.playready': key }; expect(frag.encrypted).to.equal(true); }); + + it('returns false when key method is NONE', function () { + const key = new LevelKey('NONE', 'plain-text', 'org.w3.clearkey', [1]); + frag.levelkeys = { NONE: key }; + expect(frag.encrypted).to.equal(false); + }); }); describe('setByteRange', function () { @@ -117,14 +112,14 @@ describe('Fragment class tests', function () { }); it('returns null if pdt is NaN', function () { - frag.programDateTime = 'foo'; + frag.programDateTime = NaN; frag.duration = 1; expect(frag.endProgramDateTime).to.equal(null); }); it('defaults duration to 0 if duration is NaN', function () { frag.programDateTime = 1000; - frag.duration = 'foo'; + frag.duration = NaN; expect(frag.endProgramDateTime).to.equal(1000); }); }); diff --git a/tests/unit/loader/playlist-loader.js b/tests/unit/loader/playlist-loader.ts similarity index 90% rename from tests/unit/loader/playlist-loader.js rename to tests/unit/loader/playlist-loader.ts index 4adccc0afa1..6d7367e435f 100644 --- a/tests/unit/loader/playlist-loader.js +++ b/tests/unit/loader/playlist-loader.ts @@ -2,6 +2,14 @@ import M3U8Parser from '../../../src/loader/m3u8-parser'; import { AttrList } from '../../../src/utils/attr-list'; import { PlaylistLevelType } from '../../../src/types/loader'; +import * as chai from 'chai'; +import * as sinonChai from 'sinon-chai'; +import { LevelKey } from '../../../src/loader/level-key'; +import { Fragment, Part } from '../../../src/loader/fragment'; + +chai.use(sinonChai); +const expect = chai.expect; + describe('PlaylistLoader', function () { it('parses empty manifest returns empty array', function () { const result = M3U8Parser.parseMasterPlaylist( @@ -70,7 +78,7 @@ http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/ manifest, 'http://www.dailymotion.com' ); - expect(result.levels.length, 1); + expect(result.levels.length).to.equal(1); expect(result.levels[0].bitrate).to.equal(836280); expect(result.levels[0].audioCodec).to.not.exist; expect(result.levels[0].videoCodec).to.not.exist; @@ -92,13 +100,13 @@ http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/ manifest, 'http://www.dailymotion.com' ); - expect(result.levels.length, 1); - expect(result.levels[0].bitrate, 836280); - expect(result.levels[0].audioCodec, 'mp4a.40.2'); - expect(result.levels[0].videoCodec, 'avc1.64001f'); - expect(result.levels[0].width, 848); - expect(result.levels[0].height, 360); - expect(result.levels[0].name, '480'); + expect(result.levels.length).to.equal(1); + expect(result.levels[0].bitrate).to.equal(836280); + expect(result.levels[0].audioCodec).to.equal('mp4a.40.2'); + expect(result.levels[0].videoCodec).to.equal('avc1.64001f'); + expect(result.levels[0].width).to.equal(848); + expect(result.levels[0].height).to.equal(360); + expect(result.levels[0].name).to.equal('480'); expect( result.levels[0].url, 'http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/282/158282701_mp4_h264_aac_hq.m3u8#cell=core' @@ -113,7 +121,7 @@ http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/ manifest, 'http://www.dailymotion.com' ); - expect(result.levels.length, 1); + expect(result.levels.length).to.equal(1); expect(result.levels[0].bitrate).to.equal(836280); expect(result.levels[0].audioCodec).to.equal('mp4a.40.2'); expect(result.levels[0].videoCodec).to.equal('avc1.64001f'); @@ -173,7 +181,7 @@ http://proxy-21.dailymotion.com/sec(2a991e17f08fcd94f95637a6dd718ddd)/video/107/ manifest, 'http://www.dailymotion.com' ); - expect(result.levels.length, 10); + expect(result.levels.length).to.equal(10); expect(result.levels[0].bitrate).to.equal(836280); expect(result.levels[1].bitrate).to.equal(836280); expect(result.levels[2].bitrate).to.equal(246440); @@ -204,7 +212,7 @@ http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/ }), }; expect(result.sessionData).to.deep.equal(expected); - expect(result.levels.length, 1); + expect(result.levels.length).to.equal(1); }); it('parses manifest with EXT-X-SESSION-DATA and 10 levels', function () { @@ -242,7 +250,7 @@ http://proxy-21.dailymotion.com/sec(2a991e17f08fcd94f95637a6dd718ddd)/video/107/ }), }; expect(result.sessionData).to.deep.equal(expected); - expect(result.levels.length, 10); + expect(result.levels.length).to.equal(10); }); it('parses manifest with multiple EXT-X-SESSION-DATA', function () { @@ -280,6 +288,8 @@ http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/ const result = M3U8Parser.parseLevelPlaylist( level, 'http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/282/158282701_mp4_h264_aac_hq.m3u8#cell=core', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.fragments).to.have.lengthOf(0); @@ -294,6 +304,8 @@ http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/ const result = M3U8Parser.parseLevelPlaylist( level, 'http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/282/158282701_mp4_h264_aac_hq.m3u8#cell=core', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.fragments).to.have.lengthOf(0); @@ -320,6 +332,8 @@ http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/ const result = M3U8Parser.parseLevelPlaylist( level, 'http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/282/158282701_mp4_h264_aac_hq.m3u8#cell=core', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.totalduration).to.equal(51.24); @@ -361,7 +375,7 @@ http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/ ); const initSegment = result.fragments[0].initSegment; expect(initSegment).to.be.ok; - expect(initSegment.relurl).to.equal('/something.mp4?abc'); + expect(initSegment?.relurl).to.equal('/something.mp4?abc'); }); it('parse level with single char fragment URI', function () { @@ -376,6 +390,8 @@ http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/ const result = M3U8Parser.parseLevelPlaylist( level, 'http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/frag(5)/video/107/282/158282701_mp4_h264_aac_hq.m3u8#cell=core', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.totalduration).to.equal(4); @@ -414,6 +430,8 @@ chop/segment-5.ts const result = M3U8Parser.parseLevelPlaylist( level, 'http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/frag(5)/video/107/282/158282701_mp4_h264_aac_hq.m3u8#cell=core', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.totalduration).to.equal(30); @@ -455,6 +473,8 @@ chop/segment-5.ts const result = M3U8Parser.parseLevelPlaylist( level, 'http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/282/158282701_mp4_h264_aac_hq.m3u8#cell=core', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.totalduration).to.equal(51.24); @@ -482,6 +502,8 @@ oceans_aes-audio=65000-video=236000-3.ts const result = M3U8Parser.parseLevelPlaylist( level, 'http://foo.com/adaptive/oceans_aes/oceans_aes.m3u8', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.totalduration).to.equal(25); @@ -496,7 +518,18 @@ oceans_aes-audio=65000-video=236000-3.ts expect(result.fragments[0].url).to.equal( 'http://foo.com/adaptive/oceans_aes/oceans_aes-audio=65000-video=236000-1.ts' ); - expect(result.fragments[0].decryptdata).to.be.null; + expectWithJSONMessage( + result.fragments[0].levelkeys?.['com.apple.streamingkeydelivery'], + 'levelkeys' + ).to.deep.include({ + uri: 'skd://assetid?keyId=1234', + method: 'AES-128', + keyFormat: 'com.apple.streamingkeydelivery', + keyFormatVersions: [1], + iv: null, + key: null, + keyId: null, + }); }); it('parse AES encrypted URLs, with implicit IV', function () { @@ -517,6 +550,8 @@ oceans_aes-audio=65000-video=236000-3.ts const result = M3U8Parser.parseLevelPlaylist( level, 'http://foo.com/adaptive/oceans_aes/oceans_aes.m3u8', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.totalduration).to.equal(25); @@ -531,17 +566,17 @@ oceans_aes-audio=65000-video=236000-3.ts expect(result.fragments[0].url).to.equal( 'http://foo.com/adaptive/oceans_aes/oceans_aes-audio=65000-video=236000-1.ts' ); - expect(result.fragments[0].decryptdata.uri).to.equal( + expect(result.fragments[0].decryptdata?.uri).to.equal( 'http://foo.com/adaptive/oceans_aes/oceans.key' ); - expect(result.fragments[0].decryptdata.method).to.equal('AES-128'); + expect(result.fragments[0].decryptdata?.method).to.equal('AES-128'); let sn = 1; let uint8View = new Uint8Array(16); for (let i = 12; i < 16; i++) { uint8View[i] = (sn >> (8 * (15 - i))) & 0xff; } - expect(result.fragments[0].decryptdata.iv.buffer).to.deep.equal( + expect(result.fragments[0].decryptdata?.iv?.buffer).to.deep.equal( uint8View.buffer ); @@ -551,7 +586,7 @@ oceans_aes-audio=65000-video=236000-3.ts uint8View[i] = (sn >> (8 * (15 - i))) & 0xff; } - expect(result.fragments[2].decryptdata.iv.buffer).to.deep.equal( + expect(result.fragments[2].decryptdata?.iv?.buffer).to.deep.equal( uint8View.buffer ); }); @@ -596,9 +631,11 @@ lo008ts`; const result = M3U8Parser.parseLevelPlaylist( level, 'http://dummy.com/playlist.m3u8', + 0, + PlaylistLevelType.MAIN, 0 ); - expect(result.fragments.length, 10); + expect(result.fragments.length).to.equal(10); expect(result.fragments[0].url).to.equal('http://dummy.com/lo007ts'); expect(result.fragments[0].byteRangeStartOffset).to.equal(803136); expect(result.fragments[0].byteRangeEndOffset).to.equal(943196); @@ -649,6 +686,8 @@ lo008ts`; const result = M3U8Parser.parseLevelPlaylist( level, 'http://dummy.com/playlist.m3u8', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.fragments).to.have.lengthOf(10); @@ -681,9 +720,11 @@ lo007ts`; const result = M3U8Parser.parseLevelPlaylist( level, 'http://dummy.com/playlist.m3u8', + 0, + PlaylistLevelType.MAIN, 0 ); - expect(result.fragments.length, 3); + expect(result.fragments.length).to.equal(3); expect(result.fragments[0].url).to.equal('http://dummy.com/lo007ts'); expect(result.fragments[0].byteRangeStartOffset).to.equal(803136); expect(result.fragments[0].byteRangeEndOffset).to.equal(943196); @@ -715,6 +756,8 @@ lo007ts`; const result = M3U8Parser.parseLevelPlaylist( level, 'http://video.example.com/disc.m3u8', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.fragments).to.have.lengthOf(5); @@ -746,6 +789,8 @@ lo007ts`; const result = M3U8Parser.parseLevelPlaylist( level, 'http://video.example.com/disc.m3u8', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.fragments).to.have.lengthOf(5); @@ -763,7 +808,7 @@ lo007ts`; 'https://hls.ted.com/', 'AUDIO' ); - expect(result.length, 1); + expect(result.length).to.equal(1); expect(result[0].autoselect).to.be.true; expect(result[0].default).to.be.true; expect(result[0].forced).to.be.false; @@ -801,22 +846,26 @@ lo007ts`; const result = M3U8Parser.parseLevelPlaylist( level, 'http://dummy.com/playlist.m3u8', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.fragments).to.have.lengthOf(8); expect(result.totalduration).to.equal(80); - let fragdecryptdata, - decryptdata = result.fragments[0].decryptdata, - sn = 0; + let fragdecryptdata; + let decryptdata: LevelKey = result.fragments[0].decryptdata as LevelKey; + let sn = 0; result.fragments.forEach(function (fragment, idx) { sn = idx + 1; - expect(fragment.url, 'http://dummy.com/000' + sn + '.ts'); + expect(fragment.url).to.equal('http://dummy.com/000' + sn + '.ts'); // decryptdata should persist across all fragments fragdecryptdata = fragment.decryptdata; + + expect(decryptdata).to.not.equal(null); expect(fragdecryptdata.method).to.equal(decryptdata.method); expect(fragdecryptdata.uri).to.equal(decryptdata.uri); expect(fragdecryptdata.key).to.equal(decryptdata.key); @@ -826,7 +875,7 @@ lo007ts`; expect(iv[15]).to.equal(idx); // hold this decrypt data to compare to the next fragment's decrypt data - decryptdata = fragment.decryptdata; + decryptdata = fragment.decryptdata as LevelKey; }); }); @@ -861,6 +910,8 @@ http://dummy.url.com/hls/live/segment/segment_022916_164500865_719935.ts`; const result = M3U8Parser.parseLevelPlaylist( level, 'http://dummy.url.com/playlist.m3u8', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.fragments).to.have.lengthOf(10); @@ -901,6 +952,8 @@ Rollover38803/20160525T064049-01-69844069.ts const result = M3U8Parser.parseLevelPlaylist( level, 'http://video.example.com/disc.m3u8', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.fragments).to.have.lengthOf(3); @@ -931,6 +984,8 @@ Rollover38803/20160525T064049-01-69844069.ts const result = M3U8Parser.parseLevelPlaylist( level, 'http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/282/158282701_mp4_h264_aac_hq.m3u8#cell=core', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.fragments).to.have.lengthOf(1); @@ -951,15 +1006,17 @@ main.mp4`; const result = M3U8Parser.parseLevelPlaylist( level, 'http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/282/158282701_mp4_h264_aac_hq.m3u8#cell=core', + 0, + PlaylistLevelType.MAIN, 0 ); const initSegment = result.fragments[0].initSegment; - expect(initSegment.url).to.equal( + expect(initSegment?.url).to.equal( 'http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/282/main.mp4' ); - expect(initSegment.byteRangeStartOffset).to.equal(0); - expect(initSegment.byteRangeEndOffset).to.equal(718); - expect(initSegment.sn).to.equal('initSegment'); + expect(initSegment?.byteRangeStartOffset).to.equal(0); + expect(initSegment?.byteRangeEndOffset).to.equal(718); + expect(initSegment?.sn).to.equal('initSegment'); }); it('parses multiple #EXT-X-MAP URI', function () { @@ -980,16 +1037,18 @@ frag2.mp4 const result = M3U8Parser.parseLevelPlaylist( level, 'http://video.example.com/disc.m3u8', + 0, + PlaylistLevelType.MAIN, 0 ); - expect(result.fragments[0].initSegment.url).to.equal( + expect(result.fragments[0].initSegment?.url).to.equal( 'http://video.example.com/main.mp4' ); - expect(result.fragments[0].initSegment.sn).to.equal('initSegment'); - expect(result.fragments[1].initSegment.url).to.equal( + expect(result.fragments[0].initSegment?.sn).to.equal('initSegment'); + expect(result.fragments[1].initSegment?.url).to.equal( 'http://video.example.com/alt.mp4' ); - expect(result.fragments[1].initSegment.sn).to.equal('initSegment'); + expect(result.fragments[1].initSegment?.sn).to.equal('initSegment'); }); describe('PDT calculations', function () { @@ -1011,6 +1070,8 @@ Rollover38803/20160525T064049-01-69844069.ts const result = M3U8Parser.parseLevelPlaylist( level, 'http://video.example.com/disc.m3u8', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.hasProgramDateTime).to.be.true; @@ -1042,6 +1103,8 @@ frag2.ts const result = M3U8Parser.parseLevelPlaylist( level, 'http://video.example.com/disc.m3u8', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.hasProgramDateTime).to.be.true; @@ -1066,6 +1129,8 @@ frag2.ts const result = M3U8Parser.parseLevelPlaylist( level, 'http://video.example.com/disc.m3u8', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.hasProgramDateTime).to.be.true; @@ -1102,6 +1167,8 @@ frag5.ts const result = M3U8Parser.parseLevelPlaylist( level, 'http://video.example.com/disc.m3u8', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.hasProgramDateTime).to.be.true; @@ -1134,10 +1201,11 @@ frag1.ts const result = M3U8Parser.parseLevelPlaylist( level, 'http://video.example.com/disc.m3u8', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.hasProgramDateTime).to.be.true; - expect(result.sn).to.not.equal('initSegment'); expect(result.fragments[0].rawProgramDateTime).to.equal( '2016-05-27T16:35:04Z' ); @@ -1155,6 +1223,8 @@ frag1.ts const result = M3U8Parser.parseLevelPlaylist( level, 'http://video.example.com/disc.m3u8', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.hasProgramDateTime).to.be.false; @@ -1260,65 +1330,66 @@ fileSequence1151226.ts`, // TODO: Partial Segments for a yet to be appended EXT-INF entry will be added to the fragments list // once PartLoader is implemented to abstract away part loading complexity using progressive loader events expect(details.fragments).to.have.lengthOf(8); - expect(details.partList).to.be.an('array').which.has.lengthOf(8); - expect(details.partList[0].fragment).to.equal(details.fragments[6]); - expect(details.partList[1].fragment).to.equal(details.fragments[6]); - expect(details.partList[2].fragment).to.equal(details.fragments[6]); - expect(details.partList[3].fragment).to.equal(details.fragments[6]); - expect(details.partList[4].fragment).to.equal(details.fragments[7]); - expect(details.partList[5].fragment).to.equal(details.fragments[7]); - expect(details.partList[6].fragment).to.equal(details.fragments[7]); - expect(details.partList[7].fragment).to.equal(details.fragments[7]); - expectWithJSONMessage(details.partList[0], '6-0').to.deep.include({ + const partList = details.partList as Part[]; + expect(partList).to.be.an('array').which.has.lengthOf(8); + expect(partList[0].fragment).to.equal(details.fragments[6]); + expect(partList[1].fragment).to.equal(details.fragments[6]); + expect(partList[2].fragment).to.equal(details.fragments[6]); + expect(partList[3].fragment).to.equal(details.fragments[6]); + expect(partList[4].fragment).to.equal(details.fragments[7]); + expect(partList[5].fragment).to.equal(details.fragments[7]); + expect(partList[6].fragment).to.equal(details.fragments[7]); + expect(partList[7].fragment).to.equal(details.fragments[7]); + expectWithJSONMessage(partList[0], '6-0').to.deep.include({ duration: 1, gap: false, independent: true, index: 0, relurl: 'lowLatencyHLS.php?segment=filePart1151232.1.ts', }); - expectWithJSONMessage(details.partList[1], '6-1').to.deep.include({ + expectWithJSONMessage(partList[1], '6-1').to.deep.include({ duration: 1.00001, gap: false, independent: false, index: 1, relurl: 'lowLatencyHLS.php?segment=filePart1151232.2.ts', }); - expectWithJSONMessage(details.partList[2], '6-2').to.deep.include({ + expectWithJSONMessage(partList[2], '6-2').to.deep.include({ duration: 1, gap: false, independent: true, index: 2, relurl: 'lowLatencyHLS.php?segment=filePart1151232.3.ts', }); - expectWithJSONMessage(details.partList[3], '6-3').to.deep.include({ + expectWithJSONMessage(partList[3], '6-3').to.deep.include({ duration: 1, gap: false, independent: true, index: 3, relurl: 'lowLatencyHLS.php?segment=filePart1151232.4.ts', }); - expectWithJSONMessage(details.partList[4], '7-0').to.deep.include({ + expectWithJSONMessage(partList[4], '7-0').to.deep.include({ duration: 1, gap: false, independent: true, index: 0, relurl: 'lowLatencyHLS.php?segment=filePart1151233.1.ts', }); - expectWithJSONMessage(details.partList[5], '7-1').to.deep.include({ + expectWithJSONMessage(partList[5], '7-1').to.deep.include({ duration: 0.99999, gap: false, independent: true, index: 1, relurl: 'lowLatencyHLS.php?segment=filePart1151233.2.ts', }); - expectWithJSONMessage(details.partList[6], '7-2').to.deep.include({ + expectWithJSONMessage(partList[6], '7-2').to.deep.include({ duration: 1, gap: false, independent: false, index: 2, relurl: 'lowLatencyHLS.php?segment=filePart1151233.3.ts', }); - expectWithJSONMessage(details.partList[7], '7-3').to.deep.include({ + expectWithJSONMessage(partList[7], '7-3').to.deep.include({ duration: 1, gap: true, independent: true, @@ -1336,8 +1407,8 @@ fileSequence1151226.ts`, 0 ); expect(details.preloadHint).to.be.an('object'); - expect(details.preloadHint.TYPE).to.equal('PART'); - expect(details.preloadHint.URI).to.equal( + expect(details.preloadHint?.TYPE).to.equal('PART'); + expect(details.preloadHint?.URI).to.equal( 'lowLatencyHLS.php?segment=filePart1151234.1.ts' ); }); @@ -1350,14 +1421,13 @@ fileSequence1151226.ts`, PlaylistLevelType.MAIN, 0 ); - expect(details.renditionReports).to.be.an('array').which.has.lengthOf(2); - expect(details.renditionReports[0].URI).to.equal( - '/media0/lowLatencyHLS.php' - ); - expect(details.renditionReports[0]['LAST-MSN']).to.equal('1151201'); - expect(details.renditionReports[0]['LAST-PART']).to.equal('3'); - expect(details.renditionReports[0]['LAST-I-MSN']).to.equal('1151201'); - expect(details.renditionReports[0]['LAST-I-PART']).to.equal('3'); + const renditionReports = details.renditionReports as AttrList[]; + expect(renditionReports).to.be.an('array').which.has.lengthOf(2); + expect(renditionReports[0].URI).to.equal('/media0/lowLatencyHLS.php'); + expect(renditionReports[0]['LAST-MSN']).to.equal('1151201'); + expect(renditionReports[0]['LAST-PART']).to.equal('3'); + expect(renditionReports[0]['LAST-I-MSN']).to.equal('1151201'); + expect(renditionReports[0]['LAST-I-PART']).to.equal('3'); }); }); @@ -1384,15 +1454,16 @@ fileSequence2.ts PlaylistLevelType.MAIN, 0 ); - expectWithJSONMessage(details.fragments[0].tagList).to.deep.equal([ + const fragments = details.fragments as Fragment[]; + expectWithJSONMessage(fragments[0].tagList).to.deep.equal([ ['INF', '5.97263', '\t'], ['BITRATE', '5083'], ]); - expectWithJSONMessage(details.fragments[1].tagList).to.deep.equal([ + expectWithJSONMessage(fragments[1].tagList).to.deep.equal([ ['INF', '5.97263', '\t'], ['BITRATE', '5453'], ]); - expectWithJSONMessage(details.fragments[2].tagList).to.deep.equal([ + expectWithJSONMessage(fragments[2].tagList).to.deep.equal([ ['INF', '5.97263', '\t'], ['BITRATE', '4802'], ]); @@ -1419,16 +1490,15 @@ fileSequence2.ts PlaylistLevelType.MAIN, 0 ); - expectWithJSONMessage(details.fragments[0].tagList).to.deep.equal([ + const fragments = details.fragments as Fragment[]; + expectWithJSONMessage(fragments[0].tagList).to.deep.equal([ ['INF', '5', 'title'], ]); - expectWithJSONMessage(details.fragments[1].tagList).to.deep.equal([ + expectWithJSONMessage(fragments[1].tagList).to.deep.equal([ ['INF', '5'], ['GAP'], ]); - expectWithJSONMessage(details.fragments[2].tagList).to.deep.equal([ - ['INF', '5'], - ]); + expectWithJSONMessage(fragments[2].tagList).to.deep.equal([['INF', '5']]); }); it('adds unhandled tags (DATERANGE) and comments to fragment.tagList', function () { @@ -1501,6 +1571,8 @@ http://dummy.url.com/hls/live/segment/segment_022916_164500865_719928.ts const result = M3U8Parser.parseLevelPlaylist( level, 'http://dummy.url.com/playlist.m3u8', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.fragments[2].tagList[0][0]).to.equal('EXT-X-CUSTOM-DATE'); @@ -1528,6 +1600,8 @@ http://dummy.url.com/hls/live/segment/segment_022916_164500865_719928.ts const result = M3U8Parser.parseLevelPlaylist( level, 'http://dummy.url.com/playlist.m3u8', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.fragments.length).to.equal(2); @@ -1557,6 +1631,8 @@ http://dummy.url.com/hls/live/segment/segment_022916_164500865_719928.ts const result = M3U8Parser.parseLevelPlaylist( level, 'http://dummy.url.com/playlist.m3u8', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.fragments.length).to.equal(2); @@ -1634,6 +1710,8 @@ media_1638278.m4s`; const result = M3U8Parser.parseLevelPlaylist( level, 'http://foo.com/adaptive/test.m3u8', + 0, + PlaylistLevelType.MAIN, 0 ); expect(result.fragments.length).to.equal(22); @@ -1646,6 +1724,6 @@ media_1638278.m4s`; }); }); -function expectWithJSONMessage(value, msg) { +function expectWithJSONMessage(value: any, msg?: string) { return expect(value, `${msg || 'actual:'} ${JSON.stringify(value, null, 2)}`); }