Skip to content

Commit 673b7ff

Browse files
authored
fix: Properly setup DRM to support Airplay (muxinc#1245)
Closes [muxinc#183](muxinc/devextravaganza#183) I also reorganized the `onFpEncrypted` callback to get a better understanding of the code and compare it with the [FairPlay Streaming Overview](https://developer.apple.com/streaming/fps/FairPlayStreamingOverview.pdf). The main issue here was that we were ignoring the Apple TV SPC when generating the DRM license. This is reflected on the `session.addEventListener('message', ...)` changes. We were listening just once to this message, but more recent versions of Apple TV send new messages when using AirPlay. Therefore I modified that listener to ask for that license again and update the session for each new message.
1 parent a43edd5 commit 673b7ff

File tree

1 file changed

+154
-128
lines changed

1 file changed

+154
-128
lines changed

packages/playback-core/src/index.ts

Lines changed: 154 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -882,158 +882,184 @@ export const setupNativeFairplayDRM = (
882882
props: Partial<Pick<MuxMediaPropsInternal, 'playbackId' | 'tokens' | 'playbackToken' | 'customDomain' | 'drmTypeCb'>>,
883883
mediaEl: HTMLMediaElement
884884
) => {
885-
const onFpEncrypted = async (event: MediaEncryptedEvent) => {
886-
try {
887-
const initDataType = event.initDataType;
888-
if (initDataType !== 'skd') {
889-
console.error(`Received unexpected initialization data type "${initDataType}"`);
890-
return;
891-
}
892-
893-
if (!mediaEl.mediaKeys) {
894-
const access = await navigator
895-
.requestMediaKeySystemAccess('com.apple.fps', [
896-
{
897-
initDataTypes: [initDataType],
898-
videoCapabilities: [{ contentType: 'application/vnd.apple.mpegurl', robustness: '' }],
899-
distinctiveIdentifier: 'not-allowed',
900-
persistentState: 'not-allowed',
901-
sessionTypes: ['temporary'],
902-
},
903-
])
904-
.then((value) => {
905-
props.drmTypeCb?.(DRMType.FAIRPLAY);
906-
return value;
907-
})
908-
.catch(() => {
909-
const message = i18n(
910-
'Cannot play DRM-protected content with current security configuration on this browser. Try playing in another browser.'
911-
);
912-
// Should we flag this as a business exception?
913-
const mediaError = new MediaError(message, MediaError.MEDIA_ERR_ENCRYPTED, true);
914-
mediaError.errorCategory = MuxErrorCategory.DRM;
915-
mediaError.muxCode = MuxErrorCode.ENCRYPTED_UNSUPPORTED_KEY_SYSTEM;
916-
saveAndDispatchError(mediaEl, mediaError);
917-
});
918-
919-
if (!access) return;
920-
921-
const keys = await access.createMediaKeys();
922-
923-
try {
924-
const fairPlayAppCert = await getAppCertificate(toAppCertURL(props, 'fairplay')).catch((errOrResp) => {
925-
if (errOrResp instanceof Response) {
926-
const mediaError = getErrorFromResponse(errOrResp, MuxErrorCategory.DRM, props);
927-
console.error('mediaError', mediaError?.message, mediaError?.context);
928-
if (mediaError) {
929-
return Promise.reject(mediaError);
930-
}
931-
// NOTE: This should never happen. Adding for exhaustiveness (CJP).
932-
return Promise.reject(new Error('Unexpected error in app cert request'));
933-
}
934-
return Promise.reject(errOrResp);
935-
});
936-
await keys.setServerCertificate(fairPlayAppCert).catch(() => {
937-
const message = i18n(
938-
'Your server certificate failed when attempting to set it. This may be an issue with a no longer valid certificate.'
939-
);
940-
const mediaError = new MediaError(message, MediaError.MEDIA_ERR_ENCRYPTED, true);
941-
mediaError.errorCategory = MuxErrorCategory.DRM;
942-
mediaError.muxCode = MuxErrorCode.ENCRYPTED_UPDATE_SERVER_CERT_FAILED;
943-
return Promise.reject(mediaError);
944-
});
945-
// @ts-ignore
946-
} catch (error: Error | MediaError) {
947-
saveAndDispatchError(mediaEl, error);
948-
return;
949-
}
950-
await mediaEl.setMediaKeys(keys);
951-
}
952-
953-
const initData = event.initData;
954-
if (initData == null) {
955-
console.error(`Could not start encrypted playback due to missing initData in ${event.type} event`);
956-
return;
957-
}
885+
const setupMediaKeys = async (initDataType: string) => {
886+
const access = await navigator
887+
.requestMediaKeySystemAccess('com.apple.fps', [
888+
{
889+
initDataTypes: [initDataType],
890+
videoCapabilities: [{ contentType: 'application/vnd.apple.mpegurl', robustness: '' }],
891+
distinctiveIdentifier: 'not-allowed',
892+
persistentState: 'not-allowed',
893+
sessionTypes: ['temporary'],
894+
},
895+
])
896+
.then((value) => {
897+
props.drmTypeCb?.(DRMType.FAIRPLAY);
898+
return value;
899+
})
900+
.catch(() => {
901+
const message = i18n(
902+
'Cannot play DRM-protected content with current security configuration on this browser. Try playing in another browser.'
903+
);
904+
// Should we flag this as a business exception?
905+
const mediaError = new MediaError(message, MediaError.MEDIA_ERR_ENCRYPTED, true);
906+
mediaError.errorCategory = MuxErrorCategory.DRM;
907+
mediaError.muxCode = MuxErrorCode.ENCRYPTED_UNSUPPORTED_KEY_SYSTEM;
908+
saveAndDispatchError(mediaEl, mediaError);
909+
});
958910

959-
const session = (mediaEl.mediaKeys as MediaKeys).createSession();
960-
session.addEventListener('keystatuseschange', () => {
961-
// recheck key statuses
962-
// NOTE: As an improvement, we could also add checks for a status of 'expired' and
963-
// attempt to renew the license here (CJP)
964-
session.keyStatuses.forEach((mediaKeyStatus) => {
965-
let mediaError;
966-
if (mediaKeyStatus === 'internal-error') {
967-
const message = i18n(
968-
'The DRM Content Decryption Module system had an internal failure. Try reloading the page, upading your browser, or playing in another browser.'
969-
);
970-
mediaError = new MediaError(message, MediaError.MEDIA_ERR_ENCRYPTED, true);
971-
mediaError.errorCategory = MuxErrorCategory.DRM;
972-
mediaError.muxCode = MuxErrorCode.ENCRYPTED_CDM_ERROR;
973-
} else if (mediaKeyStatus === 'output-restricted' || mediaKeyStatus === 'output-downscaled') {
974-
const message = i18n(
975-
'DRM playback is being attempted in an environment that is not sufficiently secure. User may see black screen.'
976-
);
977-
// NOTE: When encountered, this is a non-fatal error (though it's certainly interruptive of standard playback experience). (CJP)
978-
mediaError = new MediaError(message, MediaError.MEDIA_ERR_ENCRYPTED, false);
979-
mediaError.errorCategory = MuxErrorCategory.DRM;
980-
mediaError.muxCode = MuxErrorCode.ENCRYPTED_OUTPUT_RESTRICTED;
981-
}
911+
if (!access) return;
982912

983-
if (mediaError) {
984-
saveAndDispatchError(mediaEl, mediaError);
985-
}
986-
});
987-
});
988-
const message = await Promise.all([
989-
session.generateRequest(initDataType, initData).catch(() => {
990-
// eslint-disable-next-line no-shadow
991-
const message = i18n(
992-
'Failed to generate a DRM license request. This may be an issue with the player or your protected content.'
993-
);
994-
const mediaError = new MediaError(message, MediaError.MEDIA_ERR_ENCRYPTED, true);
995-
mediaError.errorCategory = MuxErrorCategory.DRM;
996-
mediaError.muxCode = MuxErrorCode.ENCRYPTED_GENERATE_REQUEST_FAILED;
997-
saveAndDispatchError(mediaEl, mediaError);
998-
}),
999-
new Promise<MediaKeyMessageEvent['message']>((resolve) => {
1000-
session.addEventListener(
1001-
'message',
1002-
(messageEvent) => {
1003-
resolve(messageEvent.message);
1004-
},
1005-
{ once: true }
1006-
);
1007-
}),
1008-
]).then(([, messageEventMsg]) => messageEventMsg);
913+
const keys = await access.createMediaKeys();
1009914

1010-
const response = await getLicenseKey(message, toLicenseKeyURL(props, 'fairplay')).catch((errOrResp) => {
915+
try {
916+
const fairPlayAppCert = await getAppCertificate(toAppCertURL(props, 'fairplay')).catch((errOrResp) => {
1011917
if (errOrResp instanceof Response) {
1012918
const mediaError = getErrorFromResponse(errOrResp, MuxErrorCategory.DRM, props);
1013919
console.error('mediaError', mediaError?.message, mediaError?.context);
1014920
if (mediaError) {
1015921
return Promise.reject(mediaError);
1016922
}
1017923
// NOTE: This should never happen. Adding for exhaustiveness (CJP).
1018-
return Promise.reject(new Error('Unexpected error in license key request'));
924+
return Promise.reject(new Error('Unexpected error in app cert request'));
1019925
}
1020926
return Promise.reject(errOrResp);
1021927
});
1022-
await session.update(response).catch(() => {
1023-
// eslint-disable-next-line no-shadow
928+
await keys.setServerCertificate(fairPlayAppCert).catch(() => {
1024929
const message = i18n(
1025-
'Failed to update DRM license. This may be an issue with the player or your protected content.'
930+
'Your server certificate failed when attempting to set it. This may be an issue with a no longer valid certificate.'
1026931
);
1027932
const mediaError = new MediaError(message, MediaError.MEDIA_ERR_ENCRYPTED, true);
1028933
mediaError.errorCategory = MuxErrorCategory.DRM;
1029-
mediaError.muxCode = MuxErrorCode.ENCRYPTED_UPDATE_LICENSE_FAILED;
934+
mediaError.muxCode = MuxErrorCode.ENCRYPTED_UPDATE_SERVER_CERT_FAILED;
1030935
return Promise.reject(mediaError);
1031936
});
1032937
// @ts-ignore
1033938
} catch (error: Error | MediaError) {
1034939
saveAndDispatchError(mediaEl, error);
1035940
return;
1036941
}
942+
await mediaEl.setMediaKeys(keys);
943+
};
944+
945+
const updateMediaKeyStatus = (mediaKeyStatus: MediaKeyStatus) => {
946+
let mediaError;
947+
if (mediaKeyStatus === 'internal-error') {
948+
const message = i18n(
949+
'The DRM Content Decryption Module system had an internal failure. Try reloading the page, upading your browser, or playing in another browser.'
950+
);
951+
mediaError = new MediaError(message, MediaError.MEDIA_ERR_ENCRYPTED, true);
952+
mediaError.errorCategory = MuxErrorCategory.DRM;
953+
mediaError.muxCode = MuxErrorCode.ENCRYPTED_CDM_ERROR;
954+
} else if (mediaKeyStatus === 'output-restricted' || mediaKeyStatus === 'output-downscaled') {
955+
const message = i18n(
956+
'DRM playback is being attempted in an environment that is not sufficiently secure. User may see black screen.'
957+
);
958+
// NOTE: When encountered, this is a non-fatal error (though it's certainly interruptive of standard playback experience). (CJP)
959+
mediaError = new MediaError(message, MediaError.MEDIA_ERR_ENCRYPTED, false);
960+
mediaError.errorCategory = MuxErrorCategory.DRM;
961+
mediaError.muxCode = MuxErrorCode.ENCRYPTED_OUTPUT_RESTRICTED;
962+
}
963+
964+
if (mediaError) {
965+
saveAndDispatchError(mediaEl, mediaError);
966+
}
967+
};
968+
969+
const setupMediaKeySession = async (initDataType: string, initData: ArrayBuffer) => {
970+
const session = (mediaEl.mediaKeys as MediaKeys).createSession();
971+
const onKeyStatusChange = () => {
972+
// recheck key statuses
973+
// NOTE: As an improvement, we could also add checks for a status of 'expired' and
974+
// attempt to renew the license here (CJP)
975+
session.keyStatuses.forEach((keyStatus) => updateMediaKeyStatus(keyStatus));
976+
};
977+
978+
const onMessage = async (event: MediaKeyMessageEvent) => {
979+
const spc = event.message;
980+
try {
981+
const ckc = await getLicenseKey(spc, toLicenseKeyURL(props, 'fairplay'));
982+
983+
try {
984+
// This is the same call whether we are local or AirPlay.
985+
// Safari will forward CKC to Apple TV automatically.
986+
await session.update(ckc);
987+
} catch {
988+
const message = i18n(
989+
'Failed to update DRM license. This may be an issue with the player or your protected content.'
990+
);
991+
const mediaError = new MediaError(message, MediaError.MEDIA_ERR_ENCRYPTED, true);
992+
mediaError.errorCategory = MuxErrorCategory.DRM;
993+
mediaError.muxCode = MuxErrorCode.ENCRYPTED_UPDATE_LICENSE_FAILED;
994+
995+
saveAndDispatchError(mediaEl, mediaError);
996+
}
997+
} catch (errOrResp) {
998+
if (errOrResp instanceof Response) {
999+
const mediaError = getErrorFromResponse(errOrResp, MuxErrorCategory.DRM, props);
1000+
console.error('mediaError', mediaError?.message, mediaError?.context);
1001+
1002+
if (mediaError) {
1003+
saveAndDispatchError(mediaEl, mediaError);
1004+
return;
1005+
}
1006+
1007+
console.error('Unexpected error in license key request', errOrResp);
1008+
return;
1009+
}
1010+
1011+
console.error(errOrResp);
1012+
}
1013+
};
1014+
1015+
session.addEventListener('keystatuseschange', onKeyStatusChange);
1016+
session.addEventListener('message', onMessage);
1017+
mediaEl.addEventListener(
1018+
'teardown',
1019+
() => {
1020+
session.removeEventListener('keystatuseschange', onKeyStatusChange);
1021+
session.removeEventListener('message', onMessage);
1022+
session.close();
1023+
},
1024+
{ once: true }
1025+
);
1026+
1027+
await session.generateRequest(initDataType, initData).catch((e) => {
1028+
console.error('Failed to generate license request', e);
1029+
const message = i18n(
1030+
'Failed to generate a DRM license request. This may be an issue with the player or your protected content.'
1031+
);
1032+
const mediaError = new MediaError(message, MediaError.MEDIA_ERR_ENCRYPTED, true);
1033+
mediaError.errorCategory = MuxErrorCategory.DRM;
1034+
mediaError.muxCode = MuxErrorCode.ENCRYPTED_GENERATE_REQUEST_FAILED;
1035+
return Promise.reject(mediaError);
1036+
});
1037+
};
1038+
1039+
const onFpEncrypted = async (event: MediaEncryptedEvent) => {
1040+
try {
1041+
const initDataType = event.initDataType;
1042+
if (initDataType !== 'skd') {
1043+
console.error(`Received unexpected initialization data type "${initDataType}"`);
1044+
return;
1045+
}
1046+
1047+
if (!mediaEl.mediaKeys) {
1048+
await setupMediaKeys(initDataType);
1049+
}
1050+
1051+
const initData = event.initData;
1052+
if (initData == null) {
1053+
console.error(`Could not start encrypted playback due to missing initData in ${event.type} event`);
1054+
return;
1055+
}
1056+
1057+
await setupMediaKeySession(initDataType, initData);
1058+
// @ts-ignore
1059+
} catch (error: Error | MediaError) {
1060+
saveAndDispatchError(mediaEl, error);
1061+
return;
1062+
}
10371063
};
10381064

10391065
addEventListenerWithTeardown(mediaEl, 'encrypted', onFpEncrypted);

0 commit comments

Comments
 (0)