Merge pull request #539 from BrandonOCasey/seperate-m3u8-files
moved all m3u8 classes into their own file
Showing
4 changed files
with
584 additions
and
576 deletions
... | @@ -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, | ... | ... |
src/m3u8/line-stream.js
0 → 100644
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 | } |
src/m3u8/parse-stream.js
0 → 100644
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 | } |
src/m3u8/parser.js
0 → 100644
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 |
-
Please register or sign in to post a comment