4761d623 by David LaPalomento

Parse key tags out of M3U8s

Enhance ParseStream to recognize and parse #EXT-X-KEY lines. Update Parser to track a currently active key and annotate each segment with the parsed key information. Added test cases and an example encrypted playlist.
1 parent 68cfe65a
/**
* Utilities for parsing M3U8 files. If the entire manifest is available,
* `Parser` will create a object representation with enough detail for managing
* `Parser` will create an object representation with enough detail for managing
* playback. `ParseStream` and `LineStream` are lower-level parsing primitives
* that do not assume the entirety of the manifest is ready and expose a
* ReadableStream-like interface.
......@@ -8,27 +8,41 @@
(function(videojs, parseInt, isFinite, mergeOptions, undefined) {
var
noop = function() {},
// "forgiving" attribute list psuedo-grammar:
// attributes -> keyvalue (',' keyvalue)*
// keyvalue -> key '=' value
// key -> [^=]*
// value -> '"' [^"]* '"' | [^,]*
attributeSeparator = (function() {
var
key = '[^=]*',
value = '"[^"]*"|[^,]*',
keyvalue = '(?:' + key + ')=(?:' + value + ')';
return new RegExp('(?:^|,)(' + keyvalue + ')');
})(),
parseAttributes = function(attributes) {
var
attrs = attributes.split(','),
// split the string using attributes as the separator
attrs = attributes.split(attributeSeparator),
i = attrs.length,
result = {},
attr;
while (i--) {
attr = attrs[i].split('=');
attr[0] = attr[0].replace(/^\s+|\s+$/g, '');
// This is not sexy, but gives us the resulting object we want.
if (attr[1]) {
attr[1] = attr[1].replace(/^\s+|\s+$/g, '');
if (attr[1].indexOf('"') !== -1) {
attr[1] = attr[1].split('"')[1];
}
result[attr[0]] = attr[1];
} else {
attrs[i - 1] = attrs[i - 1] + ',' + attr[0];
// filter out unmatched portions of the string
if (attrs[i] === '') {
continue;
}
// split the key and value
attr = /([^=]*)=(.*)/.exec(attrs[i]).slice(1);
// trim whitespace and remove optional quotes around the value
attr[0] = attr[0].replace(/^\s+|\s+$/g, '');
attr[1] = attr[1].replace(/^\s+|\s+$/g, '');
attr[1] = attr[1].replace(/^['"](.*)['"]$/g, '$1');
result[attr[0]] = attr[1];
}
return result;
},
......@@ -281,6 +295,27 @@
});
return;
}
match = (/^#EXT-X-KEY:?(.*)$/).exec(line);
if (match) {
event = {
type: 'tag',
tagType: 'key'
};
if (match[1]) {
event.attributes = parseAttributes(match[1]);
// parse the IV string into a Uint32Array
if (event.attributes.IV) {
event.attributes.IV = event.attributes.IV.match(/.{8}/g);
event.attributes.IV[0] = parseInt(event.attributes.IV[0], 16);
event.attributes.IV[1] = parseInt(event.attributes.IV[1], 16);
event.attributes.IV[2] = parseInt(event.attributes.IV[2], 16);
event.attributes.IV[3] = parseInt(event.attributes.IV[3], 16);
event.attributes.IV = new Uint32Array(event.attributes.IV);
}
}
this.trigger('data', event);
return;
}
// unknown tag type
this.trigger('data', {
......@@ -311,7 +346,8 @@
var
self = this,
uris = [],
currentUri = {};
currentUri = {},
key;
Parser.prototype.init.call(this);
this.lineStream = new LineStream();
......@@ -373,6 +409,36 @@
this.manifest.segments = uris;
},
'key': function() {
if (!entry.attributes) {
this.trigger('warn', {
message: 'ignoring key declaration without attribute list'
});
return;
}
// clear the active encryption key
if (entry.attributes.METHOD === 'NONE') {
key = null;
return;
}
if (!entry.attributes.URI) {
this.trigger('warn', {
message: 'ignoring key declaration without URI'
});
return;
}
if (!entry.attributes.METHOD) {
this.trigger('warn', {
message: 'defaulting key method to AES-128'
});
}
// setup an encryption key for upcoming segments
key = {
method: entry.attributes.METHOD || 'AES-128',
uri: entry.attributes.URI
};
},
'media-sequence': function() {
if (!isFinite(entry.number)) {
this.trigger('warn', {
......@@ -442,6 +508,10 @@
});
currentUri.duration = this.manifest.targetDuration;
}
// annotate with encryption information, if necessary
if (key) {
currentUri.key = key;
}
// prepare for the next URI
currentUri = {};
......
......@@ -512,6 +512,94 @@
strictEqual(element.tagType, 'endlist', 'the tag type is stream-inf');
});
// #EXT-X-KEY
test('parses valid #EXT-X-KEY tags', function() {
var
manifest = '#EXT-X-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=52"\n',
element;
parseStream.on('data', function(elem) {
element = elem;
});
lineStream.push(manifest);
ok(element, 'an event was triggered');
deepEqual(element, {
type: 'tag',
tagType: 'key',
attributes: {
METHOD: 'AES-128',
URI: 'https://priv.example.com/key.php?r=52'
}
}, 'parsed a valid key');
manifest = '#EXT-X-KEY:URI="https://example.com/key#1",METHOD=FutureType-1024\n';
lineStream.push(manifest);
ok(element, 'an event was triggered');
deepEqual(element, {
type: 'tag',
tagType: 'key',
attributes: {
METHOD: 'FutureType-1024',
URI: 'https://example.com/key#1'
}
}, 'parsed the attribute list independent of order');
manifest = '#EXT-X-KEY:IV=1234567890abcdef1234567890abcdef\n';
lineStream.push(manifest);
ok(element.attributes.IV, 'detected an IV attribute');
deepEqual(element.attributes.IV, new Uint32Array([
0x12345678,
0x90abcdef,
0x12345678,
0x90abcdef
]), 'parsed an IV value');
});
test('parses minimal #EXT-X-KEY tags', function() {
var
manifest = '#EXT-X-KEY:\n',
element;
parseStream.on('data', function(elem) {
element = elem;
});
lineStream.push(manifest);
ok(element, 'an event was triggered');
deepEqual(element, {
type: 'tag',
tagType: 'key'
}, 'parsed a minimal key tag');
});
test('parses lightly-broken #EXT-X-KEY tags', function() {
var
manifest = '#EXT-X-KEY:URI=\'https://example.com/single-quote\',METHOD=AES-128\n',
element;
parseStream.on('data', function(elem) {
element = elem;
});
lineStream.push(manifest);
strictEqual(element.attributes.URI,
'https://example.com/single-quote',
'parsed a single-quoted uri');
element = null;
manifest = '#EXT-X-KEYURI="https://example.com/key",METHOD=AES-128\n';
lineStream.push(manifest);
strictEqual(element.tagType, 'key', 'parsed the tag type');
strictEqual(element.attributes.URI,
'https://example.com/key',
'inferred a colon after the tag type');
element = null;
manifest = '#EXT-X-KEY: URI = "https://example.com/key",METHOD=AES-128\n';
lineStream.push(manifest);
strictEqual(element.attributes.URI,
'https://example.com/key',
'trims and removes quotes around the URI');
});
test('ignores empty lines', function() {
var
manifest = '\n',
......
{
"allowCache": true,
"mediaSequence": 7794,
"segments": [
{
"duration": 2.833,
"key": {
"method": "AES-128",
"uri": "https://priv.example.com/key.php?r=52"
},
"uri": "http://media.example.com/fileSequence52-A.ts"
},
{
"duration": 15,
"key": {
"method": "AES-128",
"uri": "https://priv.example.com/key.php?r=52"
},
"uri": "http://media.example.com/fileSequence52-B.ts"
},
{
"duration": 13.333,
"key": {
"method": "AES-128",
"uri": "https://priv.example.com/key.php?r=52"
},
"uri": "http://media.example.com/fileSequence52-C.ts"
},
{
"duration": 15,
"key": {
"method": "AES-128",
"uri": "https://priv.example.com/key.php?r=53"
},
"uri": "http://media.example.com/fileSequence53-A.ts"
},
{
"duration": 15,
"uri": "http://media.example.com/fileSequence53-B.ts"
}
],
"targetDuration": 15
}
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-MEDIA-SEQUENCE:7794
#EXT-X-TARGETDURATION:15
#EXT-X-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=52"
#EXTINF:2.833,
http://media.example.com/fileSequence52-A.ts
#EXTINF:15.0,
http://media.example.com/fileSequence52-B.ts
#EXTINF:13.333,
http://media.example.com/fileSequence52-C.ts
#EXT-X-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=53"
#EXTINF:15.0,
http://media.example.com/fileSequence53-A.ts
#EXT-X-KEY:METHOD=NONE
#EXTINF:15.0,
http://media.example.com/fileSequence53-B.ts
\ No newline at end of file