From c912bda8d452270550df48eaea3b6058f5fe7bc2 Mon Sep 17 00:00:00 2001 From: Gary Katsevman Date: Tue, 19 Oct 2021 16:52:27 -0400 Subject: [PATCH] revert: "revert: fix: use in-spec EME for versions of Safari which support it (#142) (#145)" (#146) This reverts commit fdb57e30ec679dc934808d4c680745e62b163498. --- README.md | 1 + src/eme.js | 140 ++++++++++++++++++++++++++++++-------------- src/fairplay.js | 2 +- src/plugin.js | 29 +++++---- src/utils.js | 2 +- test/eme.test.js | 64 +++++++++++++++++--- test/plugin.test.js | 51 +++++++++++++--- 7 files changed, 213 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index 2c07d40..d0930ca 100644 --- a/README.md +++ b/README.md @@ -386,6 +386,7 @@ player.src({ }, keySystems: { 'org.w3.clearkey': { + initDataTypes: ['cenc', 'webm'], audioContentType: 'audio/webm; codecs="vorbis"', videoContentType: 'video/webm; codecs="vp9"', getCertificate: function(emeOptions, callback) { diff --git a/src/eme.js b/src/eme.js index 2fca35d..97781c2 100644 --- a/src/eme.js +++ b/src/eme.js @@ -3,6 +3,13 @@ import { requestPlayreadyLicense } from './playready'; import window from 'global/window'; import {mergeAndRemoveNull} from './utils'; import {httpResponseHandler} from './http-handler.js'; +import { + defaultGetCertificate as defaultFairplayGetCertificate, + defaultGetLicense as defaultFairplayGetLicense, + defaultGetContentId as defaultFairplayGetContentId +} from './fairplay'; + +const isFairplayKeySystem = (str) => str.startsWith('com.apple.fps'); /** * Returns an array of MediaKeySystemConfigurationObjects provided in the keySystem @@ -15,16 +22,21 @@ import {httpResponseHandler} from './http-handler.js'; * @return {Object[]} * Array of MediaKeySystemConfigurationObjects */ -export const getSupportedConfigurations = (keySystemOptions) => { +export const getSupportedConfigurations = (keySystem, keySystemOptions) => { if (keySystemOptions.supportedConfigurations) { return keySystemOptions.supportedConfigurations; } - // TODO use initDataTypes when appropriate + const isFairplay = isFairplayKeySystem(keySystem); const supportedConfiguration = {}; + const initDataTypes = keySystemOptions.initDataTypes || + // fairplay requires an explicit initDataTypes + (isFairplay ? ['sinf'] : null); const audioContentType = keySystemOptions.audioContentType; const audioRobustness = keySystemOptions.audioRobustness; - const videoContentType = keySystemOptions.videoContentType; + const videoContentType = keySystemOptions.videoContentType || + // fairplay requires an explicit videoCapabilities/videoContentType + (isFairplay ? 'video/mp4' : null); const videoRobustness = keySystemOptions.videoRobustness; const persistentState = keySystemOptions.persistentState; @@ -52,6 +64,10 @@ export const getSupportedConfigurations = (keySystemOptions) => { supportedConfiguration.persistentState = persistentState; } + if (initDataTypes) { + supportedConfiguration.initDataTypes = initDataTypes; + } + return [supportedConfiguration]; }; @@ -62,7 +78,7 @@ export const getSupportedKeySystem = (keySystems) => { let promise; Object.keys(keySystems).forEach((keySystem) => { - const supportedConfigurations = getSupportedConfigurations(keySystems[keySystem]); + const supportedConfigurations = getSupportedConfigurations(keySystem, keySystems[keySystem]); if (!promise) { promise = @@ -84,8 +100,10 @@ export const makeNewRequest = (requestOptions) => { options, getLicense, removeSession, - eventBus + eventBus, + contentId } = requestOptions; + const keySession = mediaKeys.createSession(); eventBus.trigger('keysessioncreated'); @@ -97,7 +115,8 @@ export const makeNewRequest = (requestOptions) => { if (event.messageType !== 'license-request' && event.messageType !== 'license-renewal') { return; } - getLicense(options, event.message) + + getLicense(options, event.message, contentId) .then((license) => { resolve(keySession.update(license)); }) @@ -192,29 +211,27 @@ export const addSession = ({ initData, options, getLicense, + contentId, removeSession, eventBus }) => { - if (video.mediaKeysObject) { - return makeNewRequest({ - mediaKeys: video.mediaKeysObject, - initDataType, - initData, - options, - getLicense, - removeSession, - eventBus - }); - } - - video.pendingSessionData.push({ + const sessionData = { initDataType, initData, options, getLicense, removeSession, - eventBus - }); + eventBus, + contentId + }; + + if (video.mediaKeysObject) { + sessionData.mediaKeys = video.mediaKeysObject; + return makeNewRequest(sessionData); + } + + video.pendingSessionData.push(sessionData); + return Promise.resolve(); }; @@ -261,7 +278,8 @@ export const addPendingSessions = ({ options: data.options, getLicense: data.getLicense, removeSession: data.removeSession, - eventBus: data.eventBus + eventBus: data.eventBus, + contentId: data.contentId })); } @@ -292,10 +310,10 @@ export const defaultGetLicense = (keySystemOptions) => (emeOptions, keyMessage, }, httpResponseHandler(callback, true)); }; -const promisifyGetLicense = (getLicenseFn, eventBus) => { - return (emeOptions, keyMessage) => { +const promisifyGetLicense = (keySystem, getLicenseFn, eventBus) => { + return (emeOptions, keyMessage, contentId) => { return new Promise((resolve, reject) => { - getLicenseFn(emeOptions, keyMessage, (err, license) => { + const callback = function(err, license) { if (eventBus) { eventBus.trigger('licenserequestattempted'); } @@ -305,7 +323,13 @@ const promisifyGetLicense = (getLicenseFn, eventBus) => { } resolve(license); - }); + }; + + if (isFairplayKeySystem(keySystem)) { + getLicenseFn(emeOptions, contentId, new Uint8Array(keyMessage), callback); + } else { + getLicenseFn(emeOptions, keyMessage, callback); + } }); }; }; @@ -315,14 +339,36 @@ const standardizeKeySystemOptions = (keySystem, keySystemOptions) => { keySystemOptions = { url: keySystemOptions }; } + if (!keySystemOptions.url && keySystemOptions.licenseUri) { + keySystemOptions.url = keySystemOptions.licenseUri; + } + if (!keySystemOptions.url && !keySystemOptions.getLicense) { - throw new Error('Neither URL nor getLicense function provided to get license'); + throw new Error(`Missing url/licenseUri or getLicense in ${keySystem} keySystem configuration.`); + } + + const isFairplay = isFairplayKeySystem(keySystem); + + if (isFairplay && keySystemOptions.certificateUri && !keySystemOptions.getCertificate) { + keySystemOptions.getCertificate = defaultFairplayGetCertificate(keySystemOptions); + } + + if (isFairplay && !keySystemOptions.getCertificate) { + throw new Error(`Missing getCertificate or certificateUri in ${keySystem} keySystem configuration.`); + } + + if (isFairplay && !keySystemOptions.getContentId) { + keySystemOptions.getContentId = defaultFairplayGetContentId; } if (keySystemOptions.url && !keySystemOptions.getLicense) { - keySystemOptions.getLicense = keySystem === 'com.microsoft.playready' ? - defaultPlayreadyGetLicense(keySystemOptions) : - defaultGetLicense(keySystemOptions); + if (keySystem === 'com.microsoft.playready') { + keySystemOptions.getLicense = defaultPlayreadyGetLicense(keySystemOptions); + } else if (isFairplay) { + keySystemOptions.getLicense = defaultFairplayGetLicense(keySystemOptions); + } else { + keySystemOptions.getLicense = defaultGetLicense(keySystemOptions); + } } return keySystemOptions; @@ -338,6 +384,21 @@ export const standard5July2016 = ({ eventBus }) => { let keySystemPromise = Promise.resolve(); + const keySystem = keySystemAccess.keySystem; + let keySystemOptions; + + // try catch so that we return a promise rejection + try { + keySystemOptions = standardizeKeySystemOptions( + keySystem, + options.keySystems[keySystem] + ); + } catch (e) { + return Promise.reject(e); + } + + const contentId = keySystemOptions.getContentId ? + keySystemOptions.getContentId(options, initData) : null; if (typeof video.mediaKeysObject === 'undefined') { // Prevent entering this path again. @@ -347,16 +408,10 @@ export const standard5July2016 = ({ video.pendingSessionData = []; let certificate; - let keySystemOptions; keySystemPromise = new Promise((resolve, reject) => { // save key system for adding sessions - video.keySystem = keySystemAccess.keySystem; - - keySystemOptions = standardizeKeySystemOptions( - keySystemAccess.keySystem, - options.keySystems[keySystemAccess.keySystem] - ); + video.keySystem = keySystem; if (!keySystemOptions.getCertificate) { resolve(keySystemAccess); @@ -392,18 +447,17 @@ export const standard5July2016 = ({ } return keySystemPromise.then(() => { - const {getLicense} = standardizeKeySystemOptions( - video.keySystem, - options.keySystems[video.keySystem] - ); + // if key system has not been determined then addSession doesn't need getLicense + const getLicense = video.keySystem ? + promisifyGetLicense(keySystem, keySystemOptions.getLicense, eventBus) : null; return addSession({ video, initDataType, initData, options, - // if key system has not been determined then addSession doesn't need getLicense - getLicense: video.keySystem ? promisifyGetLicense(getLicense, eventBus) : null, + getLicense, + contentId, removeSession, eventBus }); diff --git a/src/fairplay.js b/src/fairplay.js index 4f0dfaa..c5ec89c 100644 --- a/src/fairplay.js +++ b/src/fairplay.js @@ -128,7 +128,7 @@ export const defaultGetCertificate = (fairplayOptions) => { }; }; -const defaultGetContentId = (emeOptions, initData) => { +export const defaultGetContentId = (emeOptions, initData) => { return getHostnameFromUri(uint8ArrayToString(initData)); }; diff --git a/src/plugin.js b/src/plugin.js index da16959..666f654 100644 --- a/src/plugin.js +++ b/src/plugin.js @@ -225,7 +225,18 @@ const onPlayerReady = (player, emeError) => { setupSessions(player); - if (window.WebKitMediaKeys) { + if (window.MediaKeys) { + // Support EME 05 July 2016 + // Chrome 42+, Firefox 47+, Edge, Safari 12.1+ on macOS 10.14+ + player.tech_.el_.addEventListener('encrypted', (event) => { + // TODO convert to videojs.log.debug and add back in + // https://github.com/videojs/video.js/pull/4780 + // videojs.log('eme', 'Received an \'encrypted\' event'); + setupSessions(player); + handleEncryptedEvent(event, getOptions(player), player.eme.sessions, player.tech_) + .catch(emeError); + }); + } else if (window.WebKitMediaKeys) { const handleFn = (event) => { // TODO convert to videojs.log.debug and add back in // https://github.com/videojs/video.js/pull/4780 @@ -278,18 +289,6 @@ const onPlayerReady = (player, emeError) => { } }); - } else if (window.MediaKeys) { - // Support EME 05 July 2016 - // Chrome 42+, Firefox 47+, Edge, Safari 12.1+ on macOS 10.14+ - player.tech_.el_.addEventListener('encrypted', (event) => { - // TODO convert to videojs.log.debug and add back in - // https://github.com/videojs/video.js/pull/4780 - // videojs.log('eme', 'Received an \'encrypted\' event'); - setupSessions(player); - handleEncryptedEvent(event, getOptions(player), player.eme.sessions, player.tech_) - .catch(emeError); - }); - } else if (window.MSMediaKeys) { // IE11 Windows 8.1+ // Since IE11 doesn't support promises, we have to use a combination of @@ -364,7 +363,7 @@ const eme = function(options = {}) { setupSessions(player); - if (player.tech_.el_.setMediaKeys) { + if (window.MediaKeys) { handleEncryptedEvent(mockEncryptedEvent, mergedEmeOptions, player.eme.sessions, player.tech_) .then(() => callback()) .catch((error) => { @@ -373,7 +372,7 @@ const eme = function(options = {}) { emeError(error); } }); - } else if (player.tech_.el_.msSetMediaKeys) { + } else if (window.MSMediaKeys) { const msKeyHandler = (event) => { player.tech_.off('mskeyadded', msKeyHandler); player.tech_.off('mskeyerror', msKeyHandler); diff --git a/src/utils.js b/src/utils.js index 2f97b60..c5f990e 100644 --- a/src/utils.js +++ b/src/utils.js @@ -14,7 +14,7 @@ export const stringToUint16Array = (string) => { }; export const uint8ArrayToString = (array) => { - return String.fromCharCode.apply(null, new Uint16Array(array.buffer)); + return String.fromCharCode.apply(null, new Uint8Array(array.buffer || array)); }; export const getHostnameFromUri = (uri) => { diff --git a/test/eme.test.js b/test/eme.test.js index d6c3b79..e374525 100644 --- a/test/eme.test.js +++ b/test/eme.test.js @@ -1,5 +1,6 @@ import QUnit from 'qunit'; import videojs from 'video.js'; +import window from 'global/window'; import { defaultGetLicense, standard5July2016, @@ -573,7 +574,7 @@ if (!videojs.browser.IS_ANY_SAFARI) { }); } -QUnit.test('errors when neither url nor getLicense is given', function(assert) { +QUnit.test('errors when missing url/licenseUri or getLicense', function(assert) { const options = { keySystems: { 'com.widevine.alpha': {} @@ -592,7 +593,32 @@ QUnit.test('errors when neither url nor getLicense is given', function(assert) { }).catch((err) => { assert.equal( err, - 'Error: Neither URL nor getLicense function provided to get license', + 'Error: Missing url/licenseUri or getLicense in com.widevine.alpha keySystem configuration.', + 'correct error message' + ); + done(); + }); +}); + +QUnit.test('errors when missing certificateUri and getCertificate for fairplay', function(assert) { + const options = { + keySystems: { + 'com.apple.fps': {url: 'fake-url'} + } + }; + const keySystemAccess = { + keySystem: 'com.apple.fps' + }; + const done = assert.async(); + + standard5July2016({ + video: {}, + keySystemAccess, + options + }).catch((err) => { + assert.equal( + err, + 'Error: Missing getCertificate or certificateUri in com.apple.fps keySystem configuration.', 'correct error message' ); done(); @@ -1087,6 +1113,25 @@ QUnit.test('licenseHeaders keySystems property overrides emeHeaders value', func }); }); +QUnit.test('sets required fairplay defaults if not explicitly configured', function(assert) { + const origRequestMediaKeySystemAccess = window.navigator.requestMediaKeySystemAccess; + + window.navigator.requestMediaKeySystemAccess = (keySystem, systemOptions) => { + assert.ok( + systemOptions[0].initDataTypes.indexOf('sinf') !== -1, + 'includes required initDataType' + ); + assert.ok( + systemOptions[0].videoCapabilities[0].contentType.indexOf('video/mp4') !== -1, + 'includes required video contentType' + ); + }; + + getSupportedKeySystem({'com.apple.fps': {}}); + + window.requestMediaKeySystemAccess = origRequestMediaKeySystemAccess; +}); + QUnit.module('session management'); QUnit.test('addSession saves options', function(assert) { @@ -1099,9 +1144,11 @@ QUnit.test('addSession saves options', function(assert) { const getLicense = () => ''; const removeSession = () => ''; const eventBus = { trigger: () => {} }; + const contentId = null; addSession({ video, + contentId, initDataType, initData, options, @@ -1118,7 +1165,8 @@ QUnit.test('addSession saves options', function(assert) { options, getLicense, removeSession, - eventBus + eventBus, + contentId }], 'saved options into pendingSessionData array' ); @@ -1190,7 +1238,7 @@ QUnit.module('videojs-contrib-eme getSupportedConfigurations'); QUnit.test('includes audio and video content types', function(assert) { assert.deepEqual( - getSupportedConfigurations({ + getSupportedConfigurations('com.widevine.alpha', { audioContentType: 'audio/mp4; codecs="mp4a.40.2"', videoContentType: 'video/mp4; codecs="avc1.42E01E"' }), @@ -1208,7 +1256,7 @@ QUnit.test('includes audio and video content types', function(assert) { QUnit.test('includes audio and video robustness', function(assert) { assert.deepEqual( - getSupportedConfigurations({ + getSupportedConfigurations('com.widevine.alpha', { audioRobustness: 'SW_SECURE_CRYPTO', videoRobustness: 'SW_SECURE_CRYPTO' }), @@ -1226,7 +1274,7 @@ QUnit.test('includes audio and video robustness', function(assert) { QUnit.test('includes audio and video content types and robustness', function(assert) { assert.deepEqual( - getSupportedConfigurations({ + getSupportedConfigurations('com.widevine.alpha', { audioContentType: 'audio/mp4; codecs="mp4a.40.2"', audioRobustness: 'SW_SECURE_CRYPTO', videoContentType: 'video/mp4; codecs="avc1.42E01E"', @@ -1248,7 +1296,7 @@ QUnit.test('includes audio and video content types and robustness', function(ass QUnit.test('includes persistentState', function(assert) { assert.deepEqual( - getSupportedConfigurations({ persistentState: 'optional' }), + getSupportedConfigurations('com.widevine.alpha', { persistentState: 'optional' }), [{ persistentState: 'optional' }], 'included persistentState' ); @@ -1256,7 +1304,7 @@ QUnit.test('includes persistentState', function(assert) { QUnit.test('uses supportedConfigurations directly if provided', function(assert) { assert.deepEqual( - getSupportedConfigurations({ + getSupportedConfigurations('com.widevine.alpha', { supportedConfigurations: [{ initDataTypes: ['cenc'], audioCapabilities: [{ diff --git a/test/plugin.test.js b/test/plugin.test.js index 676965d..6c357af 100644 --- a/test/plugin.test.js +++ b/test/plugin.test.js @@ -107,8 +107,8 @@ QUnit.test('exposes options', function(assert) { ); }); -// skip test for Safari -if (!window.WebKitMediaKeys) { +// skip test for prefix-only Safari +if (!window.MediaKeys) { QUnit.test('initializeMediaKeys standard', function(assert) { assert.expect(9); const done = assert.async(); @@ -128,7 +128,7 @@ if (!window.WebKitMediaKeys) { assert.deepEqual(sessions[0].initData, initData, 'captured initData in the session'); assert.equal( error, - 'Error: Neither URL nor getLicense function provided to get license', + 'Error: Missing url/licenseUri or getLicense in com.widevine.alpha configuration.', 'callback receives error' ); }; @@ -140,7 +140,7 @@ if (!window.WebKitMediaKeys) { assert.equal(errors, 1, 'error triggered only once'); assert.equal( this.player.error().message, - 'Neither URL nor getLicense function provided to get license', + 'Missing url/licenseUri or getLicense in com.widevine.alpha configuration.', 'error is called on player' ); this.player.error(null); @@ -281,7 +281,6 @@ QUnit.test('initializeMediaKeys ms-prefix', function(assert) { QUnit.test('tech error listener is removed on dispose', function(assert) { const done = assert.async(1); let called = 0; - const browser = videojs.browser; const origMediaKeys = window.MediaKeys; const origWebKitMediaKeys = window.WebKitMediaKeys; @@ -290,8 +289,6 @@ QUnit.test('tech error listener is removed on dispose', function(assert) { if (!window.MSMediaKeys) { window.MSMediaKeys = noop.bind(this); } - // let this test pass on edge - videojs.browser = {IS_EDGE: false}; this.player.error = () => { called++; @@ -310,7 +307,6 @@ QUnit.test('tech error listener is removed on dispose', function(assert) { assert.equal(called, 1, 'not called after player disposal'); this.player.error = undefined; - videojs.browser = browser; window.MediaKeys = origMediaKeys; window.WebKitMediaKeys = origWebKitMediaKeys; done(); @@ -319,6 +315,45 @@ QUnit.test('tech error listener is removed on dispose', function(assert) { this.clock.tick(1); }); +QUnit.test('only registers for spec-compliant events even if legacy APIs are available', function(assert) { + const done = assert.async(1); + + const origMediaKeys = window.MediaKeys; + const origMSMediaKeys = window.MSMediaKeys; + const origWebKitMediaKeys = window.WebKitMediaKeys; + + const events = { + encrypted: 0, + msneedkey: 0, + webkitneedkey: 0 + }; + + this.player.tech_.el_ = { + addEventListener: e => events[e]++, + hasAttribute: () => false + }; + + window.MediaKeys = noop; + window.MSMediaKeys = noop; + window.WebKitMediaKeys = noop; + + this.player.eme(); + + this.player.ready(() => { + assert.equal(events.encrypted, 1, 'registers for encrypted events'); + assert.equal(events.msneedkey, 0, "doesn't register for msneedkey events"); + assert.equal(events.webkitneedkey, 0, "doesn't register for webkitneedkey events"); + + window.MediaKeys = origMediaKeys; + window.MSMediaKeys = origMSMediaKeys; + window.WebKitMediaKeys = origWebKitMediaKeys; + done(); + }); + + this.clock.tick(1); + +}); + QUnit.module('plugin guard functions', { beforeEach() { this.options = {