moved all m3u8 classes into their own file
Showing
4 changed files
with
581 additions
and
0 deletions
This diff is collapsed.
Click to expand it.
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