Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: assumptions in multi-drm cases where only playready is available for playback. #6946

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
298 changes: 182 additions & 116 deletions src/controller/eme-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
parseMultiPssh,
parseSinf,
type PsshData,
type PsshInvalidResult,

Check warning on line 27 in src/controller/eme-controller.ts

View workflow job for this annotation

GitHub Actions / build

'PsshInvalidResult' is defined but never used
} from '../utils/mp4-tools';
import { base64Decode } from '../utils/numeric-encoding-utils';
import { strToUtf8array } from '../utils/utf8-utils';
Expand Down Expand Up @@ -525,7 +525,7 @@
return this.attemptKeySystemAccess(keySystemsToAttempt);
}

private onMediaEncrypted = (event: MediaEncryptedEvent) => {
private onMediaEncrypted = async (event: MediaEncryptedEvent) => {
Copy link
Collaborator

@robwalch robwalch Jan 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Making this method async for the await at the bottom of the method adds a lot of boiler plate (regenerator runtime) to the UMD build. Would you mind using then/catch (as we do in other parts of the code) rather than await keySessionContextPromise?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than suggest changes without verifying that they work, I put together this draft:

Besides avoiding the use of async/await which adds regenerator-runtime boilerplate to our es5 output, I wanted to avoid the additional looping through found pssh and config, by resolving the selected key-system first. This is closer to how playlist keys are selected. onMediaEncrypted will always run after a playlist key has been selected unless the encrypted event is for a clear segment that signals upcoming encrypted keys ("clear-lead").

const { initDataType, initData } = event;
const logMessage = `"${event.type}" event: init data type: "${initDataType}"`;
this.debug(logMessage);
Expand All @@ -535,146 +535,212 @@
return;
}

let keyId: Uint8Array | null | undefined;
let keySystemDomain: KeySystems | undefined;

if (
initDataType === 'sinf' &&
this.getLicenseServerUrl(KeySystems.FAIRPLAY)
) {
// Match sinf keyId to playlist skd://keyId=
const json = bin2str(new Uint8Array(initData));
try {
const sinf = base64Decode(JSON.parse(json).sinf);
const tenc = parseSinf(sinf);
if (!tenc) {
throw new Error(
`'schm' box missing or not cbcs/cenc with schi > tenc`,
);
type KeyIdInfo = { keyId: Uint8Array; keySystemDomain: KeySystems };

// Ideally this would be a pure function...
// This function yields an array of candidate key id + corresponding key system pairs
// to use for playback based on:
// - the init data type + init data payload (sinf for Fairplay, pssh for PlayReady, Widevine, or both)
// - available DRM server configurations via config
// NOTE: We cannot (yet) presume a single key system, as one of the key systems may still fail
// based on device + browser availability and/or client/server key requests/responses.
const getKeyIdInfos = (): KeyIdInfo[] => {
if (initDataType === 'sinf') {
const fairplayLicenseServerUrl = this.getLicenseServerUrl(
KeySystems.FAIRPLAY,
);
// FairPlay and only FairPlay uses sinf boxes for EME signaling, so
// if we don't have a FairPlay license server URL available, we can't
// use the sinf for EME.
if (!fairplayLicenseServerUrl) return [];
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since this code is now a function that yields 0+ key id + key system domain pairs/duals as candidates to use for playback, we return an empty array for any case where there are no matches based on init data + config


// Match sinf keyId to playlist skd://keyId=
const json = bin2str(new Uint8Array(initData));
try {
const sinf = base64Decode(JSON.parse(json).sinf);
const tenc = parseSinf(sinf);
if (!tenc) {
throw new Error(
`'schm' box missing or not cbcs/cenc with schi > tenc`,
);
}
const keyId = tenc.subarray(8, 24);
const keySystemDomain = KeySystems.FAIRPLAY;
return [{ keyId, keySystemDomain }];
} catch (error) {
this.warn(`${logMessage} Failed to parse sinf: ${error}`);
}
keyId = tenc.subarray(8, 24);
keySystemDomain = KeySystems.FAIRPLAY;
} catch (error) {
this.warn(`${logMessage} Failed to parse sinf: ${error}`);
return;
return [];
}
} else if (this.getLicenseServerUrl(KeySystems.WIDEVINE)) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here's an example where we're tying ourselves explicitly to the availability of a Widevine DRM server for PSSH cases, when it may be widevine, playready, or both.

// Support Widevine clear-lead key-session creation (otherwise depend on playlist keys)
const psshResults = parseMultiPssh(initData);

// TODO: If using keySystemAccessPromises we might want to wait until one is resolved
// We can assume pssh boxes otherwise. Both PlayReady and Widevine use
// pssh, and pssh data may include info for either or both.
const KeySystemLicenseURL = {
[KeySystems.WIDEVINE]: this.getLicenseServerUrl(KeySystems.WIDEVINE),
[KeySystems.PLAYREADY]: this.getLicenseServerUrl(KeySystems.PLAYREADY),
};

if (
!(
KeySystemLicenseURL[KeySystems.WIDEVINE] ||
KeySystemLicenseURL[KeySystems.PLAYREADY]
)
)
return [];

// Shouldn't we not attempt key system access if we don't have a URL for the key system?
let keySystems = Object.keys(
this.keySystemAccessPromises,
) as KeySystems[];

if (!keySystems.length) {
// getKeySystemsForConfig() does not filter out drmSystems that have no URL value
keySystems = getKeySystemsForConfig(this.config);
}

const psshInfo = psshResults.filter((pssh): pssh is PsshData => {
// Only try key systems if we actually have URLs for them via config
keySystems = keySystems.filter(
(keySystem) => !!KeySystemLicenseURL[keySystem],
);

if (!keySystems.length) return [];

// Support Widevine clear-lead key-session creation (otherwise depend on playlist keys)
const psshResults = parseMultiPssh(initData);
const psshInfos = psshResults.filter((pssh): pssh is PsshData => {
// If there's no systemId, assume it's an invalid PSSH (PsshInvalidResult)
if (!pssh.systemId) return false;
// If the parsed PSSHInfo is for a key system that is not available via config,
// filter it out so we don't attempt to use it.
const keySystem = pssh.systemId
? keySystemIdToKeySystemDomain(pssh.systemId)
: null;
return keySystem ? keySystems.indexOf(keySystem) > -1 : false;
})[0];
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NOTE: Here's an example where we're presumptuously assuming that the 0th key system will work in a multi-drm scenario, which may or may not be true.

return !!keySystem && keySystems.indexOf(keySystem) > -1;
});

if (!psshInfo) {
if (
psshResults.length === 0 ||
psshResults.some((pssh): pssh is PsshInvalidResult => !pssh.systemId)
) {
this.warn(`${logMessage} contains incomplete or invalid pssh data`);
} else {
this.log(
`ignoring ${logMessage} for ${(psshResults as PsshData[])
.map((pssh) => keySystemIdToKeySystemDomain(pssh.systemId))
.join(',')} pssh data in favor of playlist keys`,
);
}
return;
if (!psshInfos.length) {
this.warn(`${logMessage} contains incomplete or invalid pssh data`);
return [];
}

keySystemDomain = keySystemIdToKeySystemDomain(psshInfo.systemId);
if (psshInfo.version === 0 && psshInfo.data) {
if (keySystemDomain === KeySystems.WIDEVINE) {
const offset = psshInfo.data.length - 22;
keyId = psshInfo.data.subarray(offset, offset + 16);
} else if (keySystemDomain === KeySystems.PLAYREADY) {
keyId = parsePlayReadyWRM(psshInfo.data);
}
}
}
const keyInfos = psshInfos
.filter((psshInfo) => {
return (
psshInfo.version === 0 &&
!!psshInfo.data &&
!!keySystemIdToKeySystemDomain(psshInfo.systemId)
);
})
.map((psshInfo) => {
const keySystemDomain = keySystemIdToKeySystemDomain(
psshInfo.systemId,
) as KeySystems;
const psshInfoData = psshInfo.data as Uint8Array;
const keyId =
keySystemDomain === KeySystems.PLAYREADY
? (parsePlayReadyWRM(
psshInfoData,
) as Uint8Array) /** @TODO Confirm this is accurate under these conditions */
: psshInfoData.subarray(
psshInfoData.length - 22,
psshInfoData.length - 22 + 16,
);
return { keySystemDomain, keyId };
});

if (!keySystemDomain || !keyId) {
return;
}
return keyInfos;
};

const keyIdHex = Hex.hexDump(keyId);
const { keyIdToKeySessionPromise, mediaKeySessions } = this;
const keyIdInfos: KeyIdInfo[] = getKeyIdInfos();

let keySessionContextPromise = keyIdToKeySessionPromise[keyIdHex];
for (let i = 0; i < mediaKeySessions.length; i++) {
// Match playlist key
const keyContext = mediaKeySessions[i];
const decryptdata = keyContext.decryptdata;
if (!decryptdata.keyId) {
continue;
}
const oldKeyIdHex = Hex.hexDump(decryptdata.keyId);
if (
keyIdHex === oldKeyIdHex ||
decryptdata.uri.replace(/-/g, '').indexOf(keyIdHex) !== -1
) {
keySessionContextPromise = keyIdToKeySessionPromise[oldKeyIdHex];
if (decryptdata.pssh) {
break;
}
delete keyIdToKeySessionPromise[oldKeyIdHex];
decryptdata.pssh = new Uint8Array(initData);
decryptdata.keyId = keyId;
keySessionContextPromise = keyIdToKeySessionPromise[keyIdHex] =
keySessionContextPromise.then(() => {
return this.generateRequestWithPreferredKeySession(
keyContext,
initDataType,
initData,
'encrypted-event-key-match',
);
});
keySessionContextPromise.catch((error) => this.handleError(error));
break;
}
}
/** @TODO errors? */
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NOTE: If we early bail here, this means we didn't find any matches for playback between the intersection of our config + what was signaled from EME in the init data. We plausibly should construct an error here, and potentially earlier in the newly created getKeyIdInfos() function (if we want more context clues in our error as to what specifically failed)

if (!keyIdInfos.length) return;

if (!keySessionContextPromise) {
// Clear-lead key (not encountered in playlist)
keySessionContextPromise = keyIdToKeySessionPromise[keyIdHex] =
this.getKeySystemSelectionPromise([keySystemDomain]).then(
({ keySystem, mediaKeys }) => {
this.throwIfDestroyed();
const decryptdata = new LevelKey(
'ISO-23001-7',
keyIdHex,
keySystemToKeySystemFormat(keySystem) ?? '',
);
decryptdata.pssh = new Uint8Array(initData);
decryptdata.keyId = keyId as Uint8Array;
return this.attemptSetMediaKeys(keySystem, mediaKeys).then(() => {
this.throwIfDestroyed();
const keySessionContext = this.createMediaKeySessionContext({
decryptdata,
keySystem,
mediaKeys,
});
const errors: (EMEKeyError | Error)[] = [];
for (const { keyId, keySystemDomain } of keyIdInfos) {
const keyIdHex = Hex.hexDump(keyId);
const { keyIdToKeySessionPromise, mediaKeySessions } = this;

let keySessionContextPromise = keyIdToKeySessionPromise[keyIdHex];
for (let i = 0; i < mediaKeySessions.length; i++) {
// Match playlist key
const keyContext = mediaKeySessions[i];
const decryptdata = keyContext.decryptdata;
if (!decryptdata.keyId) {
continue;
}
const oldKeyIdHex = Hex.hexDump(decryptdata.keyId);
if (
keyIdHex === oldKeyIdHex ||
decryptdata.uri.replace(/-/g, '').indexOf(keyIdHex) !== -1
) {
keySessionContextPromise = keyIdToKeySessionPromise[oldKeyIdHex];
if (decryptdata.pssh) {
break;
}
delete keyIdToKeySessionPromise[oldKeyIdHex];
decryptdata.pssh = new Uint8Array(initData);
decryptdata.keyId = keyId;
keySessionContextPromise = keyIdToKeySessionPromise[keyIdHex] =
keySessionContextPromise.then(() => {
return this.generateRequestWithPreferredKeySession(
keySessionContext,
keyContext,
initDataType,
initData,
'encrypted-event-no-match',
'encrypted-event-key-match',
);
});
},
);
keySessionContextPromise.catch((error) => this.handleError(error));
break;
}
}

if (!keySessionContextPromise) {
// keySystemDomain = KeySystems.PLAYREADY;
// Clear-lead key (not encountered in playlist)
keySessionContextPromise = keyIdToKeySessionPromise[keyIdHex] =
this.getKeySystemSelectionPromise([keySystemDomain]).then(
({ keySystem, mediaKeys }) => {
this.throwIfDestroyed();
const decryptdata = new LevelKey(
'ISO-23001-7',
keyIdHex,
keySystemToKeySystemFormat(keySystem) ?? '',
);
decryptdata.pssh = new Uint8Array(initData);
decryptdata.keyId = keyId as Uint8Array;
return this.attemptSetMediaKeys(keySystem, mediaKeys).then(() => {
this.throwIfDestroyed();
const keySessionContext = this.createMediaKeySessionContext({
decryptdata,
keySystem,
mediaKeys,
});
return this.generateRequestWithPreferredKeySession(
keySessionContext,
initDataType,
initData,
'encrypted-event-no-match',
);
});
},
);

try {
// Use the first successful key session context promise.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NOTE: In order to strike a balance between non-blocking async behavior and not requesting keys for multiple key systems in parallel, the balance I struck in this implementation is to keep everything for a single key system roughly as it was in the old implementation, but then block on a 👍 / 👎 for that key system. If that key system fails to be usable, we proceed to the next (in cases where that applies).

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you mind using then/catch (as we do in other parts of the code) rather than await keySessionContextPromise?

await keySessionContextPromise;
break;
} catch (error) {
errors.push(error);
}
}
}

// If we have errors for all candidate key infos, this means we failed
// to get any key for playback. In this case, handle these errors
// NOTE: Currently, we will not signal failed key requests if at least
// one succeeds, since this *can* happen and be valid in multi-drm scenarios.
if (errors.length >= keyIdInfos.length) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NOTE: See note. (tl;dr - I didn't see a clean way to signal errors that are actual errors when one but not all key systems fail without a much larger refactor)

errors.forEach((error) => this.handleError(error));
}
};

Expand Down
Loading