54afda49 by brandonocasey

moved all m3u8 classes into their own file

1 parent d7019d06
...@@ -6,582 +6,9 @@ ...@@ -6,582 +6,9 @@
6 * ReadableStream-like interface. 6 * ReadableStream-like interface.
7 */ 7 */
8 8
9 import Stream from '../stream'; 9 import LineStream from './line-stream';
10 import {mergeOptions} from 'video.js'; 10 import ParseStream from './parse-stream';
11 /** 11 import Parser from './parser';
12 * A stream that buffers string input and generates a `data` event for each
13 * line.
14 */
15 export class LineStream extends Stream {
16 constructor() {
17 super();
18 this.buffer = '';
19 }
20
21 /**
22 * Add new data to be parsed.
23 * @param data {string} the text to process
24 */
25 push(data) {
26 let nextNewline;
27
28 this.buffer += data;
29 nextNewline = this.buffer.indexOf('\n');
30
31 for (; nextNewline > -1; nextNewline = this.buffer.indexOf('\n')) {
32 this.trigger('data', this.buffer.substring(0, nextNewline));
33 this.buffer = this.buffer.substring(nextNewline + 1);
34 }
35 }
36 }
37
38 // "forgiving" attribute list psuedo-grammar:
39 // attributes -> keyvalue (',' keyvalue)*
40 // keyvalue -> key '=' value
41 // key -> [^=]*
42 // value -> '"' [^"]* '"' | [^,]*
43 const attributeSeparator = function() {
44 let key = '[^=]*';
45 let value = '"[^"]*"|[^,]*';
46 let keyvalue = '(?:' + key + ')=(?:' + value + ')';
47
48 return new RegExp('(?:^|,)(' + keyvalue + ')');
49 };
50
51 const parseAttributes = function(attributes) {
52 // split the string using attributes as the separator
53 let attrs = attributes.split(attributeSeparator());
54 let i = attrs.length;
55 let result = {};
56 let attr;
57
58 while (i--) {
59 // filter out unmatched portions of the string
60 if (attrs[i] === '') {
61 continue;
62 }
63
64 // split the key and value
65 attr = (/([^=]*)=(.*)/).exec(attrs[i]).slice(1);
66 // trim whitespace and remove optional quotes around the value
67 attr[0] = attr[0].replace(/^\s+|\s+$/g, '');
68 attr[1] = attr[1].replace(/^\s+|\s+$/g, '');
69 attr[1] = attr[1].replace(/^['"](.*)['"]$/g, '$1');
70 result[attr[0]] = attr[1];
71 }
72 return result;
73 };
74
75 /**
76 * A line-level M3U8 parser event stream. It expects to receive input one
77 * line at a time and performs a context-free parse of its contents. A stream
78 * interpretation of a manifest can be useful if the manifest is expected to
79 * be too large to fit comfortably into memory or the entirety of the input
80 * is not immediately available. Otherwise, it's probably much easier to work
81 * with a regular `Parser` object.
82 *
83 * Produces `data` events with an object that captures the parser's
84 * interpretation of the input. That object has a property `tag` that is one
85 * of `uri`, `comment`, or `tag`. URIs only have a single additional
86 * property, `line`, which captures the entirety of the input without
87 * interpretation. Comments similarly have a single additional property
88 * `text` which is the input without the leading `#`.
89 *
90 * Tags always have a property `tagType` which is the lower-cased version of
91 * the M3U8 directive without the `#EXT` or `#EXT-X-` prefix. For instance,
92 * `#EXT-X-MEDIA-SEQUENCE` becomes `media-sequence` when parsed. Unrecognized
93 * tags are given the tag type `unknown` and a single additional property
94 * `data` with the remainder of the input.
95 */
96 export class ParseStream extends Stream {
97 constructor() {
98 super();
99 }
100
101 /**
102 * Parses an additional line of input.
103 * @param line {string} a single line of an M3U8 file to parse
104 */
105 push(line) {
106 let match;
107 let event;
108
109 // strip whitespace
110 line = line.replace(/^[\u0000\s]+|[\u0000\s]+$/g, '');
111 if (line.length === 0) {
112 // ignore empty lines
113 return;
114 }
115
116 // URIs
117 if (line[0] !== '#') {
118 this.trigger('data', {
119 type: 'uri',
120 uri: line
121 });
122 return;
123 }
124
125 // Comments
126 if (line.indexOf('#EXT') !== 0) {
127 this.trigger('data', {
128 type: 'comment',
129 text: line.slice(1)
130 });
131 return;
132 }
133
134 // strip off any carriage returns here so the regex matching
135 // doesn't have to account for them.
136 line = line.replace('\r', '');
137
138 // Tags
139 match = (/^#EXTM3U/).exec(line);
140 if (match) {
141 this.trigger('data', {
142 type: 'tag',
143 tagType: 'm3u'
144 });
145 return;
146 }
147 match = (/^#EXTINF:?([0-9\.]*)?,?(.*)?$/).exec(line);
148 if (match) {
149 event = {
150 type: 'tag',
151 tagType: 'inf'
152 };
153 if (match[1]) {
154 event.duration = parseFloat(match[1]);
155 }
156 if (match[2]) {
157 event.title = match[2];
158 }
159 this.trigger('data', event);
160 return;
161 }
162 match = (/^#EXT-X-TARGETDURATION:?([0-9.]*)?/).exec(line);
163 if (match) {
164 event = {
165 type: 'tag',
166 tagType: 'targetduration'
167 };
168 if (match[1]) {
169 event.duration = parseInt(match[1], 10);
170 }
171 this.trigger('data', event);
172 return;
173 }
174 match = (/^#ZEN-TOTAL-DURATION:?([0-9.]*)?/).exec(line);
175 if (match) {
176 event = {
177 type: 'tag',
178 tagType: 'totalduration'
179 };
180 if (match[1]) {
181 event.duration = parseInt(match[1], 10);
182 }
183 this.trigger('data', event);
184 return;
185 }
186 match = (/^#EXT-X-VERSION:?([0-9.]*)?/).exec(line);
187 if (match) {
188 event = {
189 type: 'tag',
190 tagType: 'version'
191 };
192 if (match[1]) {
193 event.version = parseInt(match[1], 10);
194 }
195 this.trigger('data', event);
196 return;
197 }
198 match = (/^#EXT-X-MEDIA-SEQUENCE:?(\-?[0-9.]*)?/).exec(line);
199 if (match) {
200 event = {
201 type: 'tag',
202 tagType: 'media-sequence'
203 };
204 if (match[1]) {
205 event.number = parseInt(match[1], 10);
206 }
207 this.trigger('data', event);
208 return;
209 }
210 match = (/^#EXT-X-DISCONTINUITY-SEQUENCE:?(\-?[0-9.]*)?/).exec(line);
211 if (match) {
212 event = {
213 type: 'tag',
214 tagType: 'discontinuity-sequence'
215 };
216 if (match[1]) {
217 event.number = parseInt(match[1], 10);
218 }
219 this.trigger('data', event);
220 return;
221 }
222 match = (/^#EXT-X-PLAYLIST-TYPE:?(.*)?$/).exec(line);
223 if (match) {
224 event = {
225 type: 'tag',
226 tagType: 'playlist-type'
227 };
228 if (match[1]) {
229 event.playlistType = match[1];
230 }
231 this.trigger('data', event);
232 return;
233 }
234 match = (/^#EXT-X-BYTERANGE:?([0-9.]*)?@?([0-9.]*)?/).exec(line);
235 if (match) {
236 event = {
237 type: 'tag',
238 tagType: 'byterange'
239 };
240 if (match[1]) {
241 event.length = parseInt(match[1], 10);
242 }
243 if (match[2]) {
244 event.offset = parseInt(match[2], 10);
245 }
246 this.trigger('data', event);
247 return;
248 }
249 match = (/^#EXT-X-ALLOW-CACHE:?(YES|NO)?/).exec(line);
250 if (match) {
251 event = {
252 type: 'tag',
253 tagType: 'allow-cache'
254 };
255 if (match[1]) {
256 event.allowed = !(/NO/).test(match[1]);
257 }
258 this.trigger('data', event);
259 return;
260 }
261 match = (/^#EXT-X-STREAM-INF:?(.*)$/).exec(line);
262 if (match) {
263 event = {
264 type: 'tag',
265 tagType: 'stream-inf'
266 };
267 if (match[1]) {
268 event.attributes = parseAttributes(match[1]);
269
270 if (event.attributes.RESOLUTION) {
271 let split = event.attributes.RESOLUTION.split('x');
272 let resolution = {};
273
274 if (split[0]) {
275 resolution.width = parseInt(split[0], 10);
276 }
277 if (split[1]) {
278 resolution.height = parseInt(split[1], 10);
279 }
280 event.attributes.RESOLUTION = resolution;
281 }
282 if (event.attributes.BANDWIDTH) {
283 event.attributes.BANDWIDTH = parseInt(event.attributes.BANDWIDTH, 10);
284 }
285 if (event.attributes['PROGRAM-ID']) {
286 event.attributes['PROGRAM-ID'] = parseInt(event.attributes['PROGRAM-ID'], 10);
287 }
288 }
289 this.trigger('data', event);
290 return;
291 }
292 match = (/^#EXT-X-ENDLIST/).exec(line);
293 if (match) {
294 this.trigger('data', {
295 type: 'tag',
296 tagType: 'endlist'
297 });
298 return;
299 }
300 match = (/^#EXT-X-DISCONTINUITY/).exec(line);
301 if (match) {
302 this.trigger('data', {
303 type: 'tag',
304 tagType: 'discontinuity'
305 });
306 return;
307 }
308 match = (/^#EXT-X-KEY:?(.*)$/).exec(line);
309 if (match) {
310 event = {
311 type: 'tag',
312 tagType: 'key'
313 };
314 if (match[1]) {
315 event.attributes = parseAttributes(match[1]);
316 // parse the IV string into a Uint32Array
317 if (event.attributes.IV) {
318 if (event.attributes.IV.substring(0, 2) === '0x') {
319 event.attributes.IV = event.attributes.IV.substring(2);
320 }
321
322 event.attributes.IV = event.attributes.IV.match(/.{8}/g);
323 event.attributes.IV[0] = parseInt(event.attributes.IV[0], 16);
324 event.attributes.IV[1] = parseInt(event.attributes.IV[1], 16);
325 event.attributes.IV[2] = parseInt(event.attributes.IV[2], 16);
326 event.attributes.IV[3] = parseInt(event.attributes.IV[3], 16);
327 event.attributes.IV = new Uint32Array(event.attributes.IV);
328 }
329 }
330 this.trigger('data', event);
331 return;
332 }
333
334 // unknown tag type
335 this.trigger('data', {
336 type: 'tag',
337 data: line.slice(4, line.length)
338 });
339 }
340 }
341
342 /**
343 * A parser for M3U8 files. The current interpretation of the input is
344 * exposed as a property `manifest` on parser objects. It's just two lines to
345 * create and parse a manifest once you have the contents available as a string:
346 *
347 * ```js
348 * var parser = new videojs.m3u8.Parser();
349 * parser.push(xhr.responseText);
350 * ```
351 *
352 * New input can later be applied to update the manifest object by calling
353 * `push` again.
354 *
355 * The parser attempts to create a usable manifest object even if the
356 * underlying input is somewhat nonsensical. It emits `info` and `warning`
357 * events during the parse if it encounters input that seems invalid or
358 * requires some property of the manifest object to be defaulted.
359 */
360 export class Parser extends Stream {
361 constructor() {
362 super();
363 this.lineStream = new LineStream();
364 this.parseStream = new ParseStream();
365 this.lineStream.pipe(this.parseStream);
366 /* eslint-disable consistent-this */
367 let self = this;
368 /* eslint-enable consistent-this */
369 let uris = [];
370 let currentUri = {};
371 let key;
372 let noop = function() {};
373
374 // the manifest is empty until the parse stream begins delivering data
375 this.manifest = {
376 allowCache: true,
377 discontinuityStarts: []
378 };
379
380 // update the manifest with the m3u8 entry from the parse stream
381 this.parseStream.on('data', function(entry) {
382 ({
383 tag() {
384 // switch based on the tag type
385 (({
386 'allow-cache'() {
387 this.manifest.allowCache = entry.allowed;
388 if (!('allowed' in entry)) {
389 this.trigger('info', {
390 message: 'defaulting allowCache to YES'
391 });
392 this.manifest.allowCache = true;
393 }
394 },
395 byterange() {
396 let byterange = {};
397
398 if ('length' in entry) {
399 currentUri.byterange = byterange;
400 byterange.length = entry.length;
401
402 if (!('offset' in entry)) {
403 this.trigger('info', {
404 message: 'defaulting offset to zero'
405 });
406 entry.offset = 0;
407 }
408 }
409 if ('offset' in entry) {
410 currentUri.byterange = byterange;
411 byterange.offset = entry.offset;
412 }
413 },
414 endlist() {
415 this.manifest.endList = true;
416 },
417 inf() {
418 if (!('mediaSequence' in this.manifest)) {
419 this.manifest.mediaSequence = 0;
420 this.trigger('info', {
421 message: 'defaulting media sequence to zero'
422 });
423 }
424 if (!('discontinuitySequence' in this.manifest)) {
425 this.manifest.discontinuitySequence = 0;
426 this.trigger('info', {
427 message: 'defaulting discontinuity sequence to zero'
428 });
429 }
430 if (entry.duration >= 0) {
431 currentUri.duration = entry.duration;
432 }
433
434 this.manifest.segments = uris;
435
436 },
437 key() {
438 if (!entry.attributes) {
439 this.trigger('warn', {
440 message: 'ignoring key declaration without attribute list'
441 });
442 return;
443 }
444 // clear the active encryption key
445 if (entry.attributes.METHOD === 'NONE') {
446 key = null;
447 return;
448 }
449 if (!entry.attributes.URI) {
450 this.trigger('warn', {
451 message: 'ignoring key declaration without URI'
452 });
453 return;
454 }
455 if (!entry.attributes.METHOD) {
456 this.trigger('warn', {
457 message: 'defaulting key method to AES-128'
458 });
459 }
460
461 // setup an encryption key for upcoming segments
462 key = {
463 method: entry.attributes.METHOD || 'AES-128',
464 uri: entry.attributes.URI
465 };
466
467 if (typeof entry.attributes.IV !== 'undefined') {
468 key.iv = entry.attributes.IV;
469 }
470 },
471 'media-sequence'() {
472 if (!isFinite(entry.number)) {
473 this.trigger('warn', {
474 message: 'ignoring invalid media sequence: ' + entry.number
475 });
476 return;
477 }
478 this.manifest.mediaSequence = entry.number;
479 },
480 'discontinuity-sequence'() {
481 if (!isFinite(entry.number)) {
482 this.trigger('warn', {
483 message: 'ignoring invalid discontinuity sequence: ' + entry.number
484 });
485 return;
486 }
487 this.manifest.discontinuitySequence = entry.number;
488 },
489 'playlist-type'() {
490 if (!(/VOD|EVENT/).test(entry.playlistType)) {
491 this.trigger('warn', {
492 message: 'ignoring unknown playlist type: ' + entry.playlist
493 });
494 return;
495 }
496 this.manifest.playlistType = entry.playlistType;
497 },
498 'stream-inf'() {
499 this.manifest.playlists = uris;
500
501 if (!entry.attributes) {
502 this.trigger('warn', {
503 message: 'ignoring empty stream-inf attributes'
504 });
505 return;
506 }
507
508 if (!currentUri.attributes) {
509 currentUri.attributes = {};
510 }
511 currentUri.attributes = mergeOptions(currentUri.attributes,
512 entry.attributes);
513 },
514 discontinuity() {
515 currentUri.discontinuity = true;
516 this.manifest.discontinuityStarts.push(uris.length);
517 },
518 targetduration() {
519 if (!isFinite(entry.duration) || entry.duration < 0) {
520 this.trigger('warn', {
521 message: 'ignoring invalid target duration: ' + entry.duration
522 });
523 return;
524 }
525 this.manifest.targetDuration = entry.duration;
526 },
527 totalduration() {
528 if (!isFinite(entry.duration) || entry.duration < 0) {
529 this.trigger('warn', {
530 message: 'ignoring invalid total duration: ' + entry.duration
531 });
532 return;
533 }
534 this.manifest.totalDuration = entry.duration;
535 }
536 })[entry.tagType] || noop).call(self);
537 },
538 uri() {
539 currentUri.uri = entry.uri;
540 uris.push(currentUri);
541
542 // if no explicit duration was declared, use the target duration
543 if (this.manifest.targetDuration &&
544 !('duration' in currentUri)) {
545 this.trigger('warn', {
546 message: 'defaulting segment duration to the target duration'
547 });
548 currentUri.duration = this.manifest.targetDuration;
549 }
550 // annotate with encryption information, if necessary
551 if (key) {
552 currentUri.key = key;
553 }
554
555 // prepare for the next URI
556 currentUri = {};
557 },
558 comment() {
559 // comments are not important for playback
560 }
561 })[entry.type].call(self);
562 });
563
564 }
565
566 /**
567 * Parse the input string and update the manifest object.
568 * @param chunk {string} a potentially incomplete portion of the manifest
569 */
570 push(chunk) {
571 this.lineStream.push(chunk);
572 }
573
574 /**
575 * Flush any remaining input. This can be handy if the last line of an M3U8
576 * manifest did not contain a trailing newline but the file has been
577 * completely received.
578 */
579 end() {
580 // flush any buffered input
581 this.lineStream.push('\n');
582 }
583
584 }
585 12
586 export default { 13 export default {
587 LineStream, 14 LineStream,
......
1 import Stream from '../stream';
2 /**
3 * A stream that buffers string input and generates a `data` event for each
4 * line.
5 */
6 export default class LineStream extends Stream {
7 constructor() {
8 super();
9 this.buffer = '';
10 }
11
12 /**
13 * Add new data to be parsed.
14 * @param data {string} the text to process
15 */
16 push(data) {
17 let nextNewline;
18
19 this.buffer += data;
20 nextNewline = this.buffer.indexOf('\n');
21
22 for (; nextNewline > -1; nextNewline = this.buffer.indexOf('\n')) {
23 this.trigger('data', this.buffer.substring(0, nextNewline));
24 this.buffer = this.buffer.substring(nextNewline + 1);
25 }
26 }
27 }
1 import Stream from '../stream';
2
3 // "forgiving" attribute list psuedo-grammar:
4 // attributes -> keyvalue (',' keyvalue)*
5 // keyvalue -> key '=' value
6 // key -> [^=]*
7 // value -> '"' [^"]* '"' | [^,]*
8 const attributeSeparator = function() {
9 let key = '[^=]*';
10 let value = '"[^"]*"|[^,]*';
11 let keyvalue = '(?:' + key + ')=(?:' + value + ')';
12
13 return new RegExp('(?:^|,)(' + keyvalue + ')');
14 };
15
16 const parseAttributes = function(attributes) {
17 // split the string using attributes as the separator
18 let attrs = attributes.split(attributeSeparator());
19 let i = attrs.length;
20 let result = {};
21 let attr;
22
23 while (i--) {
24 // filter out unmatched portions of the string
25 if (attrs[i] === '') {
26 continue;
27 }
28
29 // split the key and value
30 attr = (/([^=]*)=(.*)/).exec(attrs[i]).slice(1);
31 // trim whitespace and remove optional quotes around the value
32 attr[0] = attr[0].replace(/^\s+|\s+$/g, '');
33 attr[1] = attr[1].replace(/^\s+|\s+$/g, '');
34 attr[1] = attr[1].replace(/^['"](.*)['"]$/g, '$1');
35 result[attr[0]] = attr[1];
36 }
37 return result;
38 };
39
40 /**
41 * A line-level M3U8 parser event stream. It expects to receive input one
42 * line at a time and performs a context-free parse of its contents. A stream
43 * interpretation of a manifest can be useful if the manifest is expected to
44 * be too large to fit comfortably into memory or the entirety of the input
45 * is not immediately available. Otherwise, it's probably much easier to work
46 * with a regular `Parser` object.
47 *
48 * Produces `data` events with an object that captures the parser's
49 * interpretation of the input. That object has a property `tag` that is one
50 * of `uri`, `comment`, or `tag`. URIs only have a single additional
51 * property, `line`, which captures the entirety of the input without
52 * interpretation. Comments similarly have a single additional property
53 * `text` which is the input without the leading `#`.
54 *
55 * Tags always have a property `tagType` which is the lower-cased version of
56 * the M3U8 directive without the `#EXT` or `#EXT-X-` prefix. For instance,
57 * `#EXT-X-MEDIA-SEQUENCE` becomes `media-sequence` when parsed. Unrecognized
58 * tags are given the tag type `unknown` and a single additional property
59 * `data` with the remainder of the input.
60 */
61 export default class ParseStream extends Stream {
62 constructor() {
63 super();
64 }
65
66 /**
67 * Parses an additional line of input.
68 * @param line {string} a single line of an M3U8 file to parse
69 */
70 push(line) {
71 let match;
72 let event;
73
74 // strip whitespace
75 line = line.replace(/^[\u0000\s]+|[\u0000\s]+$/g, '');
76 if (line.length === 0) {
77 // ignore empty lines
78 return;
79 }
80
81 // URIs
82 if (line[0] !== '#') {
83 this.trigger('data', {
84 type: 'uri',
85 uri: line
86 });
87 return;
88 }
89
90 // Comments
91 if (line.indexOf('#EXT') !== 0) {
92 this.trigger('data', {
93 type: 'comment',
94 text: line.slice(1)
95 });
96 return;
97 }
98
99 // strip off any carriage returns here so the regex matching
100 // doesn't have to account for them.
101 line = line.replace('\r', '');
102
103 // Tags
104 match = (/^#EXTM3U/).exec(line);
105 if (match) {
106 this.trigger('data', {
107 type: 'tag',
108 tagType: 'm3u'
109 });
110 return;
111 }
112 match = (/^#EXTINF:?([0-9\.]*)?,?(.*)?$/).exec(line);
113 if (match) {
114 event = {
115 type: 'tag',
116 tagType: 'inf'
117 };
118 if (match[1]) {
119 event.duration = parseFloat(match[1]);
120 }
121 if (match[2]) {
122 event.title = match[2];
123 }
124 this.trigger('data', event);
125 return;
126 }
127 match = (/^#EXT-X-TARGETDURATION:?([0-9.]*)?/).exec(line);
128 if (match) {
129 event = {
130 type: 'tag',
131 tagType: 'targetduration'
132 };
133 if (match[1]) {
134 event.duration = parseInt(match[1], 10);
135 }
136 this.trigger('data', event);
137 return;
138 }
139 match = (/^#ZEN-TOTAL-DURATION:?([0-9.]*)?/).exec(line);
140 if (match) {
141 event = {
142 type: 'tag',
143 tagType: 'totalduration'
144 };
145 if (match[1]) {
146 event.duration = parseInt(match[1], 10);
147 }
148 this.trigger('data', event);
149 return;
150 }
151 match = (/^#EXT-X-VERSION:?([0-9.]*)?/).exec(line);
152 if (match) {
153 event = {
154 type: 'tag',
155 tagType: 'version'
156 };
157 if (match[1]) {
158 event.version = parseInt(match[1], 10);
159 }
160 this.trigger('data', event);
161 return;
162 }
163 match = (/^#EXT-X-MEDIA-SEQUENCE:?(\-?[0-9.]*)?/).exec(line);
164 if (match) {
165 event = {
166 type: 'tag',
167 tagType: 'media-sequence'
168 };
169 if (match[1]) {
170 event.number = parseInt(match[1], 10);
171 }
172 this.trigger('data', event);
173 return;
174 }
175 match = (/^#EXT-X-DISCONTINUITY-SEQUENCE:?(\-?[0-9.]*)?/).exec(line);
176 if (match) {
177 event = {
178 type: 'tag',
179 tagType: 'discontinuity-sequence'
180 };
181 if (match[1]) {
182 event.number = parseInt(match[1], 10);
183 }
184 this.trigger('data', event);
185 return;
186 }
187 match = (/^#EXT-X-PLAYLIST-TYPE:?(.*)?$/).exec(line);
188 if (match) {
189 event = {
190 type: 'tag',
191 tagType: 'playlist-type'
192 };
193 if (match[1]) {
194 event.playlistType = match[1];
195 }
196 this.trigger('data', event);
197 return;
198 }
199 match = (/^#EXT-X-BYTERANGE:?([0-9.]*)?@?([0-9.]*)?/).exec(line);
200 if (match) {
201 event = {
202 type: 'tag',
203 tagType: 'byterange'
204 };
205 if (match[1]) {
206 event.length = parseInt(match[1], 10);
207 }
208 if (match[2]) {
209 event.offset = parseInt(match[2], 10);
210 }
211 this.trigger('data', event);
212 return;
213 }
214 match = (/^#EXT-X-ALLOW-CACHE:?(YES|NO)?/).exec(line);
215 if (match) {
216 event = {
217 type: 'tag',
218 tagType: 'allow-cache'
219 };
220 if (match[1]) {
221 event.allowed = !(/NO/).test(match[1]);
222 }
223 this.trigger('data', event);
224 return;
225 }
226 match = (/^#EXT-X-STREAM-INF:?(.*)$/).exec(line);
227 if (match) {
228 event = {
229 type: 'tag',
230 tagType: 'stream-inf'
231 };
232 if (match[1]) {
233 event.attributes = parseAttributes(match[1]);
234
235 if (event.attributes.RESOLUTION) {
236 let split = event.attributes.RESOLUTION.split('x');
237 let resolution = {};
238
239 if (split[0]) {
240 resolution.width = parseInt(split[0], 10);
241 }
242 if (split[1]) {
243 resolution.height = parseInt(split[1], 10);
244 }
245 event.attributes.RESOLUTION = resolution;
246 }
247 if (event.attributes.BANDWIDTH) {
248 event.attributes.BANDWIDTH = parseInt(event.attributes.BANDWIDTH, 10);
249 }
250 if (event.attributes['PROGRAM-ID']) {
251 event.attributes['PROGRAM-ID'] = parseInt(event.attributes['PROGRAM-ID'], 10);
252 }
253 }
254 this.trigger('data', event);
255 return;
256 }
257 match = (/^#EXT-X-ENDLIST/).exec(line);
258 if (match) {
259 this.trigger('data', {
260 type: 'tag',
261 tagType: 'endlist'
262 });
263 return;
264 }
265 match = (/^#EXT-X-DISCONTINUITY/).exec(line);
266 if (match) {
267 this.trigger('data', {
268 type: 'tag',
269 tagType: 'discontinuity'
270 });
271 return;
272 }
273 match = (/^#EXT-X-KEY:?(.*)$/).exec(line);
274 if (match) {
275 event = {
276 type: 'tag',
277 tagType: 'key'
278 };
279 if (match[1]) {
280 event.attributes = parseAttributes(match[1]);
281 // parse the IV string into a Uint32Array
282 if (event.attributes.IV) {
283 if (event.attributes.IV.substring(0, 2) === '0x') {
284 event.attributes.IV = event.attributes.IV.substring(2);
285 }
286
287 event.attributes.IV = event.attributes.IV.match(/.{8}/g);
288 event.attributes.IV[0] = parseInt(event.attributes.IV[0], 16);
289 event.attributes.IV[1] = parseInt(event.attributes.IV[1], 16);
290 event.attributes.IV[2] = parseInt(event.attributes.IV[2], 16);
291 event.attributes.IV[3] = parseInt(event.attributes.IV[3], 16);
292 event.attributes.IV = new Uint32Array(event.attributes.IV);
293 }
294 }
295 this.trigger('data', event);
296 return;
297 }
298
299 // unknown tag type
300 this.trigger('data', {
301 type: 'tag',
302 data: line.slice(4, line.length)
303 });
304 }
305 }
1 import Stream from '../stream' ;
2 import LineStream from './line-stream';
3 import ParseStream from './parse-stream';
4 import {mergeOptions} from 'video.js';
5
6 /**
7 * A parser for M3U8 files. The current interpretation of the input is
8 * exposed as a property `manifest` on parser objects. It's just two lines to
9 * create and parse a manifest once you have the contents available as a string:
10 *
11 * ```js
12 * var parser = new videojs.m3u8.Parser();
13 * parser.push(xhr.responseText);
14 * ```
15 *
16 * New input can later be applied to update the manifest object by calling
17 * `push` again.
18 *
19 * The parser attempts to create a usable manifest object even if the
20 * underlying input is somewhat nonsensical. It emits `info` and `warning`
21 * events during the parse if it encounters input that seems invalid or
22 * requires some property of the manifest object to be defaulted.
23 */
24 export default class Parser extends Stream {
25 constructor() {
26 super();
27 this.lineStream = new LineStream();
28 this.parseStream = new ParseStream();
29 this.lineStream.pipe(this.parseStream);
30 /* eslint-disable consistent-this */
31 let self = this;
32 /* eslint-enable consistent-this */
33 let uris = [];
34 let currentUri = {};
35 let key;
36 let noop = function() {};
37
38 // the manifest is empty until the parse stream begins delivering data
39 this.manifest = {
40 allowCache: true,
41 discontinuityStarts: []
42 };
43
44 // update the manifest with the m3u8 entry from the parse stream
45 this.parseStream.on('data', function(entry) {
46 ({
47 tag() {
48 // switch based on the tag type
49 (({
50 'allow-cache'() {
51 this.manifest.allowCache = entry.allowed;
52 if (!('allowed' in entry)) {
53 this.trigger('info', {
54 message: 'defaulting allowCache to YES'
55 });
56 this.manifest.allowCache = true;
57 }
58 },
59 byterange() {
60 let byterange = {};
61
62 if ('length' in entry) {
63 currentUri.byterange = byterange;
64 byterange.length = entry.length;
65
66 if (!('offset' in entry)) {
67 this.trigger('info', {
68 message: 'defaulting offset to zero'
69 });
70 entry.offset = 0;
71 }
72 }
73 if ('offset' in entry) {
74 currentUri.byterange = byterange;
75 byterange.offset = entry.offset;
76 }
77 },
78 endlist() {
79 this.manifest.endList = true;
80 },
81 inf() {
82 if (!('mediaSequence' in this.manifest)) {
83 this.manifest.mediaSequence = 0;
84 this.trigger('info', {
85 message: 'defaulting media sequence to zero'
86 });
87 }
88 if (!('discontinuitySequence' in this.manifest)) {
89 this.manifest.discontinuitySequence = 0;
90 this.trigger('info', {
91 message: 'defaulting discontinuity sequence to zero'
92 });
93 }
94 if (entry.duration >= 0) {
95 currentUri.duration = entry.duration;
96 }
97
98 this.manifest.segments = uris;
99
100 },
101 key() {
102 if (!entry.attributes) {
103 this.trigger('warn', {
104 message: 'ignoring key declaration without attribute list'
105 });
106 return;
107 }
108 // clear the active encryption key
109 if (entry.attributes.METHOD === 'NONE') {
110 key = null;
111 return;
112 }
113 if (!entry.attributes.URI) {
114 this.trigger('warn', {
115 message: 'ignoring key declaration without URI'
116 });
117 return;
118 }
119 if (!entry.attributes.METHOD) {
120 this.trigger('warn', {
121 message: 'defaulting key method to AES-128'
122 });
123 }
124
125 // setup an encryption key for upcoming segments
126 key = {
127 method: entry.attributes.METHOD || 'AES-128',
128 uri: entry.attributes.URI
129 };
130
131 if (typeof entry.attributes.IV !== 'undefined') {
132 key.iv = entry.attributes.IV;
133 }
134 },
135 'media-sequence'() {
136 if (!isFinite(entry.number)) {
137 this.trigger('warn', {
138 message: 'ignoring invalid media sequence: ' + entry.number
139 });
140 return;
141 }
142 this.manifest.mediaSequence = entry.number;
143 },
144 'discontinuity-sequence'() {
145 if (!isFinite(entry.number)) {
146 this.trigger('warn', {
147 message: 'ignoring invalid discontinuity sequence: ' + entry.number
148 });
149 return;
150 }
151 this.manifest.discontinuitySequence = entry.number;
152 },
153 'playlist-type'() {
154 if (!(/VOD|EVENT/).test(entry.playlistType)) {
155 this.trigger('warn', {
156 message: 'ignoring unknown playlist type: ' + entry.playlist
157 });
158 return;
159 }
160 this.manifest.playlistType = entry.playlistType;
161 },
162 'stream-inf'() {
163 this.manifest.playlists = uris;
164
165 if (!entry.attributes) {
166 this.trigger('warn', {
167 message: 'ignoring empty stream-inf attributes'
168 });
169 return;
170 }
171
172 if (!currentUri.attributes) {
173 currentUri.attributes = {};
174 }
175 currentUri.attributes = mergeOptions(currentUri.attributes,
176 entry.attributes);
177 },
178 discontinuity() {
179 currentUri.discontinuity = true;
180 this.manifest.discontinuityStarts.push(uris.length);
181 },
182 targetduration() {
183 if (!isFinite(entry.duration) || entry.duration < 0) {
184 this.trigger('warn', {
185 message: 'ignoring invalid target duration: ' + entry.duration
186 });
187 return;
188 }
189 this.manifest.targetDuration = entry.duration;
190 },
191 totalduration() {
192 if (!isFinite(entry.duration) || entry.duration < 0) {
193 this.trigger('warn', {
194 message: 'ignoring invalid total duration: ' + entry.duration
195 });
196 return;
197 }
198 this.manifest.totalDuration = entry.duration;
199 }
200 })[entry.tagType] || noop).call(self);
201 },
202 uri() {
203 currentUri.uri = entry.uri;
204 uris.push(currentUri);
205
206 // if no explicit duration was declared, use the target duration
207 if (this.manifest.targetDuration &&
208 !('duration' in currentUri)) {
209 this.trigger('warn', {
210 message: 'defaulting segment duration to the target duration'
211 });
212 currentUri.duration = this.manifest.targetDuration;
213 }
214 // annotate with encryption information, if necessary
215 if (key) {
216 currentUri.key = key;
217 }
218
219 // prepare for the next URI
220 currentUri = {};
221 },
222 comment() {
223 // comments are not important for playback
224 }
225 })[entry.type].call(self);
226 });
227
228 }
229
230 /**
231 * Parse the input string and update the manifest object.
232 * @param chunk {string} a potentially incomplete portion of the manifest
233 */
234 push(chunk) {
235 this.lineStream.push(chunk);
236 }
237
238 /**
239 * Flush any remaining input. This can be handy if the last line of an M3U8
240 * manifest did not contain a trailing newline but the file has been
241 * completely received.
242 */
243 end() {
244 // flush any buffered input
245 this.lineStream.push('\n');
246 }
247
248 }
249