src/loader/key-loader.ts
import { ErrorTypes, ErrorDetails } from '../errors';
import {
LoaderStats,
LoaderResponse,
LoaderConfiguration,
LoaderCallbacks,
Loader,
KeyLoaderContext,
} from '../types/loader';
import { LoadError } from './fragment-loader';
import type { HlsConfig } from '../hls';
import type { Fragment } from '../loader/fragment';
import type { ComponentAPI } from '../types/component-api';
import type { KeyLoadedData } from '../types/events';
import type { LevelKey } from './level-key';
import type EMEController from '../controller/eme-controller';
import type { MediaKeySessionContext } from '../controller/eme-controller';
import type { KeySystemFormats } from '../utils/mediakeys-helper';
export interface KeyLoaderInfo {
decryptdata: LevelKey;
keyLoadPromise: Promise<KeyLoadedData> | null;
loader: Loader<KeyLoaderContext> | null;
mediaKeySessionContext: MediaKeySessionContext | null;
}
export default class KeyLoader implements ComponentAPI {
private readonly config: HlsConfig;
public keyUriToKeyInfo: { [keyuri: string]: KeyLoaderInfo } = {};
public emeController: EMEController | null = null;
constructor(config: HlsConfig) {
this.config = config;
}
abort() {
for (const uri in this.keyUriToKeyInfo) {
const loader = this.keyUriToKeyInfo[uri].loader;
if (loader) {
loader.abort();
}
}
}
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];
}
}
}
destroy() {
this.detach();
for (const uri in this.keyUriToKeyInfo) {
const loader = this.keyUriToKeyInfo[uri].loader;
if (loader) {
loader.destroy();
}
}
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,
});
}
loadClear(
loadingFrag: Fragment,
encryptedFragments: Fragment[]
): void | Promise<void> {
if (this.emeController && this.config.emeEnabled) {
// access key-system with nearest key on start (loaidng frag is unencrypted)
const { sn, cc } = loadingFrag;
for (let i = 0; i < encryptedFragments.length; i++) {
const frag = encryptedFragments[i];
if (cc <= frag.cc && (sn === 'initSegment' || sn < frag.sn)) {
this.emeController
.selectKeySystemFormat(frag)
.then((keySystemFormat) => {
frag.setKeyFormat(keySystemFormat);
});
break;
}
}
}
}
load(frag: Fragment): Promise<KeyLoadedData> {
if (!frag.decryptdata && frag.encrypted && this.emeController) {
// Multiple keys, but none selected, resolve in eme-controller
return this.emeController
.selectKeySystemFormat(frag)
.then((keySystemFormat) => {
return this.loadInternal(frag, keySystemFormat);
});
}
return this.loadInternal(frag);
}
loadInternal(
frag: Fragment,
keySystemFormat?: KeySystemFormats
): Promise<KeyLoadedData> {
if (keySystemFormat) {
frag.setKeyFormat(keySystemFormat);
}
const decryptdata = frag.decryptdata;
if (!decryptdata) {
const errorMessage = keySystemFormat
? `Expected frag.decryptdata to be defined after setting format ${keySystemFormat}`
: 'Missing decryption data on fragment in onKeyLoading';
return Promise.reject(
this.createKeyLoadError(
frag,
ErrorDetails.KEY_LOAD_ERROR,
null,
errorMessage
)
);
}
const uri = decryptdata.uri;
if (!uri) {
return Promise.reject(
this.createKeyLoadError(
frag,
ErrorDetails.KEY_LOAD_ERROR,
null,
`Invalid key URI: "${uri}"`
)
);
}
let keyInfo = this.keyUriToKeyInfo[uri];
if (keyInfo?.decryptdata.key) {
decryptdata.key = keyInfo.decryptdata.key;
return Promise.resolve({ frag, keyInfo });
}
// Return key load promise as long as it does not have a mediakey session with an unusable key status
if (keyInfo?.keyLoadPromise) {
switch (keyInfo.mediaKeySessionContext?.keyStatus) {
case undefined:
case 'status-pending':
case 'usable':
case 'usable-in-future':
return keyInfo.keyLoadPromise;
}
// If we have a key session and status and it is not pending or usable, continue
// This will go back to the eme-controller for expired keys to get a new keyLoadPromise
}
// Load the key or return the loading promise
keyInfo = this.keyUriToKeyInfo[uri] = {
decryptdata,
keyLoadPromise: null,
loader: null,
mediaKeySessionContext: null,
};
switch (decryptdata.method) {
case 'ISO-23001-7':
case 'SAMPLE-AES':
case 'SAMPLE-AES-CENC':
case 'SAMPLE-AES-CTR':
if (decryptdata.keyFormat === 'identity') {
// loadKeyHTTP handles http(s) and data URLs
return this.loadKeyHTTP(keyInfo, frag);
}
return this.loadKeyEME(keyInfo, frag);
case 'AES-128':
return this.loadKeyHTTP(keyInfo, frag);
default:
return Promise.reject(
this.createKeyLoadError(
frag,
ErrorDetails.KEY_LOAD_ERROR,
null,
`Key supplied with unsupported METHOD: "${decryptdata.method}"`
)
);
}
}
loadKeyEME(keyInfo: KeyLoaderInfo, frag: Fragment): Promise<KeyLoadedData> {
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<KeyLoadedData> {
const config = this.config;
const Loader = config.loader;
const keyLoader = new Loader(config) as Loader<KeyLoaderContext>;
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<KeyLoaderContext> = {
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'
)
);
}
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(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();
}
}
}