Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve EME Controller: Add PlayReady support #1918

Closed
wants to merge 25 commits into from
Closed
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
4fb5fdb
add support for pssh in manifest
ssreed Sep 13, 2018
b1e50f8
move util function into it's own file
ssreed Sep 13, 2018
3b423f5
add docs, clean up names
ssreed Sep 13, 2018
5d2a4ac
add unit tests
ssreed Sep 13, 2018
fef1409
add support for multi-drm manifests
ssreed Sep 15, 2018
6713568
add docs
ssreed Sep 15, 2018
294d8af
add playready support
ssreed Sep 15, 2018
8236809
clean up
ssreed Sep 15, 2018
d77f6d0
move playready header logic to separate function
ssreed Sep 17, 2018
d04049e
update playready challenge logic
ssreed Sep 17, 2018
685e6df
fix: use for loop instead of forEach
ssreed Sep 17, 2018
7c670cc
move playready header logic to separate file
ssreed Sep 19, 2018
0401518
fix typo
ssreed Sep 19, 2018
00025d1
don't initialize emeController if eme isn't enabled
ssreed Sep 20, 2018
09d9660
simplify playready headers
ssreed Oct 26, 2018
a9d9e8f
clarify and update function name to more accurately reflect returned …
ssreed Nov 9, 2018
1c9021f
Merge branch 'master' of https://github.com/video-dev/hls.js into add…
ssreed Nov 9, 2018
56a357f
optimize and improve readability of capturing initData
ssreed Nov 10, 2018
ca9bcc3
fix unit test
ssreed Nov 10, 2018
49e22ca
remove unnecessary code
ssreed Nov 10, 2018
897257d
add documentation, add initDataTypes enum
ssreed Nov 13, 2018
177b1e7
fix functional test
ssreed Nov 13, 2018
61494eb
add key rotation support
Jan 23, 2019
32215cc
Merge branch 'master' into add-playready
ssreed Jan 28, 2019
e840bfb
fix unit test
ssreed Jan 28, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
46 changes: 46 additions & 0 deletions docs/API.md
Expand Up @@ -74,6 +74,11 @@
- [`abrBandWidthUpFactor`](#abrbandwidthupfactor)
- [`abrMaxWithRealBitrate`](#abrmaxwithrealbitrate)
- [`minAutoBitrate`](#minautobitrate)
- [`emeEnabled`](#emeenabled)
- [`licenseXhrSetup`](#licensexhrsetup)
- [`drmSystem`](#drmsystem)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i m not sure we need that, or we need something different in terms of API.

at least you need to expose an enum on the API with the possible values, and/or document what values

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There could be a better way to do this but I was leaving it up to the consumer to figure out what type of DRM system is supported for a particular environment. So for example, if there was a manifest that is multi DRM, the client can choose their preferred system based on the browser/CDM.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but as a common purpose project, our responsability is finding common denominator solutions with future proof APIs that will cover many possible use-cases.

For example what we should do is allow to configure a preference priority-list of DRM systems. Then, the key-systems are tried for existence on the platform in that order.

Copy link
Collaborator

@tchakabam tchakabam Sep 28, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Btw such a config parameter is optional, isn'it? :) We can figure out inside our code if there is a DRM system that fits the ones in the media manifest.

So, what we really want in the end is to set a preferred system (or a prio list of), and the values in there would be the values of the key-system enum, right?

- [`widevineLicenseUrl`](#widevinelicenseurl)
- [`playreadyLicenseUrl`](#playreadylicenseurl)
- [Video Binding/Unbinding API](#video-bindingunbinding-api)
- [`hls.attachMedia(videoElement)`](#hlsattachmediavideoelement)
- [`hls.detachMedia()`](#hlsdetachmedia)
Expand Down Expand Up @@ -964,6 +969,47 @@ then if config value is set to `true`, ABR will use 2.5 Mb/s for this quality le
Return the capping/min bandwidth value that could be used by automatic level selection algorithm.
Useful when browser or tab of the browser is not in the focus and bandwidth drops

### `emeEnabled`

(default: `false`)

Whether or not to enable eme.

### `licenseXhrSetup`

(default: `undefined`)

`XMLHttpRequest` customization callback for default XHR based loader.

Parameter should be a function with two arguments `(xhr: XMLHttpRequest, url: string)`.
If `licenseXhrSetup` is specified, default loader will invoke it before calling `xhr.send()`.
This allows user to easily modify/setup XHR. See example below.

```js
var config = {
licenseXhrSetup: function(xhr, url) {
xhr.setRequestHeader('Content-Type', 'text/xml; charset=utf-8'); //indicate resource is xml
}
}
```

### `drmSystem`

(default: `undefined`)

Which DRM system use. (`WIDEVINE` or `PLAYREADY`)

### `widevineLicenseUrl`

(default: `undefined`)

Specify widevine license url.

### `playreadyLicenseUrl`

(default: `undefined`)

Specify playready license url.

## Video Binding/Unbinding API

Expand Down
2 changes: 2 additions & 0 deletions src/config.js
Expand Up @@ -89,6 +89,8 @@ export var hlsDefaultConfig = {
minAutoBitrate: 0, // used by hls
emeEnabled: false, // used by eme-controller
widevineLicenseUrl: undefined, // used by eme-controller
drmSystem: undefined, // used by eme-controller
playreadyLicenseUrl: undefined, // used by eme-controller
requestMediaKeySystemAccessFunc:
requestMediaKeySystemAccess // used by eme-controller
};
Expand Down
126 changes: 92 additions & 34 deletions src/controller/eme-controller.js
Expand Up @@ -7,10 +7,10 @@
import EventHandler from '../event-handler';
import Event from '../events';
import { ErrorTypes, ErrorDetails } from '../errors';

import { base64ToArrayBuffer, buildPlayReadyPSSHBox } from '../utils/eme-helper';
import { logger } from '../utils/logger';

const { XMLHttpRequest } = window;
const { XMLHttpRequest, atob, DOMParser } = window;
ssreed marked this conversation as resolved.
Show resolved Hide resolved

const MAX_LICENSE_REQUEST_FAILURES = 3;

Expand All @@ -22,6 +22,11 @@ const KeySystems = {
PLAYREADY: 'com.microsoft.playready'
};

const DRMIdentifiers = {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not use these values for drmSystem and expose them on public API?

WIDEVINE: 'urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed',
PLAYREADY: 'com.microsoft.playready'
};

/**
* @see https://developer.mozilla.org/en-US/docs/Web/API/MediaKeySystemConfiguration
* @param {Array<string>} audioCodecs List of required audio codecs to support
Expand Down Expand Up @@ -67,6 +72,7 @@ const createWidevineMediaKeySystemConfigurations = function (audioCodecs, videoC
const getSupportedMediaKeySystemConfigurations = function (keySystem, audioCodecs, videoCodecs) {
switch (keySystem) {
case KeySystems.WIDEVINE:
case KeySystems.PLAYREADY:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if this the same proc, we could rename createWidevineMediaKeySystemConfigurations to reflect it is not widevine specific?

return createWidevineMediaKeySystemConfigurations(audioCodecs, videoCodecs);
default:
throw Error('Unknown key-system: ' + keySystem);
Expand All @@ -88,12 +94,16 @@ class EMEController extends EventHandler {
constructor (hls) {
super(hls,
Event.MEDIA_ATTACHED,
Event.MANIFEST_PARSED
Event.MANIFEST_PARSED,
Event.LEVEL_LOADED,
Event.FRAG_LOADED
);

this._widevineLicenseUrl = hls.config.widevineLicenseUrl;
this._playreadyLicenseUrl = hls.config.playreadyLicenseUrl;
this._licenseXhrSetup = hls.config.licenseXhrSetup;
this._emeEnabled = hls.config.emeEnabled;
this._selectedDrm = hls.config.drmSystem;

this._requestMediaKeySystemAccess = hls.config.requestMediaKeySystemAccessFunc;

Expand All @@ -104,6 +114,9 @@ class EMEController extends EventHandler {
this._isMediaEncrypted = false;

this._requestLicenseFailureCount = 0;

this._initData = null;
ssreed marked this conversation as resolved.
Show resolved Hide resolved
this._initDataType = '';
ssreed marked this conversation as resolved.
Show resolved Hide resolved
}

/**
Expand All @@ -117,6 +130,9 @@ class EMEController extends EventHandler {
case KeySystems.WIDEVINE:
url = this._widevineLicenseUrl;
break;
case KeySystems.PLAYREADY:
url = this._playreadyLicenseUrl;
break;
default:
url = null;
break;
Expand Down Expand Up @@ -195,7 +211,6 @@ class EMEController extends EventHandler {
mediaKeysListItem.mediaKeys = mediaKeys;

logger.log(`Media-keys created for key-system "${keySystem}"`);

this._onMediaKeysCreated();
})
.catch((err) => {
Expand All @@ -212,6 +227,7 @@ class EMEController extends EventHandler {
this._mediaKeysList.forEach((mediaKeysListItem) => {
if (!mediaKeysListItem.mediaKeysSession) {
mediaKeysListItem.mediaKeysSession = mediaKeysListItem.mediaKeys.createSession();
this._haveKeySession = true;
this._onNewMediaKeySession(mediaKeysListItem.mediaKeysSession);
}
});
Expand Down Expand Up @@ -240,7 +256,6 @@ class EMEController extends EventHandler {

_onMediaEncrypted (initDataType, initData) {
logger.log(`Media is encrypted using "${initDataType}" init data type`);

this._isMediaEncrypted = true;
this._mediaEncryptionInitDataType = initDataType;
this._mediaEncryptionInitData = initData;
Expand Down Expand Up @@ -356,7 +371,7 @@ class EMEController extends EventHandler {

xhr.responseType = 'arraybuffer';
xhr.onreadystatechange =
this._onLicenseRequestReadyStageChange.bind(this, xhr, url, keyMessage, callback);
this._onLicenseRequestReadyStageChange.bind(this, xhr, url, keyMessage, callback);
return xhr;
}

Expand Down Expand Up @@ -400,32 +415,30 @@ class EMEController extends EventHandler {
* @param {ArrayBuffer} keyMessage
* @returns {ArrayBuffer} Challenge data posted to license server
*/
_generateLicenseRequestChallenge (keysListItem, keyMessage) {
_generateLicenseRequestChallenge (keysListItem, keyMessage, xhr) {
ssreed marked this conversation as resolved.
Show resolved Hide resolved
let challenge;

if (keysListItem.mediaKeySystemDomain === KeySystems.PLAYREADY) {
logger.error('PlayReady is not supported (yet)');
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

was firmly believing we'd remove this line one day 👍


// from https://github.com/MicrosoftEdge/Demos/blob/master/eme/scripts/demo.js
/*
if (this.licenseType !== this.LICENSE_TYPE_WIDEVINE) {
// For PlayReady CDMs, we need to dig the Challenge out of the XML.
var keyMessageXml = new DOMParser().parseFromString(String.fromCharCode.apply(null, new Uint16Array(keyMessage)), 'application/xml');
if (keyMessageXml.getElementsByTagName('Challenge')[0]) {
challenge = atob(keyMessageXml.getElementsByTagName('Challenge')[0].childNodes[0].nodeValue);
} else {
throw 'Cannot find <Challenge> in key message';
}
var headerNames = keyMessageXml.getElementsByTagName('name');
var headerValues = keyMessageXml.getElementsByTagName('value');
if (headerNames.length !== headerValues.length) {
throw 'Mismatched header <name>/<value> pair in key message';
}
for (var i = 0; i < headerNames.length; i++) {
xhr.setRequestHeader(headerNames[i].childNodes[0].nodeValue, headerValues[i].childNodes[0].nodeValue);
}
}
*/
// For PlayReady CDMs, we need to dig the Challenge out of the XML.
const keyMessageXml = new DOMParser().parseFromString(String.fromCharCode.apply(null, new Uint16Array(keyMessage)), 'application/xml');

if (keyMessageXml.getElementsByTagName('Challenge')[0]) {
challenge = atob(keyMessageXml.getElementsByTagName('Challenge')[0].childNodes[0].nodeValue);
ssreed marked this conversation as resolved.
Show resolved Hide resolved
} else {
throw Error('Cannot find <Challenge> in key message');
}

const headerNames = keyMessageXml.getElementsByTagName('name');
const headerValues = keyMessageXml.getElementsByTagName('value');

if (headerNames.length !== headerValues.length) {
throw Error('Mismatched header <name>/<value> pair in key message');
}

for (let i = 0; i < headerNames.length; i++) {
xhr.setRequestHeader(headerNames[i].childNodes[0].nodeValue, headerValues[i].childNodes[0].nodeValue);
}
} else if (keysListItem.mediaKeySystemDomain === KeySystems.WIDEVINE) {
// For Widevine CDMs, the challenge is the keyMessage.
challenge = keyMessage;
Expand Down Expand Up @@ -455,7 +468,7 @@ class EMEController extends EventHandler {

logger.log(`Sending license request to URL: ${url}`);

xhr.send(this._generateLicenseRequestChallenge(keysListItem, keyMessage));
xhr.send(this._generateLicenseRequestChallenge(keysListItem, keyMessage, xhr));
}

onMediaAttached (data) {
Expand All @@ -469,10 +482,11 @@ class EMEController extends EventHandler {
this._media = media;

// FIXME: also handle detaching media !

media.addEventListener('encrypted', (e) => {
this._onMediaEncrypted(e.initDataType, e.initData);
});
if (!this._hasSetMediaKeys) {
media.addEventListener('encrypted', (e) => {
this._onMediaEncrypted(e.initDataType, e.initData);
});
}
}

onManifestParsed (data) {
Expand All @@ -483,7 +497,51 @@ class EMEController extends EventHandler {
const audioCodecs = data.levels.map((level) => level.audioCodec);
const videoCodecs = data.levels.map((level) => level.videoCodec);

this._attemptKeySystemAccess(KeySystems.WIDEVINE, audioCodecs, videoCodecs);
this._attemptKeySystemAccess(KeySystems[this._selectedDrm], audioCodecs, videoCodecs);
}

onFragLoaded () {
if (!this._emeEnabled) {
return;
}

// add initData and type if they are included in playlist
if (this._initData && !this._hasSetMediaKeys && this._haveKeySession) {
this._onMediaEncrypted(this._initDataType, this._initData);
}
}

/**
* @param {object} data
*/
onLevelLoaded (data) {
if (!this._emeEnabled) {
ssreed marked this conversation as resolved.
Show resolved Hide resolved
return;
}

if (data.details && data.details.drmInfo && data.details.drmInfo.length > 0) {
const drmInfo = data.details.drmInfo;

drmInfo.forEach((levelkey) => {
const details = levelkey.reluri.split(',');
const encoding = details[0];
const pssh = details[1];

if (levelkey.format === DRMIdentifiers[this._selectedDrm]) {
ssreed marked this conversation as resolved.
Show resolved Hide resolved
if (encoding.includes('base64')) {
if (DRMIdentifiers[this._selectedDrm] === 'com.microsoft.playready') {
this._initData = buildPlayReadyPSSHBox(base64ToArrayBuffer(pssh)); // Playready is particular about the pssh box, so it needs to be handcrafted.
} else if (DRMIdentifiers[this._selectedDrm] === 'urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed') {
this._initData = base64ToArrayBuffer(pssh); // Widevine pssh box
} else {
logger.log('not supported');
}
}

this._initDataType = 'cenc';
ssreed marked this conversation as resolved.
Show resolved Hide resolved
}
});
}
}
}

Expand Down
11 changes: 8 additions & 3 deletions src/loader/m3u8-parser.js
Expand Up @@ -154,6 +154,7 @@ export default class M3U8Parser {
let frag = new Fragment();
let result;
let i;
let drmInfo = [];
ssreed marked this conversation as resolved.
Show resolved Hide resolved

let firstPdtIndex = null;

Expand All @@ -166,7 +167,7 @@ export default class M3U8Parser {
// avoid sliced strings https://github.com/video-dev/hls.js/issues/939
const title = (' ' + result[2]).slice(1);
frag.title = title || null;
frag.tagList.push(title ? [ 'INF', duration, title ] : [ 'INF', duration ]);
frag.tagList.push(title ? ['INF', duration, title] : ['INF', duration]);
} else if (result[3]) { // url
if (Number.isFinite(frag.duration)) {
const sn = currentSN++;
Expand Down Expand Up @@ -217,7 +218,7 @@ export default class M3U8Parser {

switch (result[i]) {
case '#':
frag.tagList.push(value2 ? [ value1, value2 ] : [ value1 ]);
frag.tagList.push(value2 ? [value1, value2] : [value1]);
break;
case 'PLAYLIST-TYPE':
level.type = value1.toUpperCase();
Expand Down Expand Up @@ -252,15 +253,18 @@ export default class M3U8Parser {
decryptiv = keyAttrs.hexadecimalInteger('IV');
if (decryptmethod) {
levelkey = new LevelKey();
if ((decrypturi) && (['AES-128', 'SAMPLE-AES', 'SAMPLE-AES-CENC'].indexOf(decryptmethod) >= 0)) {
if ((decrypturi) && (['AES-128', 'SAMPLE-AES', 'SAMPLE-AES-CENC', 'SAMPLE-AES-CTR'].indexOf(decryptmethod) >= 0)) {
levelkey.method = decryptmethod;
// URI to get the key
levelkey.baseuri = baseurl;
levelkey.reluri = decrypturi;
levelkey.key = null;
levelkey.format = keyAttrs.KEYFORMAT;
// Initialization Vector (IV)
levelkey.iv = decryptiv;
}

drmInfo.push(levelkey);
}
break;
case 'START':
Expand Down Expand Up @@ -302,6 +306,7 @@ export default class M3U8Parser {
level.endSN = currentSN - 1;
level.startCC = level.fragments[0] ? level.fragments[0].cc : 0;
level.endCC = cc;
level.drmInfo = drmInfo;

if (!level.initSegment && level.fragments.length) {
// this is a bit lurky but HLS really has no other way to tell us
Expand Down