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.
Showing
4 changed files
with
237 additions
and
13 deletions
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', | ... | ... |
test/manifest/encrypted.json
0 → 100644
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 | } |
test/manifest/encrypted.m3u8
0 → 100644
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 |
-
Please register or sign in to post a comment