-
Notifications
You must be signed in to change notification settings - Fork 2.6k
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
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -24,7 +24,7 @@ | |
parseMultiPssh, | ||
parseSinf, | ||
type PsshData, | ||
type PsshInvalidResult, | ||
} from '../utils/mp4-tools'; | ||
import { base64Decode } from '../utils/numeric-encoding-utils'; | ||
import { strToUtf8array } from '../utils/utf8-utils'; | ||
|
@@ -525,7 +525,7 @@ | |
return this.attemptKeySystemAccess(keySystemsToAttempt); | ||
} | ||
|
||
private onMediaEncrypted = (event: MediaEncryptedEvent) => { | ||
private onMediaEncrypted = async (event: MediaEncryptedEvent) => { | ||
const { initDataType, initData } = event; | ||
const logMessage = `"${event.type}" event: init data type: "${initDataType}"`; | ||
this.debug(logMessage); | ||
|
@@ -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 []; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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]; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. NOTE: Here's an example where we're presumptuously assuming that the |
||
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? */ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
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. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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). There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)); | ||
} | ||
}; | ||
|
||
|
There was a problem hiding this comment.
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
?There was a problem hiding this comment.
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").