diff --git a/api-extractor/report/hls.js.api.md b/api-extractor/report/hls.js.api.md index eb9591fb944..8bd2e95ed2e 100644 --- a/api-extractor/report/hls.js.api.md +++ b/api-extractor/report/hls.js.api.md @@ -1172,6 +1172,8 @@ export class EMEController extends Logger implements ComponentAPI { // (undocumented) destroy(): void; // (undocumented) + getKeyStatus(decryptdata: LevelKey): MediaKeyStatus | undefined; + // (undocumented) getKeySystemAccess(keySystemsToAttempt: KeySystems[]): Promise; // (undocumented) getSelectedKeySystemFormats(): KeySystemFormats[]; @@ -3381,6 +3383,8 @@ export class LevelKey implements DecryptData { // (undocumented) pssh: Uint8Array | null; // (undocumented) + static setKeyIdForUri(uri: string, keyId: Uint8Array): void; + // (undocumented) readonly uri: string; } @@ -3994,7 +3998,11 @@ export interface MediaKeySessionContext { // (undocumented) decryptdata: LevelKey; // (undocumented) - keyStatus: MediaKeyStatus; + keyStatus?: MediaKeyStatus; + // (undocumented) + keyStatusTimeouts?: { + [keyId: string]: number; + }; // (undocumented) keySystem: KeySystems; // (undocumented) diff --git a/src/controller/base-stream-controller.ts b/src/controller/base-stream-controller.ts index a26c14380be..f00b94a1a2a 100644 --- a/src/controller/base-stream-controller.ts +++ b/src/controller/base-stream-controller.ts @@ -24,7 +24,11 @@ import { getAesModeFromFullSegmentMethod, isFullSegmentEncryption, } from '../utils/encryption-methods-util'; -import { getRetryDelay, offlineHttpStatus } from '../utils/error-helper'; +import { + getRetryDelay, + isUnusableKeyError, + offlineHttpStatus, +} from '../utils/error-helper'; import { addEventListener, removeEventListener, @@ -727,7 +731,9 @@ export default class BaseStreamController ) { const media = this.media; const error = new Error( - `Encrypted track with no key in ${this.fragInfo(frag)} (media ${media ? 'attached mediaKeys: ' + media.mediaKeys : 'detached'})`, + __USE_EME_DRM__ + ? `Encrypted track with no key in ${this.fragInfo(frag)} (media ${media ? 'attached mediaKeys: ' + media.mediaKeys : 'detached'})` + : 'EME not supported (light build)', ); this.warn(error.message); // Ignore if media is detached or mediaKeys are set @@ -737,7 +743,7 @@ export default class BaseStreamController this.hls.trigger(Events.ERROR, { type: ErrorTypes.KEY_SYSTEM_ERROR, details: ErrorDetails.KEY_SYSTEM_NO_KEYS, - fatal: false, + fatal: !__USE_EME_DRM__, error, frag, }); @@ -1052,6 +1058,7 @@ export default class BaseStreamController this.handleFragLoadAborted(data.frag, data.part); } else if (data.frag && data.type === ErrorTypes.KEY_SYSTEM_ERROR) { data.frag.abortRequests(); + this.resetStartWhenNotLoaded(); this.resetFragmentLoading(data.frag); } else { this.hls.trigger(Events.ERROR, data as ErrorData); @@ -1904,7 +1911,8 @@ export default class BaseStreamController noAlternate && isMediaFragment(frag) && !frag.endList && - live + live && + !isUnusableKeyError(data) ) { this.resetFragmentErrors(filterType); this.treatAsGap(frag); diff --git a/src/controller/buffer-controller.ts b/src/controller/buffer-controller.ts index d27c56c4d8a..c0eeb8a7995 100755 --- a/src/controller/buffer-controller.ts +++ b/src/controller/buffer-controller.ts @@ -935,6 +935,9 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe this.hls.trigger(Events.ERROR, event); }, }; + this.log( + `queuing "${type}" append sn: ${sn}${part ? ' p: ' + part.index : ''} of ${frag.type === PlaylistLevelType.MAIN ? 'level' : 'track'} ${frag.level} cc: ${cc}`, + ); this.append(operation, type, this.isPending(this.tracks[type])); } diff --git a/src/controller/eme-controller.ts b/src/controller/eme-controller.ts index f6999809bc4..a4ea86f8854 100644 --- a/src/controller/eme-controller.ts +++ b/src/controller/eme-controller.ts @@ -7,6 +7,7 @@ import { EventEmitter } from 'eventemitter3'; import { ErrorDetails, ErrorTypes } from '../errors'; import { Events } from '../events'; import { LevelKey } from '../loader/level-key'; +import { arrayValuesMatch } from '../utils/arrays'; import { addEventListener, removeEventListener, @@ -56,9 +57,10 @@ interface KeySystemAccessPromises { export interface MediaKeySessionContext { keySystem: KeySystems; mediaKeys: MediaKeys; - decryptdata: LevelKey; // FIXME: LevelKey has a URI which should be bound to the session, but is dependent one KeyId specifically. Session context should be allowed to adopt multiple level keys. + decryptdata: LevelKey; mediaKeysSession: MediaKeySession; - keyStatus: MediaKeyStatus; // FIXME: MediaKeySession can manage multiple keys with each with its own status + keyStatus?: MediaKeyStatus; + keyStatusTimeouts?: { [keyId: string]: number }; licenseXhr?: XMLHttpRequest; _onmessage?: (this: MediaKeySession, ev: MediaKeyMessageEvent) => any; _onkeystatuseschange?: (this: MediaKeySession, ev: Event) => any; @@ -322,7 +324,7 @@ class EMEController extends Logger implements ComponentAPI { this.log( `Creating key-system session "${keySystem}" keyId: ${arrayToHex( decryptdata.keyId || ([] as number[]), - )}`, + )} keyUri: ${decryptdata.uri}`, ); const mediaKeysSession = mediaKeys.createSession(); @@ -346,7 +348,7 @@ class EMEController extends Logger implements ComponentAPI { const keySessionContext = this.createMediaKeySessionContext( mediaKeySessionContext, ); - const keyId = this.getKeyIdString(decryptdata); + const keyId = getKeyIdString(decryptdata); const scheme = 'cenc'; this.keyIdToKeySessionPromise[keyId] = this.generateRequestWithPreferredKeySession( @@ -362,16 +364,6 @@ class EMEController extends Logger implements ComponentAPI { this.removeSession(mediaKeySessionContext); } - private getKeyIdString(decryptdata: DecryptData | undefined): string | never { - if (!decryptdata) { - throw new Error('Could not read keyId of undefined decryptdata'); - } - if (decryptdata.keyId === null) { - throw new Error('keyId is null'); - } - return arrayToHex(decryptdata.keyId); - } - private updateKeySession( mediaKeySessionContext: MediaKeySessionContext, data: Uint8Array, @@ -450,13 +442,27 @@ class EMEController extends Logger implements ComponentAPI { return this.selectKeySystem(keySystemsToAttempt); } + public getKeyStatus(decryptdata: LevelKey): MediaKeyStatus | undefined { + const { mediaKeySessions } = this; + for (let i = 0; i < mediaKeySessions.length; i++) { + const status = getKeyStatus(decryptdata, mediaKeySessions[i]); + if (status) { + return status; + } + } + return undefined; + } + public loadKey(data: KeyLoadedData): Promise { const decryptdata = data.keyInfo.decryptdata; - const keyId = this.getKeyIdString(decryptdata); + const keyId = getKeyIdString(decryptdata); const badStatus = this.bannedKeyIds[keyId]; - if (badStatus) { - const error = getKeyStatusError(badStatus, decryptdata); + if (badStatus || this.getKeyStatus(decryptdata) === 'internal-error') { + const error = getKeyStatusError( + badStatus || 'internal-error', + decryptdata, + ); this.handleError(error, data.frag); return Promise.reject(error); } @@ -503,6 +509,18 @@ class EMEController extends Logger implements ComponentAPI { return keySessionContextPromise; } + // Re-emit error for playlist key loading + keyContextPromise.catch((error) => { + if (error instanceof EMEKeyError) { + const errorData = { ...error.data }; + if (this.getKeyStatus(decryptdata) === 'internal-error') { + errorData.decryptdata = decryptdata; + } + const clonedError = new EMEKeyError(errorData, error.message); + this.handleError(clonedError, data.frag); + } + }); + return keyContextPromise; } @@ -516,13 +534,20 @@ class EMEController extends Logger implements ComponentAPI { if (!this.hls as any) { return; } - this.error(error.message); + if (error instanceof EMEKeyError) { if (frag) { error.data.frag = frag; } + const levelKey = error.data.decryptdata; + this.error( + `${error.message}${ + levelKey ? ` (${arrayToHex(levelKey.keyId || [])})` : '' + }`, + ); this.hls.trigger(Events.ERROR, error.data); } else { + this.error(error.message); this.hls.trigger(Events.ERROR, { type: ErrorTypes.KEY_SYSTEM_ERROR, details: ErrorDetails.KEY_SYSTEM_NO_KEYS, @@ -535,7 +560,7 @@ class EMEController extends Logger implements ComponentAPI { private getKeySystemForKeyPromise( decryptdata: LevelKey, ): Promise<{ keySystem: KeySystems; mediaKeys: MediaKeys }> { - const keyId = this.getKeyIdString(decryptdata); + const keyId = getKeyIdString(decryptdata); const mediaKeySessionContext = this.keyIdToKeySessionPromise[keyId]; if (!mediaKeySessionContext) { const keySystem = keySystemFormatToKeySystemDomain( @@ -633,7 +658,7 @@ class EMEController extends Logger implements ComponentAPI { } const oldKeyIdHex = arrayToHex(decryptdata.keyId); if ( - keyIdHex === oldKeyIdHex || + arrayValuesMatch(keyId, decryptdata.keyId) || decryptdata.uri.replace(/-/g, '').indexOf(keyIdHex) !== -1 ) { keySessionContextPromise = keyIdToKeySessionPromise[oldKeyIdHex]; @@ -744,9 +769,10 @@ class EMEController extends Logger implements ComponentAPI { return Promise.resolve(context); } - const keyId = this.getKeyIdString(context.decryptdata); + const keyId = getKeyIdString(context.decryptdata); + const keyUri = context.decryptdata.uri; this.log( - `Generating key-session request for "${reason}": ${keyId} (init data type: ${initDataType} length: ${ + `Generating key-session request for "${reason}" keyId: ${keyId} URI: ${keyUri} (init data type: ${initDataType} length: ${ initData.byteLength })`, ); @@ -776,16 +802,48 @@ class EMEController extends Logger implements ComponentAPI { }); } else if (messageType === 'license-release') { if (context.keySystem === KeySystems.FAIRPLAY) { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.updateKeySession(context, strToUtf8array('acknowledged')); - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.removeSession(context); + this.updateKeySession(context, strToUtf8array('acknowledged')) + .then(() => this.removeSession(context)) + .catch((error) => this.handleError(error)); } } else { this.warn(`unhandled media key message type "${messageType}"`); } }); + const handleKeyStatus = ( + keyStatus: MediaKeyStatus, + context: MediaKeySessionContext, + ) => { + context.keyStatus = keyStatus; + let keyError: EMEKeyError | Error | undefined; + if (keyStatus.startsWith('usable')) { + licenseStatus.emit('resolved'); + } else if ( + keyStatus === 'internal-error' || + keyStatus === 'output-restricted' || + keyStatus === 'output-downscaled' + ) { + keyError = getKeyStatusError(keyStatus, context.decryptdata); + } else if (keyStatus === 'expired') { + keyError = new Error(`key expired (keyId: ${keyId})`); + } else if (keyStatus === 'released') { + keyError = new Error(`key released`); + } else if (keyStatus === 'status-pending') { + /* no-op */ + } else { + this.warn( + `unhandled key status change "${keyStatus}" (keyId: ${keyId})`, + ); + } + if (keyError) { + if (licenseStatus.eventNames().length) { + licenseStatus.emit('error', keyError); + } else { + this.handleError(keyError); + } + } + }; const onkeystatuseschange = (context._onkeystatuseschange = ( event: Event, ) => { @@ -794,15 +852,56 @@ class EMEController extends Logger implements ComponentAPI { licenseStatus.emit('error', new Error('invalid state')); return; } - const initialStatus = context.keyStatus; - this.onKeyStatusChange(context); - const status = context.keyStatus; - if (status !== initialStatus) { - licenseStatus.emit('keyStatus', status, context); - if (status === 'expired') { - this.log(`${context.keySystem} expired for key ${keyId}`); - this.renewKeySession(context); - } + + const keyStatuses = this.getKeyStatuses(context); + const keyIds = Object.keys(keyStatuses); + + // exit if all keys are status-pending + if (!keyIds.some((id) => keyStatuses[id] !== 'status-pending')) { + return; + } + + // renew when a key status for a levelKey comes back expired + if (keyStatuses[keyId] === 'expired') { + // renew when a key status comes back expired + this.log( + `Expired key ${stringify(keyStatuses)} in key-session "${context.mediaKeysSession.sessionId}"`, + ); + this.renewKeySession(context); + return; + } + + let keyStatus = keyStatuses[keyId] as MediaKeyStatus | undefined; + if (keyStatus) { + // handle status of current key + handleKeyStatus(keyStatus, context); + } else { + // Timeout key-status + const timeout = 0; + context.keyStatusTimeouts ||= {}; + context.keyStatusTimeouts[keyId] ||= self.setTimeout(() => { + if ((!context.mediaKeysSession as any) || !this.mediaKeys) { + return; + } + + // Find key status in another session if missing (PlayReady #7519 no key-status "single-key" setup with shared key) + const sessionKeyStatus = this.getKeyStatus(context.decryptdata); + if (sessionKeyStatus && sessionKeyStatus !== 'status-pending') { + this.log( + `No status for keyId ${keyId} in key-session "${context.mediaKeysSession.sessionId}". Using session key-status ${sessionKeyStatus} from other session.`, + ); + return handleKeyStatus(sessionKeyStatus, context); + } + + // Timeout key with internal-error + this.log( + `key status for ${keyId} in key-session "${context.mediaKeysSession.sessionId}" timed out after ${timeout}ms`, + ); + keyStatus = 'internal-error'; + handleKeyStatus(keyStatus, context); + }, timeout); + + this.log(`No status for keyId ${keyId} (${stringify(keyStatuses)}).`); } }); @@ -816,33 +915,7 @@ class EMEController extends Logger implements ComponentAPI { const keyUsablePromise = new Promise( (resolve: (value?: void) => void, reject) => { licenseStatus.on('error', reject); - - licenseStatus.on( - 'keyStatus', - ( - keyStatus: MediaKeyStatus, - { decryptdata }: MediaKeySessionContext, - ) => { - if (keyStatus.startsWith('usable')) { - resolve(); - } else if ( - keyStatus === 'internal-error' || - keyStatus === 'output-restricted' - ) { - reject(getKeyStatusError(keyStatus, decryptdata)); - } else if (keyStatus === 'expired') { - reject( - new Error( - `key expired while generating request (keyId: ${keyId})`, - ), - ); - } else { - this.warn( - `unhandled key status change "${keyStatus}" (keyId: ${keyId})`, - ); - } - }, - ); + licenseStatus.on('resolved', resolve); }, ); @@ -850,7 +923,7 @@ class EMEController extends Logger implements ComponentAPI { .generateRequest(initDataType, initData) .then(() => { this.log( - `Request generated for key-session "${context.mediaKeysSession.sessionId}" keyId: ${keyId}`, + `Request generated for key-session "${context.mediaKeysSession.sessionId}" keyId: ${keyId} URI: ${keyUri}`, ); }) .catch((error) => { @@ -868,9 +941,9 @@ class EMEController extends Logger implements ComponentAPI { .then(() => keyUsablePromise) .catch((error) => { licenseStatus.removeAllListeners(); - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.removeSession(context); - throw error; + return this.removeSession(context).then(() => { + throw error; + }); }) .then(() => { licenseStatus.removeAllListeners(); @@ -878,14 +951,10 @@ class EMEController extends Logger implements ComponentAPI { }); } - private onKeyStatusChange(mediaKeySessionContext: MediaKeySessionContext) { - const sessionLevelKeyId = arrayToHex( - new Uint8Array(mediaKeySessionContext.decryptdata.keyId || []), - ); - - let hasMatchedKey = false; - const keyStatuses: { status: MediaKeyStatus; keyId: string }[] = []; - + private getKeyStatuses(mediaKeySessionContext: MediaKeySessionContext): { + [keyId: string]: MediaKeyStatus; + } { + const keyStatuses: { [keyId: string]: MediaKeyStatus } = {}; mediaKeySessionContext.mediaKeysSession.keyStatuses.forEach( (status: MediaKeyStatus, keyId: BufferSource) => { // keyStatuses.forEach is not standard API so the callback value looks weird on xboxone @@ -895,79 +964,28 @@ class EMEController extends Logger implements ComponentAPI { keyId = status; status = temp; } - - const keyIdArray: Uint8Array = + const keyIdArray = 'buffer' in keyId ? new Uint8Array(keyId.buffer, keyId.byteOffset, keyId.byteLength) : new Uint8Array(keyId); - - // Handle PlayReady little-endian key ID conversion for status comparison only - // Don't modify the original key ID from playlist parsing if ( mediaKeySessionContext.keySystem === KeySystems.PLAYREADY && keyIdArray.length === 16 ) { changeEndianness(keyIdArray); } - - const keyIdWithStatusChange = arrayToHex( - keyIdArray as Uint8Array, - ); - - // Store all key statuses for processing - keyStatuses.push({ status, keyId: keyIdWithStatusChange }); - - // Error immediately when encountering a key ID with this status again + const keyIdWithStatusChange = arrayToHex(keyIdArray); + // Add to banned keys to prevent playlist usage and license requests if (status === 'internal-error') { this.bannedKeyIds[keyIdWithStatusChange] = status; } - - // Check if this key matches the session-level key ID - const matched = keyIdWithStatusChange === sessionLevelKeyId; - if (matched) { - hasMatchedKey = true; - mediaKeySessionContext.keyStatus = status; - this.log( - `matched key status change "${status}" for keyStatuses keyId: ${keyIdWithStatusChange} session keyId: ${sessionLevelKeyId} uri: ${mediaKeySessionContext.decryptdata.uri}`, - ); - } else { - this.log( - `unmatched key status change "${status}" for keyStatuses keyId: ${keyIdWithStatusChange} session keyId: ${sessionLevelKeyId} uri: ${mediaKeySessionContext.decryptdata.uri}`, - ); - } - }, - ); - - // Handle case where no keys matched but all have the same status - // This can happen with PlayReady when key IDs don't align properly - if (!hasMatchedKey && keyStatuses.length > 0) { - const firstStatus = keyStatuses[0].status; - const allSameStatus = !keyStatuses.some( - ({ status }) => status !== firstStatus, - ); - - if ( - allSameStatus && - (firstStatus === 'usable' || firstStatus.startsWith('usable')) - ) { - this.log( - `No key matched session keyId ${sessionLevelKeyId}, but all keys have usable status "${firstStatus}". Treating as usable.`, - ); - mediaKeySessionContext.keyStatus = firstStatus; - } else if ( - allSameStatus && - (firstStatus === 'internal-error' || firstStatus === 'expired') - ) { - this.log( - `No key matched session keyId ${sessionLevelKeyId}, but all keys have error status "${firstStatus}". Applying to session.`, - ); - mediaKeySessionContext.keyStatus = firstStatus; - } else { this.log( - `No key matched session keyId ${sessionLevelKeyId}. Key statuses: ${keyStatuses.map(({ keyId, status }) => `${keyId}:${status}`).join(', ')}`, + `key status change "${status}" for keyStatuses keyId: ${keyIdWithStatusChange} key-session "${mediaKeySessionContext.mediaKeysSession.sessionId}"`, ); - } - } + keyStatuses[keyIdWithStatusChange] = status; + }, + ); + return keyStatuses; } private fetchServerCertificate( @@ -1372,7 +1390,7 @@ class EMEController extends Logger implements ComponentAPI { error: new Error(`Could not clear media keys: ${error}`), }); }, - ), + ) || Promise.resolve(), ), ) .catch((error) => { @@ -1428,7 +1446,7 @@ class EMEController extends Logger implements ComponentAPI { private removeSession( mediaKeySessionContext: MediaKeySessionContext, - ): Promise | void { + ): Promise { const { mediaKeysSession, licenseXhr, decryptdata } = mediaKeySessionContext; if (mediaKeysSession as MediaKeySession | undefined) { @@ -1461,6 +1479,12 @@ class EMEController extends Logger implements ComponentAPI { if (index > -1) { this.mediaKeySessions.splice(index, 1); } + const { keyStatusTimeouts } = mediaKeySessionContext; + if (keyStatusTimeouts) { + Object.keys(keyStatusTimeouts).forEach((keyId) => + self.clearTimeout(keyStatusTimeouts[keyId]), + ); + } const { drmSystemOptions } = this.config; const removePromise = isPersistentSessionType(drmSystemOptions) ? new Promise((resolve, reject) => { @@ -1496,10 +1520,37 @@ class EMEController extends Logger implements ComponentAPI { }); }); } + return Promise.resolve(); + } +} + +function getKeyIdString(decryptdata: DecryptData | undefined): string | never { + if (!decryptdata) { + throw new Error('Could not read keyId of undefined decryptdata'); + } + if (decryptdata.keyId === null) { + throw new Error('keyId is null'); + } + return arrayToHex(decryptdata.keyId); +} + +function getKeyStatus( + decryptdata: LevelKey, + keyContext: MediaKeySessionContext, +): MediaKeyStatus | undefined { + if ( + decryptdata.keyId && + keyContext.mediaKeysSession.keyStatuses.has(decryptdata.keyId) + ) { + return keyContext.mediaKeysSession.keyStatuses.get(decryptdata.keyId); + } + if (decryptdata.matches(keyContext.decryptdata)) { + return keyContext.keyStatus; } + return undefined; } -class EMEKeyError extends Error { +export class EMEKeyError extends Error { public readonly data: ErrorData; constructor( data: Omit & { error?: Error }, diff --git a/src/controller/error-controller.ts b/src/controller/error-controller.ts index 171a3fe1ad6..2650db155eb 100644 --- a/src/controller/error-controller.ts +++ b/src/controller/error-controller.ts @@ -6,7 +6,9 @@ import { PlaylistContextType, PlaylistLevelType } from '../types/loader'; import { getCodecsForMimeType } from '../utils/codecs'; import { getRetryConfig, + isKeyError, isTimeoutError, + isUnusableKeyError, shouldRetry, } from '../utils/error-helper'; import { arrayToHex } from '../utils/hex'; @@ -302,7 +304,7 @@ export default class ErrorController const level = hls.levels[variantLevelIndex]; const { fragLoadPolicy, keyLoadPolicy } = hls.config; const retryConfig = getRetryConfig( - data.details.startsWith('key') ? keyLoadPolicy : fragLoadPolicy, + isKeyError(data) ? keyLoadPolicy : fragLoadPolicy, data, ); const fragmentErrors = hls.levels.reduce( @@ -314,19 +316,21 @@ export default class ErrorController if (data.details !== ErrorDetails.FRAG_GAP) { level.fragmentError++; } - const retry = shouldRetry( - retryConfig, - fragmentErrors, - isTimeoutError(data), - data.response, - ); - if (retry) { - return { - action: NetworkErrorAction.RetryRequest, - flags: ErrorActionFlags.None, + if (!isUnusableKeyError(data)) { + const retry = shouldRetry( retryConfig, - retryCount: fragmentErrors, - }; + fragmentErrors, + isTimeoutError(data), + data.response, + ); + if (retry) { + return { + action: NetworkErrorAction.RetryRequest, + flags: ErrorActionFlags.None, + retryConfig, + retryCount: fragmentErrors, + }; + } } } // Reach max retry count, or Missing level reference @@ -509,7 +513,9 @@ export default class ErrorController 'HDCP-LEVEL' ]; errorAction.hdcpLevel = restrictedHdcpLevel; - if (restrictedHdcpLevel) { + if (restrictedHdcpLevel === 'NONE') { + this.warn(`HDCP policy resticted output with HDCP-LEVEL=NONE`); + } else if (restrictedHdcpLevel) { hls.maxHdcpLevel = HdcpLevels[HdcpLevels.indexOf(restrictedHdcpLevel) - 1]; errorAction.resolved = true; @@ -526,7 +532,8 @@ export default class ErrorController if (levelKey) { // Penalize all levels with key const levels = this.hls.levels; - for (let i = levels.length; i--; ) { + const levelCountWithError = levels.length; + for (let i = levelCountWithError; i--; ) { if (this.variantHasKey(levels[i], levelKey)) { this.log( `Banned key found in level ${i} (${levels[i].bitrate}bps) or audio group "${levels[i].audioGroups?.join(',')}" (${data.frag?.type} fragment) ${arrayToHex(levelKey.keyId || [])}`, @@ -537,8 +544,15 @@ export default class ErrorController this.hls.removeLevel(i); } } - if (levels.length) { + const frag = data.frag; + if (this.hls.levels.length < levelCountWithError) { errorAction.resolved = true; + } else if (frag && frag.type !== PlaylistLevelType.MAIN) { + // Ignore key error for audio track with unmatched key (main session error) + const fragLevelKey = frag.decryptdata; + if (fragLevelKey && !levelKey.matches(fragLevelKey)) { + errorAction.resolved = true; + } } } break; diff --git a/src/loader/key-loader.ts b/src/loader/key-loader.ts index c2be92e08e0..9d5b6556c1c 100644 --- a/src/loader/key-loader.ts +++ b/src/loader/key-loader.ts @@ -7,10 +7,14 @@ import { getKeySystemsForConfig, keySystemFormatToKeySystemDomain, } from '../utils/mediakeys-helper'; +import { KeySystemFormats } from '../utils/mediakeys-helper'; import type { LevelKey } from './level-key'; import type { HlsConfig } from '../config'; import type EMEController from '../controller/eme-controller'; -import type { MediaKeySessionContext } from '../controller/eme-controller'; +import type { + EMEKeyError, + MediaKeySessionContext, +} from '../controller/eme-controller'; import type { ComponentAPI } from '../types/component-api'; import type { KeyLoadedData } from '../types/events'; import type { @@ -24,7 +28,6 @@ import type { } from '../types/loader'; import type { NullableNetworkDetails } from '../types/network-details'; import type { ILogger } from '../utils/logger'; -import type { KeySystemFormats } from '../utils/mediakeys-helper'; export interface KeyLoaderInfo { decryptdata: LevelKey; @@ -102,6 +105,7 @@ export default class KeyLoader extends Logger implements ComponentAPI { startFragRequested: boolean, ): null | Promise { if ( + __USE_EME_DRM__ && this.emeController && this.config.emeEnabled && !this.emeController.getSelectedKeySystemFormats().length @@ -166,7 +170,7 @@ export default class KeyLoader extends Logger implements ComponentAPI { frag: Fragment, keySystemFormat?: KeySystemFormats, ): Promise { - if (keySystemFormat) { + if (__USE_EME_DRM__ && keySystemFormat) { frag.setKeyFormat(keySystemFormat); } const decryptdata = frag.decryptdata; @@ -174,7 +178,7 @@ export default class KeyLoader extends Logger implements ComponentAPI { const error = new Error( keySystemFormat ? `Expected frag.decryptdata to be defined after setting format ${keySystemFormat}` - : 'Missing decryption data on fragment in onKeyLoading', + : `Missing decryption data on fragment in onKeyLoading (emeEnabled with controller: ${this.emeController && this.config.emeEnabled})`, ); return Promise.reject( this.createKeyLoadError(frag, ErrorDetails.KEY_LOAD_ERROR, error), @@ -198,8 +202,8 @@ export default class KeyLoader extends Logger implements ComponentAPI { return Promise.resolve({ frag, keyInfo }); } // Return key load promise once it has a mediakey session with an usable key status - if (keyInfo?.keyLoadPromise) { - const keyStatus = keyInfo.mediaKeySessionContext?.keyStatus; + if (this.emeController && keyInfo?.keyLoadPromise) { + const keyStatus = this.emeController.getKeyStatus(keyInfo.decryptdata); switch (keyStatus) { case 'usable': case 'usable-in-future': @@ -216,7 +220,7 @@ export default class KeyLoader extends Logger implements ComponentAPI { // Load the key or return the loading promise this.log( - `Loading key ${arrayToHex(decryptdata.keyId || [])} from ${frag.type} ${frag.level}`, + `${this.keyIdToKeyInfo[id] ? 'Rel' : 'L'}oading${decryptdata.keyId ? ' keyId: ' + arrayToHex(decryptdata.keyId) : ''} URI: ${decryptdata.uri} from ${frag.type} ${frag.level}`, ); keyInfo = this.keyIdToKeyInfo[id] = { @@ -262,10 +266,10 @@ export default class KeyLoader extends Logger implements ComponentAPI { keyInfo.mediaKeySessionContext = keySessionContext; return keyLoadedData; }, - )).catch((error) => { + )).catch((error: EMEKeyError | Error) => { // Remove promise for license renewal or retry keyInfo.keyLoadPromise = null; - if (error.data) { + if ('data' in error) { error.data.frag = frag; } throw error; @@ -307,8 +311,8 @@ export default class KeyLoader extends Logger implements ComponentAPI { context: KeyLoaderContext, networkDetails: NullableNetworkDetails, ) => { - const { frag, keyInfo, url: uri } = context; - const id = getKeyId(keyInfo.decryptdata) || uri; + const { frag, keyInfo } = context; + const id = getKeyId(keyInfo.decryptdata); if (!frag.decryptdata || keyInfo !== this.keyIdToKeyInfo[id]) { return reject( this.createKeyLoadError( @@ -403,9 +407,11 @@ export default class KeyLoader extends Logger implements ComponentAPI { } function getKeyId(decryptdata: LevelKey) { - const keyId = decryptdata.keyId; - if (keyId) { - return arrayToHex(keyId); + if (__USE_EME_DRM__ && decryptdata.keyFormat !== KeySystemFormats.FAIRPLAY) { + const keyId = decryptdata.keyId; + if (keyId) { + return arrayToHex(keyId); + } } return decryptdata.uri; } diff --git a/src/loader/level-key.ts b/src/loader/level-key.ts index aaccbcd4dfe..2c6a8d076cb 100644 --- a/src/loader/level-key.ts +++ b/src/loader/level-key.ts @@ -37,6 +37,10 @@ export class LevelKey implements DecryptData { keyUriToKeyIdMap = {}; } + static setKeyIdForUri(uri: string, keyId: Uint8Array) { + keyUriToKeyIdMap[uri] = keyId; + } + constructor( method: string, uri: string, @@ -101,19 +105,22 @@ export class LevelKey implements DecryptData { return null; } - if (isFullSegmentEncryption(this.method) && 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/256 - // It must have an IV defined. We cannot substitute the Segment Number in. - 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; + if (isFullSegmentEncryption(this.method)) { + let iv = this.iv; + if (!iv) { + if (typeof sn !== 'number') { + // We are fetching decryption data for a initialization segment + // If the segment was encrypted with AES-128/256 + // It must have an IV defined. We cannot substitute the Segment Number in. + 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; + } + iv = createInitializationVector(sn); } - const iv = createInitializationVector(sn); const decryptdata = new LevelKey( this.method, this.uri, @@ -142,11 +149,11 @@ export class LevelKey implements DecryptData { this.pssh = keyBytes; // In case of Widevine, if KEYID is not in the playlist, assume only two fields in the pssh KEY tag URI. if (!this.keyId) { - const [psshData] = parseMultiPssh(keyBytes.buffer); - this.keyId = - psshData && 'kids' in psshData && psshData.kids?.[0] - ? psshData.kids[0] - : null; + const results = parseMultiPssh(keyBytes.buffer); + if (results.length) { + const psshData = results[0]; + this.keyId = psshData.kids?.length ? psshData.kids[0] : null; + } } if (!this.keyId) { const offset = keyBytes.length - 22; @@ -189,7 +196,7 @@ export class LevelKey implements DecryptData { 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; + LevelKey.setKeyIdForUri(this.uri, keyId); } this.keyId = keyId; } diff --git a/src/loader/m3u8-parser.ts b/src/loader/m3u8-parser.ts index a7765fdfc76..66c73d2744d 100644 --- a/src/loader/m3u8-parser.ts +++ b/src/loader/m3u8-parser.ts @@ -584,7 +584,9 @@ export default class M3U8Parser { levelkeys[levelKey.keyFormat] = levelKey; } } else { - logger.warn(`[Keys] Ignoring invalid EXT-X-KEY tag: "${value1}"`); + logger.warn( + `[Keys] Ignoring unsupported EXT-X-KEY tag: "${value1}"${__USE_EME_DRM__ ? '' : ' (light build)'}`, + ); } break; } diff --git a/src/utils/error-helper.ts b/src/utils/error-helper.ts index f6376e635f5..1925cae54ce 100644 --- a/src/utils/error-helper.ts +++ b/src/utils/error-helper.ts @@ -17,6 +17,14 @@ export function isTimeoutError(error: ErrorData): boolean { return false; } +export function isKeyError(error: ErrorData): boolean { + return error.details.startsWith('key'); +} + +export function isUnusableKeyError(error: ErrorData): boolean { + return isKeyError(error) && !!error.frag && !error.frag.decryptdata; +} + export function getRetryConfig( loadPolicy: LoadPolicy, error: ErrorData, diff --git a/src/utils/mp4-tools.ts b/src/utils/mp4-tools.ts index 86d536f7047..be093141986 100644 --- a/src/utils/mp4-tools.ts +++ b/src/utils/mp4-tools.ts @@ -1361,6 +1361,7 @@ export type PsshData = { export type PsshInvalidResult = { systemId?: undefined; + kids?: undefined; offset: number; size: number; }; diff --git a/tests/unit/controller/eme-controller.ts b/tests/unit/controller/eme-controller.ts index 52aa6688a7f..2acf1c2db14 100644 --- a/tests/unit/controller/eme-controller.ts +++ b/tests/unit/controller/eme-controller.ts @@ -5,7 +5,11 @@ import sinonChai from 'sinon-chai'; import EMEController from '../../../src/controller/eme-controller'; import { ErrorDetails } from '../../../src/errors'; import { Events } from '../../../src/events'; -import { KeySystemFormats } from '../../../src/utils/mediakeys-helper'; +import { LevelKey } from '../../../src/loader/level-key'; +import { + KeySystemFormats, + KeySystems, +} from '../../../src/utils/mediakeys-helper'; import HlsMock from '../../mocks/hls.mock'; import type { MediaKeySessionContext } from '../../../src/controller/eme-controller'; import type { MediaAttachedData } from '../../../src/types/events'; @@ -15,20 +19,22 @@ const expect = chai.expect; type EMEControllerTestable = Omit< EMEController, - 'hls' | 'keyIdToKeySessionPromise' | 'mediaKeySessions' + 'hls' | 'keyUriToSessionPromise' | 'mediaKeySessions' | 'keyUriToLevelKeys' > & { hls: HlsMock; + mediaKeySessions: MediaKeySessionContext[]; keyIdToKeySessionPromise: { - [keyId: string]: Promise; + [keyId: string]: Promise | undefined; }; - mediaKeySessions: MediaKeySessionContext[]; onMediaAttached: ( event: Events.MEDIA_ATTACHED, data: MediaAttachedData, ) => void; onMediaDetached: () => void; media: HTMLMediaElement | null; - onKeyStatusChange: (mediaKeySessionContext: MediaKeySessionContext) => void; + getKeyStatuses: (mediaKeySessionContext: MediaKeySessionContext) => { + [keyId: string]: MediaKeyStatus; + }; }; class MediaMock extends EventEmitter { @@ -45,15 +51,64 @@ class MediaMock extends EventEmitter { } } -class MediaKeySessionMock extends EventEmitter { +class MediaKeysMock implements MediaKeys { + createSession(/* sessionType?: MediaKeySessionType */) { + return new MediaKeySessionMock(); + } + getStatusForPolicy(/* policy?: MediaKeysPolicy */) { + // "output-restricted" | "released" | "status-pending" | "usable" | "usable-in-future" + return Promise.resolve('usable' as MediaKeyStatus); + } + setServerCertificate(/* serverCertificate: BufferSource */) { + return Promise.resolve(true); + } +} + +class MediaKeySessionMock extends EventEmitter implements MediaKeySession { addEventListener: any; removeEventListener: any; - keyStatuses: Map; + private _resolveClose: (reason: MediaKeySessionClosedReason) => void = + () => {}; + protected _keyStatuses: Map; + readonly closed: Promise; + readonly keyStatuses: MediaKeyStatusMap; + readonly expiration: number; + readonly onkeystatuseschange = null; // use add/removeEventListener + readonly onmessage = null; // use add/removeEventListener + readonly sessionId: string; + constructor() { super(); - this.keyStatuses = new Map(); this.addEventListener = this.addListener.bind(this); this.removeEventListener = this.removeListener.bind(this); + this.closed = new Promise((resolve) => { + this._resolveClose = resolve; + }); + this.expiration = NaN; + this.sessionId = ''; + + const keyStatuses = (this._keyStatuses = new Map()); + this.keyStatuses = { + get size() { + return keyStatuses.size; + }, + get(keyId) { + return keyStatuses.get(keyId); + }, + has(keyId) { + return keyStatuses.has(keyId); + }, + forEach(callbackfn, thisArg?) { + return keyStatuses.forEach(callbackfn, thisArg); + }, + }; + } + dispatchEvent() { + return true; + } + close() { + this._resolveClose('release-acknoledged' as MediaKeySessionClosedReason); + return this.closed.then(() => {}); } generateRequest() { return Promise.resolve().then(() => { @@ -61,10 +116,13 @@ class MediaKeySessionMock extends EventEmitter { messageType: 'license-request', message: new Uint8Array(0), }); - this.keyStatuses.set(new Uint8Array(16), 'usable'); + this._keyStatuses.set(new Uint8Array(16), 'usable'); this.emit('keystatuseschange', {}); }); } + load(sessionId: string) { + return Promise.reject(new Error('not supported')); + } remove() { return Promise.resolve(); } @@ -94,6 +152,16 @@ const setupEach = function (config) { sinonFakeXMLHttpRequestStatic = sinon.useFakeXMLHttpRequest(); }; +const getParsedLevelKey = ( + uri: string = 'data://key-uri', + format: string = 'com.apple.streamingkeydelivery', +) => { + const levelKey = new LevelKey('SAMPLE-AES', uri, format); + levelKey.keyId = new Uint8Array(16); + levelKey.pssh = new Uint8Array(16); + return levelKey; +}; + describe('EMEController', function () { beforeEach(function () { setupEach({}); @@ -104,16 +172,12 @@ describe('EMEController', function () { }); it('should request keysystem access based on key format when `emeEnabled` is true', function () { + const mediaKeys = new MediaKeysMock(); const reqMediaKsAccessSpy = sinon.spy(function () { return Promise.resolve({ // Media-keys mock keySystem: 'com.apple.fps', - createMediaKeys: sinon.spy(() => - Promise.resolve({ - setServerCertificate: () => Promise.resolve(), - createSession: () => new MediaKeySessionMock(), - }), - ), + createMediaKeys: sinon.spy(() => Promise.resolve(mediaKeys)), }); }); @@ -143,19 +207,16 @@ describe('EMEController', function () { expect(media.setMediaKeys).callCount(0); expect(reqMediaKsAccessSpy).callCount(0); + const levelKey = getParsedLevelKey(); const emePromise = emeController.loadKey({ - frag: {}, + frag: {} as any, keyInfo: { - decryptdata: { - encrypted: true, - method: 'SAMPLE-AES', - keyFormat: 'com.apple.streamingkeydelivery', - uri: 'data://key-uri', - keyId: new Uint8Array(16), - pssh: new Uint8Array(16), - }, + decryptdata: levelKey, + keyLoadPromise: null, + loader: null, + mediaKeySessionContext: null, }, - } as any); + }); expect(emePromise).to.be.a('Promise'); return emePromise.finally(() => { @@ -317,7 +378,7 @@ describe('EMEController', function () { class MediaKeySessionMock2 extends MediaKeySessionMock { constructor() { super(); - this.keyStatuses.set(new Uint8Array(16), 'usable'); + this._keyStatuses.set(new Uint8Array(16), 'usable'); } } @@ -331,24 +392,21 @@ describe('EMEController', function () { }, }); + const levelKey = getParsedLevelKey(); const keySession = new MediaKeySessionMock2(); - const mockMediaKeySessionContext = { + const mockMediaKeySessionContext: MediaKeySessionContext = { + decryptdata: levelKey, + keySystem: KeySystems.FAIRPLAY, + mediaKeys: new MediaKeysMock(), mediaKeysSession: keySession, - decryptdata: { - encrypted: true, - method: 'SAMPLE-AES', - keyFormat: 'com.apple.streamingkeydelivery', - uri: 'data://key-uri', - keyId: new Uint8Array(16), - pssh: new Uint8Array(16), - }, - keyStatus: 'status-pending', }; - emeController.onKeyStatusChange( - mockMediaKeySessionContext as unknown as MediaKeySessionContext, + const keyStatuses = emeController.getKeyStatuses( + mockMediaKeySessionContext, ); - expect(mockMediaKeySessionContext.keyStatus).to.be.equal('usable'); + expect(keyStatuses) + .to.have.property('00000000000000000000000000000000') + .which.equals('usable'); }); it('should fetch the server certificate and set it into the session', function () { @@ -411,7 +469,7 @@ describe('EMEController', function () { ).to.be.a('Promise'); return emeController.keyIdToKeySessionPromise[ '00000000000000000000000000000000' - ].finally(() => { + ]!.finally(() => { expect(mediaKeysSetServerCertificateSpy).to.have.been.calledOnce; expect(mediaKeysSetServerCertificateSpy).to.have.been.calledWith( sinon.match({ byteLength: 6 }), @@ -492,19 +550,17 @@ describe('EMEController', function () { ).to.be.a('Promise'); return emeController.keyIdToKeySessionPromise[ '00000000000000000000000000000000' - ] - .catch(() => {}) - .finally(() => { - expect(mediaKeysSetServerCertificateSpy).to.have.been.calledOnce; - expect((mediaKeysSetServerCertificateSpy.args[0] as any)[0]).to.equal( - xhrInstance.response, - ); + ]!.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, - ); - }); + 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 () { @@ -572,14 +628,12 @@ describe('EMEController', function () { ).to.be.a('Promise'); return emeController.keyIdToKeySessionPromise[ '00000000000000000000000000000000' - ] - .catch(() => {}) - .finally(() => { - expect(emeController.hls.trigger).to.have.been.calledOnce; - expect(emeController.hls.trigger.args[0][1].details).to.equal( - ErrorDetails.KEY_SYSTEM_SERVER_CERTIFICATE_REQUEST_FAILED, - ); - }); + ]!.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 remove media property when media is detached', function () { @@ -602,8 +656,6 @@ describe('EMEController', function () { ), }); }); - const keySessionRemoveSpy = sinon.spy(() => Promise.resolve()); - const keySessionCloseSpy = sinon.spy(() => Promise.resolve()); setupEach({ emeEnabled: true, @@ -613,14 +665,19 @@ describe('EMEController', function () { emeController.onMediaAttached(Events.MEDIA_ATTACHED, { media: media as any as HTMLMediaElement, }); - emeController.mediaKeySessions = [ - { - mediaKeysSession: { - remove: keySessionRemoveSpy, - close: keySessionCloseSpy, - }, - } as any, - ]; + + const levelKey = getParsedLevelKey(); + const keySession = new MediaKeySessionMock(); + const mockMediaKeySessionContext: MediaKeySessionContext = { + decryptdata: levelKey, + keySystem: KeySystems.FAIRPLAY, + mediaKeys: new MediaKeysMock(), + mediaKeysSession: keySession, + }; + sinon.stub(keySession, 'remove'); + sinon.stub(keySession, 'close'); + + emeController.mediaKeySessions = [mockMediaKeySessionContext]; emeController.destroy(); expect(emeController.media).to.equal(null); @@ -646,8 +703,6 @@ describe('EMEController', function () { ), }); }); - const keySessionRemoveSpy = sinon.spy(() => Promise.resolve()); - const keySessionCloseSpy = sinon.spy(() => Promise.resolve()); setupEach({ emeEnabled: true, @@ -657,14 +712,19 @@ describe('EMEController', function () { emeController.onMediaAttached(Events.MEDIA_ATTACHED, { media: media as any as HTMLMediaElement, }); - emeController.mediaKeySessions = [ - { - mediaKeysSession: { - remove: keySessionRemoveSpy, - close: keySessionCloseSpy, - }, - } as any, - ]; + + const levelKey = getParsedLevelKey(); + const keySession = new MediaKeySessionMock(); + const mockMediaKeySessionContext: MediaKeySessionContext = { + decryptdata: levelKey, + keySystem: KeySystems.FAIRPLAY, + mediaKeys: new MediaKeysMock(), + mediaKeysSession: keySession, + }; + sinon.stub(keySession, 'remove'); + const keySessionCloseSpy = sinon.stub(keySession, 'close'); + + emeController.mediaKeySessions = [mockMediaKeySessionContext]; emeController.destroy(); expect(EMEController.CDMCleanupPromise).to.be.a('Promise'); @@ -698,8 +758,6 @@ describe('EMEController', function () { ), }); }); - const keySessionRemoveSpy = sinon.spy(() => Promise.resolve()); - const keySessionCloseSpy = sinon.spy(() => Promise.resolve()); setupEach({ emeEnabled: true, @@ -712,14 +770,19 @@ describe('EMEController', function () { emeController.onMediaAttached(Events.MEDIA_ATTACHED, { media: media as any as HTMLMediaElement, }); - emeController.mediaKeySessions = [ - { - mediaKeysSession: { - remove: keySessionRemoveSpy, - close: keySessionCloseSpy, - }, - } as any, - ]; + + const levelKey = getParsedLevelKey(); + const keySession = new MediaKeySessionMock(); + const mockMediaKeySessionContext: MediaKeySessionContext = { + decryptdata: levelKey, + keySystem: KeySystems.FAIRPLAY, + mediaKeys: new MediaKeysMock(), + mediaKeysSession: keySession, + }; + sinon.stub(keySession, 'remove'); + const keySessionCloseSpy = sinon.stub(keySession, 'close'); + + emeController.mediaKeySessions = [mockMediaKeySessionContext]; emeController.destroy(); expect(EMEController.CDMCleanupPromise).to.be.a('Promise');