feat: add modern EME support for FairPlay (#3776)

Add support for HLS com.apple.streamingkeydelivery through MSE/EME implementation.

Close #3346

## Tests
Tested on:
- Mac 11.6 Safari 15.2
- iOS 15.2 Safari 15.2
- Mac 11.6 Chrome 96 (for potential regressions on Widevine keySystem)

| Mode | DRM API | TS | CMAF (mono-key and multi-keys)
|---|---|---|---|
| file | EME |   |   |
| file | Legacy-prefixed |    |    |
| media-source | EME | **mux-js**: `encrypted` never fired<br />**real MSE**: `encrypted` event received, but with incorrect `sinf` initData (*1)  |   |
| media-source | Legacy-prefixed | **mux-js**: `webkitneedkey` never fired<br/>**real MSE**: TBD  | 🔴 fails to append media segment to SourceBuffer (init segment ok) `(video:4) – "failed fetch and append: code=3015"` |

## Support table 
| Mode | DRM API | TS | CMAF (mono-key and multi-keys)
|---|---|---|---|
| file | EME |   |   |
| file | Legacy-prefixed |    |    |
| media-source | EME | 🚫 `4040: HLS_MSE_ENCRYPTED_MP2T_NOT_SUPPORTED`  |   |
| media-source | Legacy-prefixed | 🚫 `4041: HLS_MSE_ENCRYPTED_LEGACY_APPLE_MEDIA_KEYS_NOT_SUPPORTED`  |🚫 `4041: HLS_MSE_ENCRYPTED_LEGACY_APPLE_MEDIA_KEYS_NOT_SUPPORTED` |

⚠️ Use EME APIs with multi-keys CMAF makes the video stalling with the audio continuing alone after a short time (~3 minutes in the stream, could be shorter, could be longer). Didn't find an explanation to that yet. I've observed the same behaviour with hls.js code so I don't think this is a player issue.
This commit is contained in:
Vincent Valot
2022-02-07 20:17:22 +01:00
committed by GitHub
parent 7155ec73bc
commit 6d76a135e5
16 changed files with 396 additions and 186 deletions
+3 -1
View File
@@ -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
+5
View File
@@ -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.
+11 -5
View File
@@ -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:
+15
View File
@@ -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;
+3 -2
View File
@@ -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
+53 -19
View File
@@ -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.<!shaka.hls.Tag>} */
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.<!shaka.hls.Tag>} */
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.<!shaka.hls.Tag>} */
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':
+111 -32
View File
@@ -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);
+3 -5
View File
@@ -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;
}
/**
+2
View File
@@ -58,6 +58,8 @@ shaka.polyfill.PatchedMediaKeysMs = class {
// eslint-disable-next-line no-restricted-syntax
HTMLMediaElement.prototype.setMediaKeys =
PatchedMediaKeysMs.MediaKeySystemAccess.setMediaKeys;
window.shakaMediaKeysPolyfill = true;
}
/**
+2
View File
@@ -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;
}
/**
+2
View File
@@ -72,6 +72,8 @@ shaka.polyfill.PatchedMediaKeysWebkit = class {
PatchedMediaKeysWebkit.setMediaKeys;
window.MediaKeys = PatchedMediaKeysWebkit.MediaKeys;
window.MediaKeySystemAccess = PatchedMediaKeysWebkit.MediaKeySystemAccess;
window.shakaMediaKeysPolyfill = true;
}
/**
+11
View File
@@ -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,
+23
View File
@@ -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} */
+64
View File
@@ -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
+87 -121
View File
@@ -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});
const initData1 = new Uint8Array(1);
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();
}
});
+1 -1
View File
@@ -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});
}
/**