@transmitlive/m3u8-parser 4.7.2-beta.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/CONTRIBUTING.md +30 -0
  2. package/LICENSE +13 -0
  3. package/README.md +388 -0
  4. package/dist/m3u8-parser.cjs.js +1563 -0
  5. package/dist/m3u8-parser.es.js +1549 -0
  6. package/dist/m3u8-parser.js +1748 -0
  7. package/dist/m3u8-parser.min.js +2 -0
  8. package/index.html +15 -0
  9. package/package.json +100 -0
  10. package/scripts/karma.conf.js +12 -0
  11. package/scripts/rollup.config.js +47 -0
  12. package/src/index.js +19 -0
  13. package/src/line-stream.js +35 -0
  14. package/src/parse-stream.js +619 -0
  15. package/src/parser.js +748 -0
  16. package/test/fixtures/integration/absoluteUris.js +31 -0
  17. package/test/fixtures/integration/absoluteUris.m3u8 +12 -0
  18. package/test/fixtures/integration/allowCache.js +165 -0
  19. package/test/fixtures/integration/allowCache.m3u8 +58 -0
  20. package/test/fixtures/integration/allowCacheInvalid.js +21 -0
  21. package/test/fixtures/integration/allowCacheInvalid.m3u8 +10 -0
  22. package/test/fixtures/integration/alternateAudio.js +56 -0
  23. package/test/fixtures/integration/alternateAudio.m3u8 +9 -0
  24. package/test/fixtures/integration/alternateVideo.js +48 -0
  25. package/test/fixtures/integration/alternateVideo.m3u8 +8 -0
  26. package/test/fixtures/integration/brightcove.js +57 -0
  27. package/test/fixtures/integration/brightcove.m3u8 +9 -0
  28. package/test/fixtures/integration/byteRange.js +161 -0
  29. package/test/fixtures/integration/byteRange.m3u8 +56 -0
  30. package/test/fixtures/integration/dateTime.js +27 -0
  31. package/test/fixtures/integration/dateTime.m3u8 +12 -0
  32. package/test/fixtures/integration/diff-init-key.js +164 -0
  33. package/test/fixtures/integration/diff-init-key.m3u8 +57 -0
  34. package/test/fixtures/integration/disallowCache.js +21 -0
  35. package/test/fixtures/integration/disallowCache.m3u8 +10 -0
  36. package/test/fixtures/integration/disc-sequence.js +32 -0
  37. package/test/fixtures/integration/disc-sequence.m3u8 +15 -0
  38. package/test/fixtures/integration/discontinuity.js +59 -0
  39. package/test/fixtures/integration/discontinuity.m3u8 +26 -0
  40. package/test/fixtures/integration/domainUris.js +31 -0
  41. package/test/fixtures/integration/domainUris.m3u8 +12 -0
  42. package/test/fixtures/integration/empty.js +5 -0
  43. package/test/fixtures/integration/empty.m3u8 +0 -0
  44. package/test/fixtures/integration/emptyAllowCache.js +21 -0
  45. package/test/fixtures/integration/emptyAllowCache.m3u8 +10 -0
  46. package/test/fixtures/integration/emptyMediaSequence.js +31 -0
  47. package/test/fixtures/integration/emptyMediaSequence.m3u8 +14 -0
  48. package/test/fixtures/integration/emptyPlaylistType.js +40 -0
  49. package/test/fixtures/integration/emptyPlaylistType.m3u8 +16 -0
  50. package/test/fixtures/integration/emptyTargetDuration.js +57 -0
  51. package/test/fixtures/integration/emptyTargetDuration.m3u8 +10 -0
  52. package/test/fixtures/integration/encrypted.js +61 -0
  53. package/test/fixtures/integration/encrypted.m3u8 +28 -0
  54. package/test/fixtures/integration/event.js +41 -0
  55. package/test/fixtures/integration/event.m3u8 +16 -0
  56. package/test/fixtures/integration/extXPlaylistTypeInvalidPlaylist.js +15 -0
  57. package/test/fixtures/integration/extXPlaylistTypeInvalidPlaylist.m3u8 +8 -0
  58. package/test/fixtures/integration/extinf.js +165 -0
  59. package/test/fixtures/integration/extinf.m3u8 +57 -0
  60. package/test/fixtures/integration/fmp4.js +44 -0
  61. package/test/fixtures/integration/fmp4.m3u8 +14 -0
  62. package/test/fixtures/integration/headerOnly.js +5 -0
  63. package/test/fixtures/integration/headerOnly.m3u8 +1 -0
  64. package/test/fixtures/integration/invalidAllowCache.js +21 -0
  65. package/test/fixtures/integration/invalidAllowCache.m3u8 +10 -0
  66. package/test/fixtures/integration/invalidMediaSequence.js +31 -0
  67. package/test/fixtures/integration/invalidMediaSequence.m3u8 +14 -0
  68. package/test/fixtures/integration/invalidPlaylistType.js +40 -0
  69. package/test/fixtures/integration/invalidPlaylistType.m3u8 +16 -0
  70. package/test/fixtures/integration/invalidTargetDuration.js +164 -0
  71. package/test/fixtures/integration/invalidTargetDuration.m3u8 +57 -0
  72. package/test/fixtures/integration/liveMissingSegmentDuration.js +25 -0
  73. package/test/fixtures/integration/liveMissingSegmentDuration.m3u8 +9 -0
  74. package/test/fixtures/integration/liveStart30sBefore.js +54 -0
  75. package/test/fixtures/integration/liveStart30sBefore.m3u8 +22 -0
  76. package/test/fixtures/integration/llhls-byte-range.js +253 -0
  77. package/test/fixtures/integration/llhls-byte-range.m3u8 +66 -0
  78. package/test/fixtures/integration/llhls-delta-byte-range.js +149 -0
  79. package/test/fixtures/integration/llhls-delta-byte-range.m3u8 +30 -0
  80. package/test/fixtures/integration/llhls.js +214 -0
  81. package/test/fixtures/integration/llhls.m3u8 +56 -0
  82. package/test/fixtures/integration/llhlsDelta.js +186 -0
  83. package/test/fixtures/integration/llhlsDelta.m3u8 +50 -0
  84. package/test/fixtures/integration/manifestExtTTargetdurationNegative.js +14 -0
  85. package/test/fixtures/integration/manifestExtTTargetdurationNegative.m3u8 +5 -0
  86. package/test/fixtures/integration/manifestExtXEndlistEarly.js +35 -0
  87. package/test/fixtures/integration/manifestExtXEndlistEarly.m3u8 +14 -0
  88. package/test/fixtures/integration/manifestNoExtM3u.js +15 -0
  89. package/test/fixtures/integration/manifestNoExtM3u.m3u8 +4 -0
  90. package/test/fixtures/integration/master-fmp4.js +465 -0
  91. package/test/fixtures/integration/master-fmp4.m3u8 +76 -0
  92. package/test/fixtures/integration/master.js +57 -0
  93. package/test/fixtures/integration/master.m3u8 +10 -0
  94. package/test/fixtures/integration/media.js +31 -0
  95. package/test/fixtures/integration/media.m3u8 +12 -0
  96. package/test/fixtures/integration/mediaSequence.js +31 -0
  97. package/test/fixtures/integration/mediaSequence.m3u8 +14 -0
  98. package/test/fixtures/integration/missingEndlist.js +19 -0
  99. package/test/fixtures/integration/missingEndlist.m3u8 +6 -0
  100. package/test/fixtures/integration/missingExtinf.js +27 -0
  101. package/test/fixtures/integration/missingExtinf.m3u8 +11 -0
  102. package/test/fixtures/integration/missingMediaSequence.js +31 -0
  103. package/test/fixtures/integration/missingMediaSequence.m3u8 +13 -0
  104. package/test/fixtures/integration/missingSegmentDuration.js +31 -0
  105. package/test/fixtures/integration/missingSegmentDuration.m3u8 +11 -0
  106. package/test/fixtures/integration/multipleAudioGroups.js +89 -0
  107. package/test/fixtures/integration/multipleAudioGroups.m3u8 +17 -0
  108. package/test/fixtures/integration/multipleAudioGroupsCombinedMain.js +88 -0
  109. package/test/fixtures/integration/multipleAudioGroupsCombinedMain.m3u8 +17 -0
  110. package/test/fixtures/integration/multipleTargetDurations.js +28 -0
  111. package/test/fixtures/integration/multipleTargetDurations.m3u8 +8 -0
  112. package/test/fixtures/integration/multipleVideo.js +74 -0
  113. package/test/fixtures/integration/multipleVideo.m3u8 +16 -0
  114. package/test/fixtures/integration/negativeMediaSequence.js +31 -0
  115. package/test/fixtures/integration/negativeMediaSequence.m3u8 +14 -0
  116. package/test/fixtures/integration/playlist.js +165 -0
  117. package/test/fixtures/integration/playlist.m3u8 +57 -0
  118. package/test/fixtures/integration/playlistMediaSequenceHigher.js +16 -0
  119. package/test/fixtures/integration/playlistMediaSequenceHigher.m3u8 +8 -0
  120. package/test/fixtures/integration/start.js +36 -0
  121. package/test/fixtures/integration/start.m3u8 +13 -0
  122. package/test/fixtures/integration/streamInfInvalid.js +24 -0
  123. package/test/fixtures/integration/streamInfInvalid.m3u8 +6 -0
  124. package/test/fixtures/integration/twoMediaSequences.js +31 -0
  125. package/test/fixtures/integration/twoMediaSequences.m3u8 +15 -0
  126. package/test/fixtures/integration/versionInvalid.js +16 -0
  127. package/test/fixtures/integration/versionInvalid.m3u8 +8 -0
  128. package/test/fixtures/integration/whiteSpace.js +31 -0
  129. package/test/fixtures/integration/whiteSpace.m3u8 +13 -0
  130. package/test/fixtures/integration/zeroDuration.js +16 -0
  131. package/test/fixtures/integration/zeroDuration.m3u8 +7 -0
  132. package/test/line-stream.test.js +80 -0
  133. package/test/parse-stream.test.js +903 -0
  134. package/test/parser.test.js +884 -0
package/src/parser.js ADDED
@@ -0,0 +1,748 @@
1
+ /**
2
+ * @file m3u8/parser.js
3
+ */
4
+ import Stream from '@videojs/vhs-utils/es/stream.js';
5
+ import decodeB64ToUint8Array from '@videojs/vhs-utils/es/decode-b64-to-uint8-array.js';
6
+ import LineStream from './line-stream';
7
+ import ParseStream from './parse-stream';
8
+
9
+ const camelCase = (str) => str
10
+ .toLowerCase()
11
+ .replace(/-(\w)/g, (a) => a[1].toUpperCase());
12
+
13
+ const camelCaseKeys = function(attributes) {
14
+ const result = {};
15
+
16
+ Object.keys(attributes).forEach(function(key) {
17
+ result[camelCase(key)] = attributes[key];
18
+ });
19
+
20
+ return result;
21
+ };
22
+
23
+ // set SERVER-CONTROL hold back based upon targetDuration and partTargetDuration
24
+ // we need this helper because defaults are based upon targetDuration and
25
+ // partTargetDuration being set, but they may not be if SERVER-CONTROL appears before
26
+ // target durations are set.
27
+ const setHoldBack = function(manifest) {
28
+ const { serverControl, targetDuration, partTargetDuration } = manifest;
29
+
30
+ if (!serverControl) {
31
+ return;
32
+ }
33
+
34
+ const tag = '#EXT-X-SERVER-CONTROL';
35
+ const hb = 'holdBack';
36
+ const phb = 'partHoldBack';
37
+ const minTargetDuration = targetDuration && targetDuration * 3;
38
+ const minPartDuration = partTargetDuration && partTargetDuration * 2;
39
+
40
+ if (targetDuration && !serverControl.hasOwnProperty(hb)) {
41
+ serverControl[hb] = minTargetDuration;
42
+ this.trigger('info', {
43
+ message: `${tag} defaulting HOLD-BACK to targetDuration * 3 (${minTargetDuration}).`
44
+ });
45
+ }
46
+
47
+ if (minTargetDuration && serverControl[hb] < minTargetDuration) {
48
+ this.trigger('warn', {
49
+ message: `${tag} clamping HOLD-BACK (${serverControl[hb]}) to targetDuration * 3 (${minTargetDuration})`
50
+ });
51
+ serverControl[hb] = minTargetDuration;
52
+ }
53
+
54
+ // default no part hold back to part target duration * 3
55
+ if (partTargetDuration && !serverControl.hasOwnProperty(phb)) {
56
+ serverControl[phb] = partTargetDuration * 3;
57
+ this.trigger('info', {
58
+ message: `${tag} defaulting PART-HOLD-BACK to partTargetDuration * 3 (${serverControl[phb]}).`
59
+ });
60
+ }
61
+
62
+ // if part hold back is too small default it to part target duration * 2
63
+ if (partTargetDuration && serverControl[phb] < (minPartDuration)) {
64
+ this.trigger('warn', {
65
+ message: `${tag} clamping PART-HOLD-BACK (${serverControl[phb]}) to partTargetDuration * 2 (${minPartDuration}).`
66
+ });
67
+
68
+ serverControl[phb] = minPartDuration;
69
+ }
70
+ };
71
+
72
+ /**
73
+ * A parser for M3U8 files. The current interpretation of the input is
74
+ * exposed as a property `manifest` on parser objects. It's just two lines to
75
+ * create and parse a manifest once you have the contents available as a string:
76
+ *
77
+ * ```js
78
+ * var parser = new m3u8.Parser();
79
+ * parser.push(xhr.responseText);
80
+ * ```
81
+ *
82
+ * New input can later be applied to update the manifest object by calling
83
+ * `push` again.
84
+ *
85
+ * The parser attempts to create a usable manifest object even if the
86
+ * underlying input is somewhat nonsensical. It emits `info` and `warning`
87
+ * events during the parse if it encounters input that seems invalid or
88
+ * requires some property of the manifest object to be defaulted.
89
+ *
90
+ * @class Parser
91
+ * @extends Stream
92
+ */
93
+ export default class Parser extends Stream {
94
+ constructor() {
95
+ super();
96
+ this.lineStream = new LineStream();
97
+ this.parseStream = new ParseStream();
98
+ this.lineStream.pipe(this.parseStream);
99
+
100
+ /* eslint-disable consistent-this */
101
+ const self = this;
102
+ /* eslint-enable consistent-this */
103
+ const uris = [];
104
+ let currentUri = {};
105
+ // if specified, the active EXT-X-MAP definition
106
+ let currentMap;
107
+ // if specified, the active decryption key
108
+ let key;
109
+ let hasParts = false;
110
+ const noop = function() { };
111
+ const defaultMediaGroups = {
112
+ 'AUDIO': {},
113
+ 'VIDEO': {},
114
+ 'CLOSED-CAPTIONS': {},
115
+ 'SUBTITLES': {}
116
+ };
117
+ // This is the Widevine UUID from DASH IF IOP. The same exact string is
118
+ // used in MPDs with Widevine encrypted streams.
119
+ const widevineUuid = 'urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed';
120
+ // group segments into numbered timelines delineated by discontinuities
121
+ let currentTimeline = 0;
122
+
123
+ // the manifest is empty until the parse stream begins delivering data
124
+ this.manifest = {
125
+ allowCache: true,
126
+ discontinuityStarts: [],
127
+ segments: []
128
+ };
129
+ // keep track of the last seen segment's byte range end, as segments are not required
130
+ // to provide the offset, in which case it defaults to the next byte after the
131
+ // previous segment
132
+ let lastByterangeEnd = 0;
133
+ // keep track of the last seen part's byte range end.
134
+ let lastPartByterangeEnd = 0;
135
+
136
+ // track where next segment starts
137
+ let nextSegmentLineNumberStart = 0;
138
+
139
+ this.on('end', () => {
140
+ // only add preloadSegment if we don't yet have a uri for it.
141
+ // and we actually have parts/preloadHints
142
+ if (currentUri.uri || (!currentUri.parts && !currentUri.preloadHints)) {
143
+ return;
144
+ }
145
+ if (!currentUri.map && currentMap) {
146
+ currentUri.map = currentMap;
147
+ }
148
+
149
+ if (!currentUri.key && key) {
150
+ currentUri.key = key;
151
+ }
152
+
153
+ if (!currentUri.timeline && typeof currentTimeline === 'number') {
154
+ currentUri.timeline = currentTimeline;
155
+ }
156
+
157
+ this.manifest.preloadSegment = currentUri;
158
+ });
159
+
160
+ // update the manifest with the m3u8 entry from the parse stream
161
+ this.parseStream.on('data', function(entry) {
162
+ let mediaGroup;
163
+ let rendition;
164
+
165
+ // starting a new segment
166
+ if (!Object.keys(currentUri).length) {
167
+ nextSegmentLineNumberStart = this.lineNumber;
168
+ }
169
+
170
+ ({
171
+ tag() {
172
+ // switch based on the tag type
173
+ (({
174
+ version() {
175
+ if (entry.version) {
176
+ this.manifest.version = entry.version;
177
+ }
178
+ },
179
+ 'allow-cache'() {
180
+ this.manifest.allowCache = entry.allowed;
181
+ if (!('allowed' in entry)) {
182
+ this.trigger('info', {
183
+ message: 'defaulting allowCache to YES'
184
+ });
185
+ this.manifest.allowCache = true;
186
+ }
187
+ },
188
+ byterange() {
189
+ const byterange = {};
190
+
191
+ if ('length' in entry) {
192
+ currentUri.byterange = byterange;
193
+ byterange.length = entry.length;
194
+
195
+ if (!('offset' in entry)) {
196
+ /*
197
+ * From the latest spec (as of this writing):
198
+ * https://tools.ietf.org/html/draft-pantos-http-live-streaming-23#section-4.3.2.2
199
+ *
200
+ * Same text since EXT-X-BYTERANGE's introduction in draft 7:
201
+ * https://tools.ietf.org/html/draft-pantos-http-live-streaming-07#section-3.3.1)
202
+ *
203
+ * "If o [offset] is not present, the sub-range begins at the next byte
204
+ * following the sub-range of the previous media segment."
205
+ */
206
+ entry.offset = lastByterangeEnd;
207
+ }
208
+ }
209
+ if ('offset' in entry) {
210
+ currentUri.byterange = byterange;
211
+ byterange.offset = entry.offset;
212
+ }
213
+ lastByterangeEnd = byterange.offset + byterange.length;
214
+ },
215
+ endlist() {
216
+ this.manifest.endList = true;
217
+ },
218
+ inf() {
219
+ if (!('mediaSequence' in this.manifest)) {
220
+ this.manifest.mediaSequence = 0;
221
+ this.trigger('info', {
222
+ message: 'defaulting media sequence to zero'
223
+ });
224
+ }
225
+ if (!('discontinuitySequence' in this.manifest)) {
226
+ this.manifest.discontinuitySequence = 0;
227
+ this.trigger('info', {
228
+ message: 'defaulting discontinuity sequence to zero'
229
+ });
230
+ }
231
+ if (entry.duration > 0) {
232
+ currentUri.duration = entry.duration;
233
+ }
234
+
235
+ if (entry.duration === 0) {
236
+ currentUri.duration = 0.01;
237
+ this.trigger('info', {
238
+ message: 'updating zero segment duration to a small value'
239
+ });
240
+ }
241
+
242
+ this.manifest.segments = uris;
243
+ },
244
+ key() {
245
+ if (!entry.attributes) {
246
+ this.trigger('warn', {
247
+ message: 'ignoring key declaration without attribute list'
248
+ });
249
+ return;
250
+ }
251
+ // clear the active encryption key
252
+ if (entry.attributes.METHOD === 'NONE') {
253
+ key = null;
254
+ return;
255
+ }
256
+ if (!entry.attributes.URI) {
257
+ this.trigger('warn', {
258
+ message: 'ignoring key declaration without URI'
259
+ });
260
+ return;
261
+ }
262
+
263
+ if (entry.attributes.KEYFORMAT === 'com.apple.streamingkeydelivery') {
264
+ this.manifest.contentProtection = this.manifest.contentProtection || {};
265
+
266
+ // TODO: add full support for this.
267
+ this.manifest.contentProtection['com.apple.fps.1_0'] = {
268
+ attributes: entry.attributes
269
+ };
270
+
271
+ return;
272
+ }
273
+
274
+ if (entry.attributes.KEYFORMAT === 'com.microsoft.playready') {
275
+ this.manifest.contentProtection = this.manifest.contentProtection || {};
276
+
277
+ // TODO: add full support for this.
278
+ this.manifest.contentProtection['com.microsoft.playready'] = {
279
+ uri: entry.attributes.URI
280
+ };
281
+
282
+ return;
283
+ }
284
+
285
+ // check if the content is encrypted for Widevine
286
+ // Widevine/HLS spec: https://storage.googleapis.com/wvdocs/Widevine_DRM_HLS.pdf
287
+ if (entry.attributes.KEYFORMAT === widevineUuid) {
288
+ const VALID_METHODS = ['SAMPLE-AES', 'SAMPLE-AES-CTR', 'SAMPLE-AES-CENC'];
289
+
290
+ if (VALID_METHODS.indexOf(entry.attributes.METHOD) === -1) {
291
+ this.trigger('warn', {
292
+ message: 'invalid key method provided for Widevine'
293
+ });
294
+ return;
295
+ }
296
+
297
+ if (entry.attributes.METHOD === 'SAMPLE-AES-CENC') {
298
+ this.trigger('warn', {
299
+ message: 'SAMPLE-AES-CENC is deprecated, please use SAMPLE-AES-CTR instead'
300
+ });
301
+ }
302
+
303
+ if (entry.attributes.URI.substring(0, 23) !== 'data:text/plain;base64,') {
304
+ this.trigger('warn', {
305
+ message: 'invalid key URI provided for Widevine'
306
+ });
307
+ return;
308
+ }
309
+
310
+ if (!(entry.attributes.KEYID && entry.attributes.KEYID.substring(0, 2) === '0x')) {
311
+ this.trigger('warn', {
312
+ message: 'invalid key ID provided for Widevine'
313
+ });
314
+ return;
315
+ }
316
+
317
+ // if Widevine key attributes are valid, store them as `contentProtection`
318
+ // on the manifest to emulate Widevine tag structure in a DASH mpd
319
+ this.manifest.contentProtection = this.manifest.contentProtection || {};
320
+ this.manifest.contentProtection['com.widevine.alpha'] = {
321
+ attributes: {
322
+ schemeIdUri: entry.attributes.KEYFORMAT,
323
+ // remove '0x' from the key id string
324
+ keyId: entry.attributes.KEYID.substring(2)
325
+ },
326
+ // decode the base64-encoded PSSH box
327
+ pssh: decodeB64ToUint8Array(entry.attributes.URI.split(',')[1])
328
+ };
329
+ return;
330
+ }
331
+
332
+ if (!entry.attributes.METHOD) {
333
+ this.trigger('warn', {
334
+ message: 'defaulting key method to AES-128'
335
+ });
336
+ }
337
+
338
+ // setup an encryption key for upcoming segments
339
+ key = {
340
+ method: entry.attributes.METHOD || 'AES-128',
341
+ uri: entry.attributes.URI
342
+ };
343
+
344
+ if (typeof entry.attributes.IV !== 'undefined') {
345
+ key.iv = entry.attributes.IV;
346
+ }
347
+ },
348
+ 'media-sequence'() {
349
+ if (!isFinite(entry.number)) {
350
+ this.trigger('warn', {
351
+ message: 'ignoring invalid media sequence: ' + entry.number
352
+ });
353
+ return;
354
+ }
355
+ this.manifest.mediaSequence = entry.number;
356
+ },
357
+ 'discontinuity-sequence'() {
358
+ if (!isFinite(entry.number)) {
359
+ this.trigger('warn', {
360
+ message: 'ignoring invalid discontinuity sequence: ' + entry.number
361
+ });
362
+ return;
363
+ }
364
+ this.manifest.discontinuitySequence = entry.number;
365
+ currentTimeline = entry.number;
366
+ },
367
+ 'playlist-type'() {
368
+ if (!(/VOD|EVENT/).test(entry.playlistType)) {
369
+ this.trigger('warn', {
370
+ message: 'ignoring unknown playlist type: ' + entry.playlist
371
+ });
372
+ return;
373
+ }
374
+ this.manifest.playlistType = entry.playlistType;
375
+ },
376
+ map() {
377
+ currentMap = {};
378
+ if (entry.uri) {
379
+ currentMap.uri = entry.uri;
380
+ }
381
+ if (entry.byterange) {
382
+ currentMap.byterange = entry.byterange;
383
+ }
384
+
385
+ if (key) {
386
+ currentMap.key = key;
387
+ }
388
+ },
389
+ 'stream-inf'() {
390
+ this.manifest.playlists = uris;
391
+ this.manifest.mediaGroups =
392
+ this.manifest.mediaGroups || defaultMediaGroups;
393
+
394
+ if (!entry.attributes) {
395
+ this.trigger('warn', {
396
+ message: 'ignoring empty stream-inf attributes'
397
+ });
398
+ return;
399
+ }
400
+
401
+ if (!currentUri.attributes) {
402
+ currentUri.attributes = {};
403
+ }
404
+ Object.assign(currentUri.attributes, entry.attributes);
405
+ },
406
+ media() {
407
+ this.manifest.mediaGroups =
408
+ this.manifest.mediaGroups || defaultMediaGroups;
409
+
410
+ if (!(entry.attributes &&
411
+ entry.attributes.TYPE &&
412
+ entry.attributes['GROUP-ID'] &&
413
+ entry.attributes.NAME)) {
414
+ this.trigger('warn', {
415
+ message: 'ignoring incomplete or missing media group'
416
+ });
417
+ return;
418
+ }
419
+
420
+ // find the media group, creating defaults as necessary
421
+ const mediaGroupType = this.manifest.mediaGroups[entry.attributes.TYPE];
422
+
423
+ mediaGroupType[entry.attributes['GROUP-ID']] =
424
+ mediaGroupType[entry.attributes['GROUP-ID']] || {};
425
+ mediaGroup = mediaGroupType[entry.attributes['GROUP-ID']];
426
+
427
+ // collect the rendition metadata
428
+ rendition = {
429
+ default: (/yes/i).test(entry.attributes.DEFAULT)
430
+ };
431
+ if (rendition.default) {
432
+ rendition.autoselect = true;
433
+ } else {
434
+ rendition.autoselect = (/yes/i).test(entry.attributes.AUTOSELECT);
435
+ }
436
+ if (entry.attributes.LANGUAGE) {
437
+ rendition.language = entry.attributes.LANGUAGE;
438
+ }
439
+ if (entry.attributes.URI) {
440
+ rendition.uri = entry.attributes.URI;
441
+ }
442
+ if (entry.attributes['INSTREAM-ID']) {
443
+ rendition.instreamId = entry.attributes['INSTREAM-ID'];
444
+ }
445
+ if (entry.attributes.CHARACTERISTICS) {
446
+ rendition.characteristics = entry.attributes.CHARACTERISTICS;
447
+ }
448
+ if (entry.attributes.FORCED) {
449
+ rendition.forced = (/yes/i).test(entry.attributes.FORCED);
450
+ }
451
+
452
+ // insert the new rendition
453
+ mediaGroup[entry.attributes.NAME] = rendition;
454
+ },
455
+ discontinuity() {
456
+ currentTimeline += 1;
457
+ currentUri.discontinuity = true;
458
+ this.manifest.discontinuityStarts.push(uris.length);
459
+ },
460
+ 'program-date-time'() {
461
+ if (typeof this.manifest.dateTimeString === 'undefined') {
462
+ // PROGRAM-DATE-TIME is a media-segment tag, but for backwards
463
+ // compatibility, we add the first occurence of the PROGRAM-DATE-TIME tag
464
+ // to the manifest object
465
+ // TODO: Consider removing this in future major version
466
+ this.manifest.dateTimeString = entry.dateTimeString;
467
+ this.manifest.dateTimeObject = entry.dateTimeObject;
468
+ }
469
+
470
+ currentUri.dateTimeString = entry.dateTimeString;
471
+ currentUri.dateTimeObject = entry.dateTimeObject;
472
+ },
473
+ targetduration() {
474
+ if (!isFinite(entry.duration) || entry.duration < 0) {
475
+ this.trigger('warn', {
476
+ message: 'ignoring invalid target duration: ' + entry.duration
477
+ });
478
+ return;
479
+ }
480
+ this.manifest.targetDuration = entry.duration;
481
+
482
+ setHoldBack.call(this, this.manifest);
483
+ },
484
+ start() {
485
+ if (!entry.attributes || isNaN(entry.attributes['TIME-OFFSET'])) {
486
+ this.trigger('warn', {
487
+ message: 'ignoring start declaration without appropriate attribute list'
488
+ });
489
+ return;
490
+ }
491
+ this.manifest.start = {
492
+ timeOffset: entry.attributes['TIME-OFFSET'],
493
+ precise: entry.attributes.PRECISE
494
+ };
495
+ },
496
+ 'cue-out'() {
497
+ currentUri.cueOut = entry.data;
498
+ },
499
+ 'cue-out-cont'() {
500
+ currentUri.cueOutCont = entry.data;
501
+ },
502
+ 'cue-in'() {
503
+ currentUri.cueIn = entry.data;
504
+ },
505
+ 'skip'() {
506
+ this.manifest.skip = camelCaseKeys(entry.attributes);
507
+
508
+ this.warnOnMissingAttributes_(
509
+ '#EXT-X-SKIP',
510
+ entry.attributes,
511
+ ['SKIPPED-SEGMENTS']
512
+ );
513
+ },
514
+ 'part'() {
515
+ hasParts = true;
516
+ // parts are always specifed before a segment
517
+ const segmentIndex = this.manifest.segments.length;
518
+ const part = camelCaseKeys(entry.attributes);
519
+
520
+ currentUri.parts = currentUri.parts || [];
521
+ currentUri.parts.push(part);
522
+
523
+ if (part.byterange) {
524
+ if (!part.byterange.hasOwnProperty('offset')) {
525
+ part.byterange.offset = lastPartByterangeEnd;
526
+ }
527
+ lastPartByterangeEnd = part.byterange.offset + part.byterange.length;
528
+ }
529
+
530
+ const partIndex = currentUri.parts.length - 1;
531
+
532
+ this.warnOnMissingAttributes_(
533
+ `#EXT-X-PART #${partIndex} for segment #${segmentIndex}`,
534
+ entry.attributes,
535
+ ['URI', 'DURATION']
536
+ );
537
+
538
+ if (this.manifest.renditionReports) {
539
+ this.manifest.renditionReports.forEach((r, i) => {
540
+ if (!r.hasOwnProperty('lastPart')) {
541
+ this.trigger('warn', {
542
+ message: `#EXT-X-RENDITION-REPORT #${i} lacks required attribute(s): LAST-PART`
543
+ });
544
+ }
545
+ });
546
+ }
547
+ },
548
+ 'server-control'() {
549
+ const attrs = this.manifest.serverControl = camelCaseKeys(entry.attributes);
550
+
551
+ if (!attrs.hasOwnProperty('canBlockReload')) {
552
+ attrs.canBlockReload = false;
553
+ this.trigger('info', {
554
+ message: '#EXT-X-SERVER-CONTROL defaulting CAN-BLOCK-RELOAD to false'
555
+ });
556
+ }
557
+ setHoldBack.call(this, this.manifest);
558
+
559
+ if (attrs.canSkipDateranges && !attrs.hasOwnProperty('canSkipUntil')) {
560
+ this.trigger('warn', {
561
+ message: '#EXT-X-SERVER-CONTROL lacks required attribute CAN-SKIP-UNTIL which is required when CAN-SKIP-DATERANGES is set'
562
+ });
563
+ }
564
+ },
565
+ 'preload-hint'() {
566
+ // parts are always specifed before a segment
567
+ const segmentIndex = this.manifest.segments.length;
568
+ const hint = camelCaseKeys(entry.attributes);
569
+ const isPart = hint.type && hint.type === 'PART';
570
+
571
+ currentUri.preloadHints = currentUri.preloadHints || [];
572
+ currentUri.preloadHints.push(hint);
573
+
574
+ if (hint.byterange) {
575
+
576
+ if (!hint.byterange.hasOwnProperty('offset')) {
577
+ // use last part byterange end or zero if not a part.
578
+ hint.byterange.offset = isPart ? lastPartByterangeEnd : 0;
579
+ if (isPart) {
580
+ lastPartByterangeEnd = hint.byterange.offset + hint.byterange.length;
581
+ }
582
+ }
583
+ }
584
+ const index = currentUri.preloadHints.length - 1;
585
+
586
+ this.warnOnMissingAttributes_(
587
+ `#EXT-X-PRELOAD-HINT #${index} for segment #${segmentIndex}`,
588
+ entry.attributes,
589
+ ['TYPE', 'URI']
590
+ );
591
+
592
+ if (!hint.type) {
593
+ return;
594
+ }
595
+ // search through all preload hints except for the current one for
596
+ // a duplicate type.
597
+ for (let i = 0; i < currentUri.preloadHints.length - 1; i++) {
598
+ const otherHint = currentUri.preloadHints[i];
599
+
600
+ if (!otherHint.type) {
601
+ continue;
602
+ }
603
+
604
+ if (otherHint.type === hint.type) {
605
+ this.trigger('warn', {
606
+ message: `#EXT-X-PRELOAD-HINT #${index} for segment #${segmentIndex} has the same TYPE ${hint.type} as preload hint #${i}`
607
+ });
608
+ }
609
+ }
610
+ },
611
+ 'rendition-report'() {
612
+ const report = camelCaseKeys(entry.attributes);
613
+
614
+ this.manifest.renditionReports = this.manifest.renditionReports || [];
615
+ this.manifest.renditionReports.push(report);
616
+ const index = this.manifest.renditionReports.length - 1;
617
+ const required = ['LAST-MSN', 'URI'];
618
+
619
+ if (hasParts) {
620
+ required.push('LAST-PART');
621
+ }
622
+
623
+ this.warnOnMissingAttributes_(
624
+ `#EXT-X-RENDITION-REPORT #${index}`,
625
+ entry.attributes,
626
+ required
627
+ );
628
+ },
629
+ 'part-inf'() {
630
+ this.manifest.partInf = camelCaseKeys(entry.attributes);
631
+
632
+ this.warnOnMissingAttributes_(
633
+ '#EXT-X-PART-INF',
634
+ entry.attributes,
635
+ ['PART-TARGET']
636
+ );
637
+ if (this.manifest.partInf.partTarget) {
638
+ this.manifest.partTargetDuration = this.manifest.partInf.partTarget;
639
+ }
640
+
641
+ setHoldBack.call(this, this.manifest);
642
+ }
643
+ })[entry.tagType] || noop).call(self);
644
+ },
645
+ uri() {
646
+ currentUri.uri = entry.uri;
647
+ currentUri.lineNumberStart = nextSegmentLineNumberStart;
648
+ currentUri.lineNumberEnd = this.parseStream.lineNumber;
649
+ uris.push(currentUri);
650
+
651
+ // if no explicit duration was declared, use the target duration
652
+ if (this.manifest.targetDuration && !('duration' in currentUri)) {
653
+ this.trigger('warn', {
654
+ message: 'defaulting segment duration to the target duration'
655
+ });
656
+ currentUri.duration = this.manifest.targetDuration;
657
+ }
658
+ // annotate with encryption information, if necessary
659
+ if (key) {
660
+ currentUri.key = key;
661
+ }
662
+ currentUri.timeline = currentTimeline;
663
+ // annotate with initialization segment information, if necessary
664
+ if (currentMap) {
665
+ currentUri.map = currentMap;
666
+ }
667
+
668
+ // reset the last byterange end as it needs to be 0 between parts
669
+ lastPartByterangeEnd = 0;
670
+
671
+ // prepare for the next URI
672
+ currentUri = {};
673
+ },
674
+ comment() {
675
+ // comments are not important for playback
676
+ },
677
+ custom() {
678
+ // if this is segment-level data attach the output to the segment
679
+ if (entry.segment) {
680
+ currentUri.custom = currentUri.custom || {};
681
+ currentUri.custom[entry.customType] = entry.data;
682
+ // if this is manifest-level data attach to the top level manifest object
683
+ } else {
684
+ this.manifest.custom = this.manifest.custom || {};
685
+ this.manifest.custom[entry.customType] = entry.data;
686
+ }
687
+ }
688
+ })[entry.type].call(self);
689
+ });
690
+ }
691
+
692
+ warnOnMissingAttributes_(identifier, attributes, required) {
693
+ const missing = [];
694
+
695
+ required.forEach(function(key) {
696
+ if (!attributes.hasOwnProperty(key)) {
697
+ missing.push(key);
698
+ }
699
+ });
700
+
701
+ if (missing.length) {
702
+ this.trigger('warn', { message: `${identifier} lacks required attribute(s): ${missing.join(', ')}` });
703
+ }
704
+ }
705
+
706
+ /**
707
+ * Parse the input string and update the manifest object.
708
+ *
709
+ * @param {string} chunk a potentially incomplete portion of the manifest
710
+ */
711
+ push(chunk) {
712
+ this.lineStream.push(chunk);
713
+ }
714
+
715
+ /**
716
+ * Flush any remaining input. This can be handy if the last line of an M3U8
717
+ * manifest did not contain a trailing newline but the file has been
718
+ * completely received.
719
+ */
720
+ end() {
721
+ // flush any buffered input
722
+ this.lineStream.push('\n');
723
+
724
+ this.trigger('end');
725
+ }
726
+ /**
727
+ * Add an additional parser for non-standard tags
728
+ *
729
+ * @param {Object} options a map of options for the added parser
730
+ * @param {RegExp} options.expression a regular expression to match the custom header
731
+ * @param {string} options.type the type to register to the output
732
+ * @param {Function} [options.dataParser] function to parse the line into an object
733
+ * @param {boolean} [options.segment] should tag data be attached to the segment object
734
+ */
735
+ addParser(options) {
736
+ this.parseStream.addParser(options);
737
+ }
738
+ /**
739
+ * Add a custom header mapper
740
+ *
741
+ * @param {Object} options
742
+ * @param {RegExp} options.expression a regular expression to match the custom header
743
+ * @param {Function} options.map function to translate tag into a different tag
744
+ */
745
+ addTagMapper(options) {
746
+ this.parseStream.addTagMapper(options);
747
+ }
748
+ }