diff --git a/README.md b/README.md index c25e61fbe4..c9e2c61490 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,9 @@ NOTES: |HLS |**Y** |**Y** |**Y** ¹ | - | NOTES: - - ¹: We support FairPlay through Apple's native HLS player. + - ¹: By default, FairPlay is handled using Apple's native HLS player, when on + Safari. We do support FairPlay through MSE/EME, however. See the + `streaming.useNativeHlsOnSafari` configuration value. ## Media container and subtitle support diff --git a/docs/tutorials/drm-config.md b/docs/tutorials/drm-config.md index c68d253293..8e9f79767e 100644 --- a/docs/tutorials/drm-config.md +++ b/docs/tutorials/drm-config.md @@ -184,6 +184,11 @@ Microsoft Documentation: https://docs.microsoft.com/en-us/playready/overview/sec NB: Audio Hardware DRM is not supported (PlayReady limitation) +##### FairPlay + +Based on [Apple's Documentation](https://developer.apple.com/streaming/fps/), +you should provide an empty string as robustness + ##### Other key-systems Values for other key systems are not known to us at this time. diff --git a/docs/tutorials/fairplay.md b/docs/tutorials/fairplay.md index 7ca3f7439c..d223f05fc8 100644 --- a/docs/tutorials/fairplay.md +++ b/docs/tutorials/fairplay.md @@ -1,25 +1,31 @@ # FairPlay Support -When using native `src=` playback, we support using FairPlay on Safari. +We support FairPlay with EME on compatible environments or native `src=`. Adding FairPlay support involves a bit more work than other key systems. - ## Server certificate -All FairPlay content requires setting a server certificate. This is set in the -Player configuration: +All FairPlay content requires setting a server certificate. You can either +provide it directly or set a serverCertificateUri for Shaka to fetch it for +you. ```js const req = await fetch('https://example.com/cert.der'); const cert = await req.arrayBuffer(); -player.configure('drm.advanced.com\\.apple\\.fps\\.1_0.serverCertificate', +player.configure('drm.advanced.com\\.apple\\.fps\\.serverCertificate', new Uint8Array(cert)); ``` +```js +player.configure('drm.advanced.com\\.apple\\.fps\\.serverCertificateUri', + 'https://example.com/cert.der'); +``` ## Content ID +Note: This only applies when legacy Apple Media Keys is used. + Some FairPlay content use custom signaling for the content ID. The content ID is used by the browser to generate the license request. If you don't use the default content ID derivation, you need to specify a custom init data transform: diff --git a/externs/polyfill.js b/externs/polyfill.js new file mode 100644 index 0000000000..89d5f58147 --- /dev/null +++ b/externs/polyfill.js @@ -0,0 +1,15 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Externs for Shaka polyfills + * + * @externs + */ + + +/** @type {boolean} */ +window.shakaMediaKeysPolyfill; diff --git a/externs/shaka/player.js b/externs/shaka/player.js index a2f9eff1fb..5b17095be3 100644 --- a/externs/shaka/player.js +++ b/externs/shaka/player.js @@ -934,8 +934,9 @@ shaka.extern.ManifestConfiguration; * @property {boolean} useNativeHlsOnSafari * Desktop Safari has both MediaSource and their native HLS implementation. * Depending on the application's needs, it may prefer one over the other. - * Examples: FairPlay is only supported via Safari's native HLS, but it - * doesn't have an API for selecting specific tracks. + * Warning when disabled: Where single-key DRM streams work fine, multi-keys + * streams is showing unexpected behaviours (stall, audio playing with video + * freezes, ...). Use with care. * @property {number} inaccurateManifestTolerance * The maximum difference, in seconds, between the times in the manifest and * the times in the segments. Larger values allow us to compensate for more diff --git a/lib/hls/hls_parser.js b/lib/hls/hls_parser.js index 42de5a8d21..0d60e27e7a 100644 --- a/lib/hls/hls_parser.js +++ b/lib/hls/hls_parser.js @@ -40,6 +40,7 @@ goog.require('shaka.util.Networking'); goog.require('shaka.util.OperationManager'); goog.require('shaka.util.Pssh'); goog.require('shaka.util.Timer'); +goog.require('shaka.util.Platform'); goog.requireType('shaka.hls.Segment'); @@ -1391,6 +1392,21 @@ shaka.hls.HlsParser = class { shaka.util.Error.Code.HLS_INVALID_PLAYLIST_HIERARCHY); } + /** @type {!Array.} */ + const variablesTags = shaka.hls.Utils.filterTagsByName(playlist.tags, + 'EXT-X-DEFINE'); + + const mediaVariables = this.parseMediaVariables_(variablesTags); + + goog.asserts.assert(playlist.segments != null, + 'Media playlist should have segments!'); + + this.determinePresentationType_(playlist); + + /** @type {string} */ + const mimeType = await this.guessMimeType_(type, codecs, playlist, + mediaVariables); + /** @type {!Array.} */ const drmTags = []; if (playlist.segments) { @@ -1425,7 +1441,7 @@ shaka.hls.HlsParser = class { const drmParser = shaka.hls.HlsParser.KEYFORMATS_TO_DRM_PARSERS_[keyFormat]; - const drmInfo = drmParser ? drmParser(drmTag) : null; + const drmInfo = drmParser ? drmParser(drmTag, mimeType) : null; if (drmInfo) { if (drmInfo.keyIds) { for (const keyId of drmInfo.keyIds) { @@ -1446,21 +1462,6 @@ shaka.hls.HlsParser = class { shaka.util.Error.Code.HLS_KEYFORMATS_NOT_SUPPORTED); } - /** @type {!Array.} */ - const variablesTags = shaka.hls.Utils.filterTagsByName(playlist.tags, - 'EXT-X-DEFINE'); - - const mediaVariables = this.parseMediaVariables_(variablesTags); - - goog.asserts.assert(playlist.segments != null, - 'Media playlist should have segments!'); - - this.determinePresentationType_(playlist); - - /** @type {string} */ - const mimeType = await this.guessMimeType_(type, codecs, playlist, - mediaVariables); - // MediaSource expects no codec strings combined with raw formats. // TODO(#2337): Instead, create a Stream flag indicating a raw format. if (shaka.hls.HlsParser.RAW_FORMATS_.includes(mimeType)) { @@ -2807,6 +2808,41 @@ shaka.hls.HlsParser = class { return op.promise; } + /** + * @param {!shaka.hls.Tag} drmTag + * @param {string} mimeType + * @return {?shaka.extern.DrmInfo} + * @private + */ + static fairplayDrmParser_(drmTag, mimeType) { + if (mimeType == 'video/mp2t') { + throw new shaka.util.Error( + shaka.util.Error.Severity.CRITICAL, + shaka.util.Error.Category.MANIFEST, + shaka.util.Error.Code.HLS_MSE_ENCRYPTED_MP2T_NOT_SUPPORTED); + } + + if (shaka.util.Platform.isMediaKeysPolyfilled()) { + throw new shaka.util.Error( + shaka.util.Error.Severity.CRITICAL, + shaka.util.Error.Category.MANIFEST, + shaka.util.Error.Code + .HLS_MSE_ENCRYPTED_LEGACY_APPLE_MEDIA_KEYS_NOT_SUPPORTED); + } + + /* + * Even if we're not able to construct initData through the HLS tag, adding + * a DRMInfo will allow DRM Engine to request a media key system access + * with the correct keySystem and initDataType + */ + const drmInfo = shaka.util.ManifestParserUtils.createDrmInfo( + 'com.apple.fps', [ + {initDataType: 'sinf', initData: new Uint8Array(0)}, + ]); + + return drmInfo; + } + /** * @param {!shaka.hls.Tag} drmTag * @return {?shaka.extern.DrmInfo} @@ -3029,7 +3065,7 @@ shaka.hls.HlsParser.EXTENSION_MAP_BY_CONTENT_TYPE_ = { /** - * @typedef {function(!shaka.hls.Tag):?shaka.extern.DrmInfo} + * @typedef {function(!shaka.hls.Tag, string):?shaka.extern.DrmInfo} * @private */ shaka.hls.HlsParser.DrmParser_; @@ -3040,10 +3076,8 @@ shaka.hls.HlsParser.DrmParser_; * @private */ shaka.hls.HlsParser.KEYFORMATS_TO_DRM_PARSERS_ = { - /* TODO: https://github.com/google/shaka-player/issues/382 'com.apple.streamingkeydelivery': shaka.hls.HlsParser.fairplayDrmParser_, - */ 'urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed': shaka.hls.HlsParser.widevineDrmParser_, 'com.microsoft.playready': diff --git a/lib/media/drm_engine.js b/lib/media/drm_engine.js index f1de943ba1..889b205103 100644 --- a/lib/media/drm_engine.js +++ b/lib/media/drm_engine.js @@ -128,6 +128,9 @@ shaka.media.DrmEngine = class { /** @private {boolean} */ this.srcEquals_ = false; + + /** @private {Promise} */ + this.mediaKeysAttached_ = null; } /** @override */ @@ -184,6 +187,7 @@ shaka.media.DrmEngine = class { this.onError_ = () => {}; this.playerInterface_ = null; this.srcEquals_ = false; + this.mediaKeysAttached_ = null; } /** @@ -387,7 +391,59 @@ shaka.media.DrmEngine = class { } /** - * Attach MediaKeys to the video element and start processing events. + * Attach MediaKeys to the video element + * @return {Promise} + * @private + */ + async attachMediaKeys_() { + if (this.video_.mediaKeys) { + return; + } + + // An attach process has already started, let's wait it out + if (this.mediaKeysAttached_) { + await this.mediaKeysAttached_; + + return; + } + + try { + this.mediaKeysAttached_ = this.video_.setMediaKeys(this.mediaKeys_); + + await this.mediaKeysAttached_; + } catch (exception) { + goog.asserts.assert(exception instanceof Error, 'Wrong error type!'); + + this.onError_(new shaka.util.Error( + shaka.util.Error.Severity.CRITICAL, + shaka.util.Error.Category.DRM, + shaka.util.Error.Code.FAILED_TO_ATTACH_TO_VIDEO, + exception.message)); + } + + this.destroyer_.ensureNotDestroyed(); + } + + /** + * Processes encrypted event and start licence challenging + * @return {!Promise} + * @private + */ + async onEncryptedEvent_(event) { + /** + * MediaKeys should be added when receiving an encrypted event. Setting + * mediaKeys before could result into encrypted event not being fired on + * some browsers + */ + await this.attachMediaKeys_(); + + this.newInitData( + event.initDataType, + shaka.util.BufferUtils.toUint8(event.initData)); + } + + /** + * Start processing events. * @param {HTMLMediaElement} video * @return {!Promise} */ @@ -420,27 +476,30 @@ shaka.media.DrmEngine = class { () => this.closeOpenSessions_()); } - let setMediaKeys = this.video_.setMediaKeys(this.mediaKeys_); - setMediaKeys = setMediaKeys.catch((exception) => { - goog.asserts.assert(exception instanceof Error, 'Wrong error type!'); - return Promise.reject(new shaka.util.Error( - shaka.util.Error.Severity.CRITICAL, - shaka.util.Error.Category.DRM, - shaka.util.Error.Code.FAILED_TO_ATTACH_TO_VIDEO, - exception.message)); - }); + const manifestInitData = this.currentDrmInfo_.initData.find( + (initDataOverride) => initDataOverride.initData.length > 0); - await setMediaKeys; - this.destroyer_.ensureNotDestroyed(); + /** + * We can attach media keys before the playback actually begins when: + * - Using legacy implementations requires MediaKeys to be set before + * having webkitneedkey / msneedkey event, which will be translated as + * an encrypted event by the polyfills + * - Some initData already has been generated (through the manifest) + * - In case of an offline session + */ + if (manifestInitData || + shaka.util.Platform.isMediaKeysPolyfilled() || + this.offlineSessionIds_.length) { + await this.attachMediaKeys_(); + } this.createOrLoad(); - if (!this.currentDrmInfo_.initData.length && - !this.offlineSessionIds_.length) { - // Explicit init data for any one stream or an offline session is - // sufficient to suppress 'encrypted' events for all streams. - const cb = (e) => this.newInitData( - e.initDataType, shaka.util.BufferUtils.toUint8(e.initData)); - this.eventManager_.listen(this.video_, 'encrypted', cb); + + // Explicit init data for any one stream or an offline session is + // sufficient to suppress 'encrypted' events for all streams. + if (!manifestInitData && !this.offlineSessionIds_.length) { + this.eventManager_.listen( + this.video_, 'encrypted', (e) => this.onEncryptedEvent_(e)); } } @@ -589,6 +648,10 @@ shaka.media.DrmEngine = class { * @param {!Uint8Array} initData */ newInitData(initDataType, initData) { + if (!initData.length) { + return; + } + // Suppress duplicate init data. // Note that some init data are extremely large and can't portably be used // as keys in a dictionary. @@ -1188,20 +1251,26 @@ shaka.media.DrmEngine = class { }; this.activeSessions_.set(session, metadata); - try { - initData = this.config_.initDataTransform( - initData, initDataType, this.currentDrmInfo_); - } catch (error) { - let shakaError = error; - if (!(error instanceof shaka.util.Error)) { - shakaError = new shaka.util.Error( - shaka.util.Error.Severity.CRITICAL, - shaka.util.Error.Category.DRM, - shaka.util.Error.Code.INIT_DATA_TRANSFORM_ERROR, - error); + /** + * initDataTransform is only necessary when using legacy protection + * APIs, so prevent doing any transform when using the EME HTML5 spec + */ + if (shaka.util.Platform.isMediaKeysPolyfilled()) { + try { + initData = this.config_.initDataTransform( + initData, initDataType, this.currentDrmInfo_); + } catch (error) { + let shakaError = error; + if (!(error instanceof shaka.util.Error)) { + shakaError = new shaka.util.Error( + shaka.util.Error.Severity.CRITICAL, + shaka.util.Error.Category.DRM, + shaka.util.Error.Code.INIT_DATA_TRANSFORM_ERROR, + error); + } + this.onError_(shakaError); + return; } - this.onError_(shakaError); - return; } if (this.config_.logLicenseExchange) { @@ -1641,6 +1710,16 @@ shaka.media.DrmEngine = class { const testSystem = async (keySystem) => { try { + // Our Polyfill will reject anything apart com.apple.fps key systems. + // It seems the Safari modern EME API will allow to request a + // MediaKeySystemAccess for the ClearKey CDM, create and update a key + // session but playback will never start + // Safari bug: https://bugs.webkit.org/show_bug.cgi?id=231006 + if (keySystem === 'org.w3.clearkey' && + shaka.util.Platform.isSafari()) { + throw new Error('Unsupported keySystem'); + } + const access = await navigator.requestMediaKeySystemAccess( keySystem, configs); diff --git a/lib/polyfill/patchedmediakeys_apple.js b/lib/polyfill/patchedmediakeys_apple.js index cc44b0bb2a..09a309293f 100644 --- a/lib/polyfill/patchedmediakeys_apple.js +++ b/lib/polyfill/patchedmediakeys_apple.js @@ -17,6 +17,7 @@ goog.require('shaka.util.FakeEventTarget'); goog.require('shaka.util.MediaReadyState'); goog.require('shaka.util.PublicPromise'); goog.require('shaka.util.StringUtils'); +goog.require('shaka.util.Platform'); /** @@ -35,10 +36,6 @@ shaka.polyfill.PatchedMediaKeysApple = class { return; } - /* Unprefixed EME disabled. See: - https://github.com/google/shaka-player/pull/3021#issuecomment-766999811 - - // Only tested in Safari 14. const safariVersion = shaka.util.Platform.safariVersion(); if (navigator.requestMediaKeySystemAccess && // eslint-disable-next-line no-restricted-syntax @@ -47,7 +44,6 @@ shaka.polyfill.PatchedMediaKeysApple = class { // Unprefixed EME is preferable. return; } - */ shaka.log.info('Using Apple-prefixed EME'); @@ -69,6 +65,8 @@ shaka.polyfill.PatchedMediaKeysApple = class { window.MediaKeySystemAccess = PatchedMediaKeysApple.MediaKeySystemAccess; navigator.requestMediaKeySystemAccess = PatchedMediaKeysApple.requestMediaKeySystemAccess; + + window.shakaMediaKeysPolyfill = true; } /** diff --git a/lib/polyfill/patchedmediakeys_ms.js b/lib/polyfill/patchedmediakeys_ms.js index 23197792fb..9e46313115 100644 --- a/lib/polyfill/patchedmediakeys_ms.js +++ b/lib/polyfill/patchedmediakeys_ms.js @@ -58,6 +58,8 @@ shaka.polyfill.PatchedMediaKeysMs = class { // eslint-disable-next-line no-restricted-syntax HTMLMediaElement.prototype.setMediaKeys = PatchedMediaKeysMs.MediaKeySystemAccess.setMediaKeys; + + window.shakaMediaKeysPolyfill = true; } /** diff --git a/lib/polyfill/patchedmediakeys_nop.js b/lib/polyfill/patchedmediakeys_nop.js index be6bfd3d09..5ba9266c50 100644 --- a/lib/polyfill/patchedmediakeys_nop.js +++ b/lib/polyfill/patchedmediakeys_nop.js @@ -49,6 +49,8 @@ shaka.polyfill.PatchedMediaKeysNop = class { // These are not usable, but allow Player.isBrowserSupported to pass. window.MediaKeys = PatchedMediaKeysNop.MediaKeys; window.MediaKeySystemAccess = PatchedMediaKeysNop.MediaKeySystemAccess; + + window.shakaMediaKeysPolyfill = true; } /** diff --git a/lib/polyfill/patchedmediakeys_webkit.js b/lib/polyfill/patchedmediakeys_webkit.js index 343868948e..4ec47e8ce2 100644 --- a/lib/polyfill/patchedmediakeys_webkit.js +++ b/lib/polyfill/patchedmediakeys_webkit.js @@ -72,6 +72,8 @@ shaka.polyfill.PatchedMediaKeysWebkit = class { PatchedMediaKeysWebkit.setMediaKeys; window.MediaKeys = PatchedMediaKeysWebkit.MediaKeys; window.MediaKeySystemAccess = PatchedMediaKeysWebkit.MediaKeySystemAccess; + + window.shakaMediaKeysPolyfill = true; } /** diff --git a/lib/util/error.js b/lib/util/error.js index aaea12326e..6cbd56f85a 100644 --- a/lib/util/error.js +++ b/lib/util/error.js @@ -686,6 +686,17 @@ shaka.util.Error.Code = { */ 'HLS_VARIABLE_NOT_FOUND': 4039, + /** + * We do not support playing encrypted mp2t with MSE + */ + 'HLS_MSE_ENCRYPTED_MP2T_NOT_SUPPORTED': 4040, + + /** + * We do not support playing encrypted content (different than mp2t) with MSE + * and legacy Apple MediaKeys API. + */ + 'HLS_MSE_ENCRYPTED_LEGACY_APPLE_MEDIA_KEYS_NOT_SUPPORTED': 4041, + // RETIRED: 'INCONSISTENT_BUFFER_STATE': 5000, // RETIRED: 'INVALID_SEGMENT_INDEX': 5001, diff --git a/lib/util/platform.js b/lib/util/platform.js index 040c547868..49dce396cf 100644 --- a/lib/util/platform.js +++ b/lib/util/platform.js @@ -228,6 +228,16 @@ shaka.util.Platform = class { return null; } + /** + * Check if the current platform is Apple Safari + * or Safari-based iOS browsers. + * + * @return {boolean} + */ + static isSafari() { + return !!shaka.util.Platform.safariVersion(); + } + /** * Guesses if the platform is a mobile one (iOS or Android). * @@ -318,6 +328,19 @@ shaka.util.Platform = class { const Platform = shaka.util.Platform; return Platform.isTizen() || Platform.isXboxOne(); } + + /** + * Returns true if MediaKeys is polyfilled + * + * @return {boolean} + */ + static isMediaKeysPolyfilled() { + if (window.shakaMediaKeysPolyfill) { + return true; + } + + return false; + } }; /** @private {shaka.util.Timer} */ diff --git a/test/hls/hls_parser_unit.js b/test/hls/hls_parser_unit.js index 25c38b1a1a..6c5afe95aa 100644 --- a/test/hls/hls_parser_unit.js +++ b/test/hls/hls_parser_unit.js @@ -2231,6 +2231,42 @@ describe('HlsParser', () => { await testHlsParser(master, media, manifest); }); + it('constructs DrmInfo for FairPlay', async () => { + const master = [ + '#EXTM3U\n', + '#EXT-X-STREAM-INF:BANDWIDTH=200,CODECS="avc1",', + 'RESOLUTION=960x540,FRAME-RATE=60\n', + 'video\n', + ].join(''); + + const media = [ + '#EXTM3U\n', + '#EXT-X-TARGETDURATION:6\n', + '#EXT-X-PLAYLIST-TYPE:VOD\n', + '#EXT-X-KEY:METHOD=SAMPLE-AES-CTR,', + 'KEYFORMAT="com.apple.streamingkeydelivery",', + 'URI="skd://f93d4e700d7ddde90529a27735d9e7cb",\n', + '#EXT-X-MAP:URI="init.mp4"\n', + '#EXTINF:5,\n', + '#EXT-X-BYTERANGE:121090@616\n', + 'main.mp4', + ].join(''); + + const manifest = shaka.test.ManifestGenerator.generate((manifest) => { + manifest.anyTimeline(); + manifest.addPartialVariant((variant) => { + variant.addPartialStream(ContentType.VIDEO, (stream) => { + stream.encrypted = true; + stream.addDrmInfo('com.apple.fps', (drmInfo) => { + drmInfo.addInitData('sinf', new Uint8Array(0)); + }); + }); + }); + }); + + await testHlsParser(master, media, manifest); + }); + it('falls back to mp4 if HEAD request fails', async () => { const master = [ '#EXTM3U\n', @@ -2427,6 +2463,34 @@ describe('HlsParser', () => { }); }); + it('if FairPlay encryption with MSE and mp2t content', async () => { + const master = [ + '#EXTM3U\n', + '#EXT-X-STREAM-INF:BANDWIDTH=200,CODECS="avc1",', + 'RESOLUTION=960x540,FRAME-RATE=60\n', + 'video\n', + ].join(''); + + const media = [ + '#EXTM3U\n', + '#EXT-X-TARGETDURATION:6\n', + '#EXT-X-PLAYLIST-TYPE:VOD\n', + '#EXT-X-KEY:METHOD=SAMPLE-AES-CTR,', + 'KEYFORMAT="com.apple.streamingkeydelivery",', + 'URI="skd://f93d4e700d7ddde90529a27735d9e7cb",\n', + '#EXTINF:5,\n', + '#EXT-X-BYTERANGE:121090@616\n', + 'main.ts', + ].join(''); + + const error = new shaka.util.Error( + shaka.util.Error.Severity.CRITICAL, + shaka.util.Error.Category.MANIFEST, + Code.HLS_MSE_ENCRYPTED_MP2T_NOT_SUPPORTED); + + await verifyError(master, media, error); + }); + describe('if required tags are missing', () => { /** * @param {string} master diff --git a/test/media/drm_engine_unit.js b/test/media/drm_engine_unit.js index 9b98f8314c..4851324c26 100644 --- a/test/media/drm_engine_unit.js +++ b/test/media/drm_engine_unit.js @@ -776,11 +776,6 @@ describe('DrmEngine', () => { expect(mockVideo.setMediaKeys).not.toHaveBeenCalled(); }); - it('sets MediaKeys for encrypted content', async () => { - await initAndAttach(); - expect(mockVideo.setMediaKeys).toHaveBeenCalledWith(mockMediaKeys); - }); - it('sets server certificate if present in config', async () => { const cert = new Uint8Array(1); config.advanced['drm.abc'] = createAdvancedConfig(cert); @@ -877,7 +872,7 @@ describe('DrmEngine', () => { /** @type {!Uint8Array} */ const initData1 = new Uint8Array(5); /** @type {!Uint8Array} */ - const initData2 = new Uint8Array(0); + const initData2 = new Uint8Array(1); /** @type {!Uint8Array} */ const initData3 = new Uint8Array(10); @@ -1019,12 +1014,23 @@ describe('DrmEngine', () => { mockVideo.setMediaKeys.and.returnValue(Promise.reject( new Error('whoops!'))); - const expected = Util.jasmineError(new shaka.util.Error( + tweakDrmInfos((drmInfos) => { + drmInfos[0].initData = [ + {initData: new Uint8Array(1), initDataType: 'cenc', keyId: null}, + ]; + }); + + onErrorSpy.and.stub(); + + await initAndAttach(); + + expect(onErrorSpy).toHaveBeenCalled(); + const error = onErrorSpy.calls.argsFor(0)[0]; + shaka.test.Util.expectToEqualError(error, new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.DRM, shaka.util.Error.Code.FAILED_TO_ATTACH_TO_VIDEO, 'whoops!')); - await expectAsync(initAndAttach()).toBeRejectedWith(expected); }); it('fails with an error if setServerCertificate fails', async () => { @@ -1087,10 +1093,8 @@ describe('DrmEngine', () => { const initData1 = new Uint8Array(1); const initData2 = new Uint8Array(2); - mockVideo.on['encrypted']( - {initDataType: 'webm', initData: initData1, keyId: null}); - mockVideo.on['encrypted']( - {initDataType: 'cenc', initData: initData2, keyId: null}); + await sendEncryptedEvent('webm', initData1); + await sendEncryptedEvent('cenc', initData2); expect(mockMediaKeys.createSession).toHaveBeenCalledTimes(2); expect(session1.generateRequest) @@ -1101,32 +1105,23 @@ describe('DrmEngine', () => { it('suppresses duplicate initDatas', async () => { await initAndAttach(); + const initData1 = new Uint8Array(1); - const initData2 = new Uint8Array(1); // identical to initData1 - mockVideo.on['encrypted']( - {initDataType: 'webm', initData: initData1, keyId: null}); - mockVideo.on['encrypted']( - {initDataType: 'cenc', initData: initData2, keyId: null}); + await sendEncryptedEvent('webm', initData1); + await sendEncryptedEvent('cenc'); // identical to webm initData expect(mockMediaKeys.createSession).toHaveBeenCalledTimes(1); expect(session1.generateRequest) .toHaveBeenCalledWith('webm', initData1); }); - it('is ignored when init data is in DrmInfo', async () => { - // Set up an init data override in the manifest: - tweakDrmInfos((drmInfos) => { - drmInfos[0].initData = [ - {initData: new Uint8Array(0), initDataType: 'cenc', keyId: null}, - ]; - }); - + it('set media keys when not already done at startup', async () => { await initAndAttach(); - // We already created a session for the init data override. + await sendEncryptedEvent(); + + expect(mockVideo.setMediaKeys).toHaveBeenCalledTimes(1); expect(mockMediaKeys.createSession).toHaveBeenCalledTimes(1); - // We aren't even listening for 'encrypted' events. - expect(mockVideo.on['encrypted']).toBe(undefined); }); it('dispatches an error if createSession fails', async () => { @@ -1134,9 +1129,7 @@ describe('DrmEngine', () => { onErrorSpy.and.stub(); await initAndAttach(); - const initData1 = new Uint8Array(1); - mockVideo.on['encrypted']( - {initDataType: 'webm', initData: initData1, keyId: null}); + await sendEncryptedEvent(); expect(onErrorSpy).toHaveBeenCalled(); const error = onErrorSpy.calls.argsFor(0)[0]; @@ -1156,9 +1149,7 @@ describe('DrmEngine', () => { onErrorSpy.and.stub(); await initAndAttach(); - const initData1 = new Uint8Array(1); - mockVideo.on['encrypted']( - {initDataType: 'webm', initData: initData1, keyId: null}); + await sendEncryptedEvent(); expect(onErrorSpy).toHaveBeenCalled(); const error = onErrorSpy.calls.argsFor(0)[0]; @@ -1172,9 +1163,7 @@ describe('DrmEngine', () => { describe('message', () => { it('is listened for', async () => { await initAndAttach(); - const initData = new Uint8Array(0); - mockVideo.on['encrypted']( - {initDataType: 'webm', initData: initData, keyId: null}); + await sendEncryptedEvent(); expect(session1.addEventListener).toHaveBeenCalledWith( 'message', jasmine.any(Function), jasmine.anything()); @@ -1214,9 +1203,7 @@ describe('DrmEngine', () => { onErrorSpy.and.stub(); await initAndAttach(); - const initData = new Uint8Array(0); - mockVideo.on['encrypted']( - {initDataType: 'webm', initData: initData, keyId: null}); + await sendEncryptedEvent(); // Simulate a permission error from the web server. const netError = new shaka.util.Error( @@ -1252,9 +1239,7 @@ describe('DrmEngine', () => { async function sendMessageTest( expectedUrl, messageType = 'license-request') { await initAndAttach(); - const initData = new Uint8Array(0); - mockVideo.on['encrypted']( - {initDataType: 'webm', initData: initData, keyId: null}); + await sendEncryptedEvent(); const operation = shaka.util.AbortableOperation.completed({}); fakeNetEngine.request.and.returnValue(operation); @@ -1276,9 +1261,7 @@ describe('DrmEngine', () => { describe('keystatuseschange', () => { it('is listened for', async () => { await initAndAttach(); - const initData = new Uint8Array(0); - mockVideo.on['encrypted']( - {initDataType: 'webm', initData: initData, keyId: null}); + await sendEncryptedEvent(); expect(session1.addEventListener).toHaveBeenCalledWith( 'keystatuseschange', jasmine.any(Function), jasmine.anything()); @@ -1286,9 +1269,7 @@ describe('DrmEngine', () => { it('triggers callback', async () => { await initAndAttach(); - const initData = new Uint8Array(0); - mockVideo.on['encrypted']( - {initDataType: 'webm', initData: initData, keyId: null}); + await sendEncryptedEvent(); const keyId1 = makeKeyId(1); const keyId2 = makeKeyId(2); @@ -1314,10 +1295,7 @@ describe('DrmEngine', () => { // See https://github.com/google/shaka-player/issues/1541 it('does not update public key statuses before callback', async () => { await initAndAttach(); - - const initData = new Uint8Array(0); - mockVideo.on['encrypted']( - {initDataType: 'webm', initData: initData, keyId: null}); + await sendEncryptedEvent(); const keyId1 = makeKeyId(1); const keyId2 = makeKeyId(2); @@ -1394,9 +1372,7 @@ describe('DrmEngine', () => { await initAndAttach(); expect(onErrorSpy).not.toHaveBeenCalled(); - const initData = new Uint8Array(0); - mockVideo.on['encrypted']( - {initDataType: 'webm', initData: initData, keyId: null}); + await sendEncryptedEvent(); const keyId1 = makeKeyId(1); const keyId2 = makeKeyId(2); @@ -1443,9 +1419,7 @@ describe('DrmEngine', () => { await initAndAttach(); expect(onErrorSpy).not.toHaveBeenCalled(); - const initData = new Uint8Array(0); - mockVideo.on['encrypted']( - {initDataType: 'webm', initData: initData, keyId: null}); + await sendEncryptedEvent(); const keyId1 = makeKeyId(1); const keyId2 = makeKeyId(2); @@ -1489,9 +1463,7 @@ describe('DrmEngine', () => { const license = new Uint8Array(0); await initAndAttach(); - const initData = new Uint8Array(0); - mockVideo.on['encrypted']( - {initDataType: 'webm', initData: initData, keyId: null}); + await sendEncryptedEvent(); fakeNetEngine.setResponseValue('http://abc.drm/license', license); const message = new Uint8Array(0); @@ -1550,9 +1522,8 @@ describe('DrmEngine', () => { it('publishes an event if update succeeds', async () => { await initAndAttach(); - const initData = new Uint8Array(1); - mockVideo.on['encrypted']( - {initDataType: 'webm', initData: initData, keyId: null}); + await sendEncryptedEvent(); + const message = new Uint8Array(0); session1.on['message']({target: session1, message: message}); session1.update.and.returnValue(Promise.resolve()); @@ -1568,9 +1539,7 @@ describe('DrmEngine', () => { const license = new Uint8Array(0); await initAndAttach(); - const initData = new Uint8Array(0); - mockVideo.on['encrypted']( - {initDataType: 'webm', initData: initData, keyId: null}); + await sendEncryptedEvent(); fakeNetEngine.setResponseValue('http://abc.drm/license', license); const message = new Uint8Array(0); @@ -1591,12 +1560,9 @@ describe('DrmEngine', () => { describe('destroy', () => { it('tears down MediaKeys and active sessions', async () => { await initAndAttach(); - const initData1 = new Uint8Array(1); - const initData2 = new Uint8Array(2); - mockVideo.on['encrypted']( - {initDataType: 'webm', initData: initData1, keyId: null}); - mockVideo.on['encrypted']( - {initDataType: 'webm', initData: initData2, keyId: null}); + + await sendEncryptedEvent('webm'); + await sendEncryptedEvent('cenc', new Uint8Array(2)); const message = new Uint8Array(0); session1.on['message']({target: session1, message: message}); @@ -1621,12 +1587,8 @@ describe('DrmEngine', () => { drmEngine.configure(config); await initAndAttach(); - const initData1 = new Uint8Array(1); - const initData2 = new Uint8Array(2); - mockVideo.on['encrypted']( - {initDataType: 'webm', initData: initData1, keyId: null}); - mockVideo.on['encrypted']( - {initDataType: 'webm', initData: initData2, keyId: null}); + await sendEncryptedEvent('webm'); + await sendEncryptedEvent('cenc', new Uint8Array(2)); const message = new Uint8Array(0); session1.on['message']({target: session1, message: message}); @@ -1647,12 +1609,8 @@ describe('DrmEngine', () => { it('swallows errors when closing sessions', async () => { await initAndAttach(); - const initData1 = new Uint8Array(1); - const initData2 = new Uint8Array(2); - mockVideo.on['encrypted']( - {initDataType: 'webm', initData: initData1, keyId: null}); - mockVideo.on['encrypted']( - {initDataType: 'webm', initData: initData2, keyId: null}); + await sendEncryptedEvent('webm'); + await sendEncryptedEvent('cenc', new Uint8Array(2)); const message = new Uint8Array(0); session1.on['message']({target: session1, message: message}); @@ -1668,12 +1626,8 @@ describe('DrmEngine', () => { it('swallows errors when clearing MediaKeys', async () => { await initAndAttach(); - const initData1 = new Uint8Array(1); - const initData2 = new Uint8Array(2); - mockVideo.on['encrypted']( - {initDataType: 'webm', initData: initData1, keyId: null}); - mockVideo.on['encrypted']( - {initDataType: 'webm', initData: initData2, keyId: null}); + await sendEncryptedEvent('webm'); + await sendEncryptedEvent('cenc', new Uint8Array(2)); const message = new Uint8Array(0); session1.on['message']({target: session1, message: message}); @@ -1752,6 +1706,14 @@ describe('DrmEngine', () => { const p1 = new shaka.util.PublicPromise(); mockVideo.setMediaKeys.and.returnValue(p1); + onErrorSpy.and.stub(); + + tweakDrmInfos((drmInfos) => { + drmInfos[0].initData = [ + {initData: new Uint8Array(1), initDataType: 'cenc', keyId: null}, + ]; + }); + const init = expectAsync(initAndAttach()).toBeRejected(); await shaka.test.Util.shortDelay(); @@ -1765,10 +1727,12 @@ describe('DrmEngine', () => { const destroy = drmEngine.destroy(); const fail = async () => { await shaka.test.Util.shortDelay(); - p1.reject(new Error('')); + shaka.log.warning('fail'); + p1.reject(new Error('titi')); }; const success = async () => { await shaka.test.Util.shortDelay(); + shaka.log.warning('success'); p2.resolve(); }; await Promise.all([init, destroy, fail(), success()]); @@ -1780,6 +1744,12 @@ describe('DrmEngine', () => { const p1 = new shaka.util.PublicPromise(); mockVideo.setMediaKeys.and.returnValue(p1); + tweakDrmInfos((drmInfos) => { + drmInfos[0].initData = [ + {initData: new Uint8Array(1), initDataType: 'cenc', keyId: null}, + ]; + }); + const init = expectAsync(initAndAttach()).toBeRejected(); await shaka.test.Util.shortDelay(); @@ -1857,9 +1827,7 @@ describe('DrmEngine', () => { session1.generateRequest.and.returnValue(p); await initAndAttach(); - const initData1 = new Uint8Array(1); - mockVideo.on['encrypted']( - {initDataType: 'webm', initData: initData1, keyId: null}); + await sendEncryptedEvent(); // We are now blocked on generateRequest: expect(session1.generateRequest).toHaveBeenCalledTimes(1); @@ -1878,9 +1846,7 @@ describe('DrmEngine', () => { fakeNetEngine.request.and.returnValue(operation); await initAndAttach(); - const initData1 = new Uint8Array(1); - mockVideo.on['encrypted']( - {initDataType: 'webm', initData: initData1, keyId: null}); + await sendEncryptedEvent(); const message = new Uint8Array(0); session1.on['message']({target: session1, message: message}); @@ -1909,9 +1875,7 @@ describe('DrmEngine', () => { fakeNetEngine.request.and.returnValue(operation); await initAndAttach(); - const initData1 = new Uint8Array(1); - mockVideo.on['encrypted']( - {initDataType: 'webm', initData: initData1, keyId: null}); + await sendEncryptedEvent(); const message = new Uint8Array(0); session1.on['message']({target: session1, message: message}); @@ -1939,9 +1903,7 @@ describe('DrmEngine', () => { session1.update.and.returnValue(p); await initAndAttach(); - const initData1 = new Uint8Array(1); - mockVideo.on['encrypted']( - {initDataType: 'webm', initData: initData1, keyId: null}); + await sendEncryptedEvent(); const message = new Uint8Array(0); session1.on['message']({target: session1, message: message}); @@ -1975,12 +1937,8 @@ describe('DrmEngine', () => { session1.close.and.returnValue(rejected); session2.close.and.returnValue(rejected); - const initData1 = new Uint8Array(1); - const initData2 = new Uint8Array(2); - mockVideo.on['encrypted']( - {initDataType: 'webm', initData: initData1, keyId: null}); - mockVideo.on['encrypted']( - {initDataType: 'webm', initData: initData2, keyId: null}); + await sendEncryptedEvent('webm'); + await sendEncryptedEvent('cenc', new Uint8Array(2)); // Still resolve these since we are mocking close and closed. This // ensures DrmEngine is in the correct state. @@ -2205,9 +2163,7 @@ describe('DrmEngine', () => { mockVideo.paused = true; await initAndAttach(); - const initData = new Uint8Array(0); - mockVideo.on['encrypted']( - {initDataType: 'webm', initData: initData, keyId: null}); + await sendEncryptedEvent(); const operation = shaka.util.AbortableOperation.completed({}); fakeNetEngine.request.and.returnValue(operation); @@ -2233,9 +2189,7 @@ describe('DrmEngine', () => { mockVideo.paused = true; await initAndAttach(); - const initData = new Uint8Array(0); - mockVideo.on['encrypted']( - {initDataType: 'webm', initData: initData, keyId: null}); + await sendEncryptedEvent(); const operation = shaka.util.AbortableOperation.completed({}); fakeNetEngine.request.and.returnValue(operation); @@ -2350,10 +2304,9 @@ describe('DrmEngine', () => { session1.expiration = NaN; await initAndAttach(); - const initData = new Uint8Array(0); + await sendEncryptedEvent(); + const message = new Uint8Array(0); - mockVideo.on['encrypted']( - {initDataType: 'webm', initData: initData, keyId: null}); session1.on['message']({target: session1, message: message}); session1.update.and.returnValue(Promise.resolve()); }); @@ -2516,4 +2469,17 @@ describe('DrmEngine', () => { callback(manifest.variants[0].audio.drmInfos); } } + + /** + * + * @param {string} initDataType + * @param {Uint8Array} initData + * @param {string|null} keyId + */ + async function sendEncryptedEvent( + initDataType = 'cenc', initData = new Uint8Array(1), keyId = null) { + mockVideo.on['encrypted']({initDataType, initData, keyId}); + + await Util.shortDelay(); + } }); diff --git a/test/test/util/manifest_generator.js b/test/test/util/manifest_generator.js index 89392d4712..be862d3889 100644 --- a/test/test/util/manifest_generator.js +++ b/test/test/util/manifest_generator.js @@ -433,7 +433,7 @@ shaka.test.ManifestGenerator.DrmInfo = class { if (!this.initData) { this.initData = []; } - this.initData.push({initData: buffer, initDataType: type, keyId: null}); + this.initData.push({initData: buffer, initDataType: type}); } /**