a8daa7f7 by David LaPalomento

Merge pull request #539 from BrandonOCasey/seperate-m3u8-files

moved all m3u8 classes into their own file
2 parents d7019d06 54afda49
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