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
1 /** 1 /**
2 * Utilities for parsing M3U8 files. If the entire manifest is available, 2 * Utilities for parsing M3U8 files. If the entire manifest is available,
3 * `Parser` will create a object representation with enough detail for managing 3 * `Parser` will create an object representation with enough detail for managing
4 * playback. `ParseStream` and `LineStream` are lower-level parsing primitives 4 * playback. `ParseStream` and `LineStream` are lower-level parsing primitives
5 * that do not assume the entirety of the manifest is ready and expose a 5 * that do not assume the entirety of the manifest is ready and expose a
6 * ReadableStream-like interface. 6 * ReadableStream-like interface.
...@@ -8,27 +8,41 @@ ...@@ -8,27 +8,41 @@
8 (function(videojs, parseInt, isFinite, mergeOptions, undefined) { 8 (function(videojs, parseInt, isFinite, mergeOptions, undefined) {
9 var 9 var
10 noop = function() {}, 10 noop = function() {},
11
12 // "forgiving" attribute list psuedo-grammar:
13 // attributes -> keyvalue (',' keyvalue)*
14 // keyvalue -> key '=' value
15 // key -> [^=]*
16 // value -> '"' [^"]* '"' | [^,]*
17 attributeSeparator = (function() {
18 var
19 key = '[^=]*',
20 value = '"[^"]*"|[^,]*',
21 keyvalue = '(?:' + key + ')=(?:' + value + ')';
22
23 return new RegExp('(?:^|,)(' + keyvalue + ')');
24 })(),
11 parseAttributes = function(attributes) { 25 parseAttributes = function(attributes) {
12 var 26 var
13 attrs = attributes.split(','), 27 // split the string using attributes as the separator
28 attrs = attributes.split(attributeSeparator),
14 i = attrs.length, 29 i = attrs.length,
15 result = {}, 30 result = {},
16 attr; 31 attr;
17 32
18 while (i--) { 33 while (i--) {
19 attr = attrs[i].split('='); 34 // filter out unmatched portions of the string
20 attr[0] = attr[0].replace(/^\s+|\s+$/g, ''); 35 if (attrs[i] === '') {
36 continue;
37 }
21 38
22 // This is not sexy, but gives us the resulting object we want. 39 // split the key and value
23 if (attr[1]) { 40 attr = /([^=]*)=(.*)/.exec(attrs[i]).slice(1);
41 // trim whitespace and remove optional quotes around the value
42 attr[0] = attr[0].replace(/^\s+|\s+$/g, '');
24 attr[1] = attr[1].replace(/^\s+|\s+$/g, ''); 43 attr[1] = attr[1].replace(/^\s+|\s+$/g, '');
25 if (attr[1].indexOf('"') !== -1) { 44 attr[1] = attr[1].replace(/^['"](.*)['"]$/g, '$1');
26 attr[1] = attr[1].split('"')[1];
27 }
28 result[attr[0]] = attr[1]; 45 result[attr[0]] = attr[1];
29 } else {
30 attrs[i - 1] = attrs[i - 1] + ',' + attr[0];
31 }
32 } 46 }
33 return result; 47 return result;
34 }, 48 },
...@@ -281,6 +295,27 @@ ...@@ -281,6 +295,27 @@
281 }); 295 });
282 return; 296 return;
283 } 297 }
298 match = (/^#EXT-X-KEY:?(.*)$/).exec(line);
299 if (match) {
300 event = {
301 type: 'tag',
302 tagType: 'key'
303 };
304 if (match[1]) {
305 event.attributes = parseAttributes(match[1]);
306 // parse the IV string into a Uint32Array
307 if (event.attributes.IV) {
308 event.attributes.IV = event.attributes.IV.match(/.{8}/g);
309 event.attributes.IV[0] = parseInt(event.attributes.IV[0], 16);
310 event.attributes.IV[1] = parseInt(event.attributes.IV[1], 16);
311 event.attributes.IV[2] = parseInt(event.attributes.IV[2], 16);
312 event.attributes.IV[3] = parseInt(event.attributes.IV[3], 16);
313 event.attributes.IV = new Uint32Array(event.attributes.IV);
314 }
315 }
316 this.trigger('data', event);
317 return;
318 }
284 319
285 // unknown tag type 320 // unknown tag type
286 this.trigger('data', { 321 this.trigger('data', {
...@@ -311,7 +346,8 @@ ...@@ -311,7 +346,8 @@
311 var 346 var
312 self = this, 347 self = this,
313 uris = [], 348 uris = [],
314 currentUri = {}; 349 currentUri = {},
350 key;
315 Parser.prototype.init.call(this); 351 Parser.prototype.init.call(this);
316 352
317 this.lineStream = new LineStream(); 353 this.lineStream = new LineStream();
...@@ -373,6 +409,36 @@ ...@@ -373,6 +409,36 @@
373 this.manifest.segments = uris; 409 this.manifest.segments = uris;
374 410
375 }, 411 },
412 'key': function() {
413 if (!entry.attributes) {
414 this.trigger('warn', {
415 message: 'ignoring key declaration without attribute list'
416 });
417 return;
418 }
419 // clear the active encryption key
420 if (entry.attributes.METHOD === 'NONE') {
421 key = null;
422 return;
423 }
424 if (!entry.attributes.URI) {
425 this.trigger('warn', {
426 message: 'ignoring key declaration without URI'
427 });
428 return;
429 }
430 if (!entry.attributes.METHOD) {
431 this.trigger('warn', {
432 message: 'defaulting key method to AES-128'
433 });
434 }
435
436 // setup an encryption key for upcoming segments
437 key = {
438 method: entry.attributes.METHOD || 'AES-128',
439 uri: entry.attributes.URI
440 };
441 },
376 'media-sequence': function() { 442 'media-sequence': function() {
377 if (!isFinite(entry.number)) { 443 if (!isFinite(entry.number)) {
378 this.trigger('warn', { 444 this.trigger('warn', {
...@@ -442,6 +508,10 @@ ...@@ -442,6 +508,10 @@
442 }); 508 });
443 currentUri.duration = this.manifest.targetDuration; 509 currentUri.duration = this.manifest.targetDuration;
444 } 510 }
511 // annotate with encryption information, if necessary
512 if (key) {
513 currentUri.key = key;
514 }
445 515
446 // prepare for the next URI 516 // prepare for the next URI
447 currentUri = {}; 517 currentUri = {};
......
...@@ -512,6 +512,94 @@ ...@@ -512,6 +512,94 @@
512 strictEqual(element.tagType, 'endlist', 'the tag type is stream-inf'); 512 strictEqual(element.tagType, 'endlist', 'the tag type is stream-inf');
513 }); 513 });
514 514
515 // #EXT-X-KEY
516 test('parses valid #EXT-X-KEY tags', function() {
517 var
518 manifest = '#EXT-X-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=52"\n',
519 element;
520 parseStream.on('data', function(elem) {
521 element = elem;
522 });
523 lineStream.push(manifest);
524
525 ok(element, 'an event was triggered');
526 deepEqual(element, {
527 type: 'tag',
528 tagType: 'key',
529 attributes: {
530 METHOD: 'AES-128',
531 URI: 'https://priv.example.com/key.php?r=52'
532 }
533 }, 'parsed a valid key');
534
535 manifest = '#EXT-X-KEY:URI="https://example.com/key#1",METHOD=FutureType-1024\n';
536 lineStream.push(manifest);
537 ok(element, 'an event was triggered');
538 deepEqual(element, {
539 type: 'tag',
540 tagType: 'key',
541 attributes: {
542 METHOD: 'FutureType-1024',
543 URI: 'https://example.com/key#1'
544 }
545 }, 'parsed the attribute list independent of order');
546
547 manifest = '#EXT-X-KEY:IV=1234567890abcdef1234567890abcdef\n';
548 lineStream.push(manifest);
549 ok(element.attributes.IV, 'detected an IV attribute');
550 deepEqual(element.attributes.IV, new Uint32Array([
551 0x12345678,
552 0x90abcdef,
553 0x12345678,
554 0x90abcdef
555 ]), 'parsed an IV value');
556 });
557
558 test('parses minimal #EXT-X-KEY tags', function() {
559 var
560 manifest = '#EXT-X-KEY:\n',
561 element;
562 parseStream.on('data', function(elem) {
563 element = elem;
564 });
565 lineStream.push(manifest);
566
567 ok(element, 'an event was triggered');
568 deepEqual(element, {
569 type: 'tag',
570 tagType: 'key'
571 }, 'parsed a minimal key tag');
572 });
573
574 test('parses lightly-broken #EXT-X-KEY tags', function() {
575 var
576 manifest = '#EXT-X-KEY:URI=\'https://example.com/single-quote\',METHOD=AES-128\n',
577 element;
578 parseStream.on('data', function(elem) {
579 element = elem;
580 });
581 lineStream.push(manifest);
582
583 strictEqual(element.attributes.URI,
584 'https://example.com/single-quote',
585 'parsed a single-quoted uri');
586
587 element = null;
588 manifest = '#EXT-X-KEYURI="https://example.com/key",METHOD=AES-128\n';
589 lineStream.push(manifest);
590 strictEqual(element.tagType, 'key', 'parsed the tag type');
591 strictEqual(element.attributes.URI,
592 'https://example.com/key',
593 'inferred a colon after the tag type');
594
595 element = null;
596 manifest = '#EXT-X-KEY: URI = "https://example.com/key",METHOD=AES-128\n';
597 lineStream.push(manifest);
598 strictEqual(element.attributes.URI,
599 'https://example.com/key',
600 'trims and removes quotes around the URI');
601 });
602
515 test('ignores empty lines', function() { 603 test('ignores empty lines', function() {
516 var 604 var
517 manifest = '\n', 605 manifest = '\n',
......
1 {
2 "allowCache": true,
3 "mediaSequence": 7794,
4 "segments": [
5 {
6 "duration": 2.833,
7 "key": {
8 "method": "AES-128",
9 "uri": "https://priv.example.com/key.php?r=52"
10 },
11 "uri": "http://media.example.com/fileSequence52-A.ts"
12 },
13 {
14 "duration": 15,
15 "key": {
16 "method": "AES-128",
17 "uri": "https://priv.example.com/key.php?r=52"
18 },
19 "uri": "http://media.example.com/fileSequence52-B.ts"
20 },
21 {
22 "duration": 13.333,
23 "key": {
24 "method": "AES-128",
25 "uri": "https://priv.example.com/key.php?r=52"
26 },
27 "uri": "http://media.example.com/fileSequence52-C.ts"
28 },
29 {
30 "duration": 15,
31 "key": {
32 "method": "AES-128",
33 "uri": "https://priv.example.com/key.php?r=53"
34 },
35 "uri": "http://media.example.com/fileSequence53-A.ts"
36 },
37 {
38 "duration": 15,
39 "uri": "http://media.example.com/fileSequence53-B.ts"
40 }
41 ],
42 "targetDuration": 15
43 }
1 #EXTM3U
2 #EXT-X-VERSION:3
3 #EXT-X-MEDIA-SEQUENCE:7794
4 #EXT-X-TARGETDURATION:15
5
6 #EXT-X-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=52"
7
8 #EXTINF:2.833,
9 http://media.example.com/fileSequence52-A.ts
10 #EXTINF:15.0,
11 http://media.example.com/fileSequence52-B.ts
12 #EXTINF:13.333,
13 http://media.example.com/fileSequence52-C.ts
14
15 #EXT-X-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=53"
16
17 #EXTINF:15.0,
18 http://media.example.com/fileSequence53-A.ts
19
20 #EXT-X-KEY:METHOD=NONE
21
22 #EXTINF:15.0,
23 http://media.example.com/fileSequence53-B.ts
...\ No newline at end of file ...\ No newline at end of file