@warren-bank/hls-proxy 3.1.2 → 3.2.1

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.
@@ -1,489 +1,489 @@
1
- const {URL} = require('@warren-bank/url')
2
- const utils = require('./utils')
3
-
4
- const regexs = {
5
- vod_start_at: /#vod_start(?:_prefetch_at)?=((?:\d+:)?(?:\d+:)?\d+)$/i,
6
- m3u8_line_separator: /\s*[\r\n]+\s*/,
7
- m3u8_line_landmark: /^(#[^:]+[:]?)/,
8
- m3u8_line_url: /URI=["']([^"']+)["']/id
9
- }
10
-
11
- const url_location_landmarks = {
12
- m3u8: {
13
- same_line: [
14
- '#EXT-X-MEDIA:',
15
- '#EXT-X-I-FRAME-STREAM-INF:',
16
- '#EXT-X-RENDITION-REPORT:',
17
- '#EXT-X-DATERANGE:',
18
- '#EXT-X-CONTENT-STEERING:'
19
- ],
20
- next_line: [
21
- '#EXT-X-STREAM-INF:'
22
- ]
23
- },
24
- ts: {
25
- same_line: [
26
- '#EXT-X-MAP:',
27
- '#EXT-X-PART:',
28
- '#EXT-X-PRELOAD-HINT:'
29
- ],
30
- next_line: [
31
- '#EXTINF:'
32
- ]
33
- },
34
- json: {
35
- same_line: [
36
- '#EXT-X-SESSION-DATA:'
37
- ],
38
- next_line: []
39
- },
40
- key: {
41
- same_line: [
42
- '#EXT-X-KEY:'
43
- ],
44
- next_line: []
45
- },
46
- other: {
47
- same_line: [],
48
- next_line: []
49
- }
50
- }
51
-
52
- const meta_data_location_landmarks = {
53
- is_vod: {
54
- same_line: [
55
- '#EXT-X-PLAYLIST-TYPE:',
56
- '#EXT-X-ENDLIST'
57
- ],
58
- resolve_value: {
59
- '#EXT-X-PLAYLIST-TYPE:': (m3u8_line, landmark) => {
60
- const value = m3u8_line.substring(landmark.length, landmark.length + 3)
61
- return (value.toUpperCase() === 'VOD')
62
- },
63
- '#EXT-X-ENDLIST': () => true
64
- }
65
- },
66
- seg_duration_ms: {
67
- same_line: [
68
- '#EXT-X-TARGETDURATION:'
69
- ],
70
- resolve_value: {
71
- '#EXT-X-TARGETDURATION:': (m3u8_line, landmark) => {
72
- m3u8_line = m3u8_line.substring(landmark.length)
73
- const value = parseInt(m3u8_line, 10)
74
- return isNaN(value)
75
- ? null
76
- : (value * 1000) // convert seconds to ms
77
- }
78
- }
79
- }
80
- }
81
-
82
- const get_vod_start_at_ms = function(m3u8_url) {
83
- try {
84
- const matches = regexs.vod_start_at.exec(m3u8_url)
85
-
86
- if ((matches == null) || !Array.isArray(matches) || (matches.length < 2))
87
- throw ''
88
-
89
- let offset
90
- offset = matches[1]
91
- offset = parse_HHMMSS_to_seconds(offset)
92
- offset = offset * 1000 // convert seconds to ms
93
-
94
- return offset
95
- }
96
- catch(e) {
97
- const def_offset = null
98
-
99
- return def_offset
100
- }
101
- }
102
-
103
- const parse_HHMMSS_to_seconds = function(str) {
104
- const parts = str.split(':')
105
- let seconds = 0
106
- let multiplier = 1
107
-
108
- while (parts.length > 0) {
109
- seconds += multiplier * parseInt(parts.pop(), 10)
110
- multiplier *= 60
111
- }
112
-
113
- return seconds
114
- }
115
-
116
- // returns: {
117
- // meta_data: {is_vod, seg_duration_ms},
118
- // embedded_urls: [{line_index, url_indices, url_type, original_match_url, resolved_match_url, redirected_url, unencoded_url, encoded_url, referer_url}],
119
- // prefetch_urls: [],
120
- // modified_m3u8: ''
121
- // }
122
- const parse_manifest = function(m3u8_content, m3u8_url, referer_url, hooks, cache_segments, debug, vod_start_at_ms, redirected_base_url, should_prefetch_url) {
123
- const m3u8_lines = m3u8_content.split(regexs.m3u8_line_separator)
124
- m3u8_content = null
125
-
126
- const meta_data = {}
127
- const embedded_urls = extract_embedded_urls(m3u8_lines, m3u8_url, referer_url, (cache_segments ? meta_data : null))
128
- const prefetch_urls = []
129
-
130
- if (embedded_urls && Array.isArray(embedded_urls) && embedded_urls.length) {
131
- embedded_urls.forEach(embedded_url => {
132
- redirect_embedded_url(embedded_url, hooks, m3u8_url, debug)
133
- finalize_embedded_url(embedded_url, vod_start_at_ms, debug)
134
- encode_embedded_url(embedded_url, redirected_base_url, debug)
135
- get_prefetch_url(embedded_url, should_prefetch_url, prefetch_urls)
136
- modify_m3u8_line(embedded_url, m3u8_lines)
137
- })
138
- }
139
-
140
- return {
141
- meta_data,
142
- embedded_urls,
143
- prefetch_urls,
144
- modified_m3u8: m3u8_lines.filter(line => !!line).join("\n")
145
- }
146
- }
147
-
148
- const extract_embedded_urls = function(m3u8_lines, m3u8_url, referer_url, meta_data) {
149
- const embedded_urls = []
150
-
151
- let m3u8_line, has_next_m3u8_line, next_m3u8_line, matches, matching_landmark, matching_url
152
-
153
- for (let i=0; i < m3u8_lines.length; i++) {
154
- m3u8_line = m3u8_lines[i]
155
- has_next_m3u8_line = ((i+1) < m3u8_lines.length)
156
-
157
- matches = regexs.m3u8_line_landmark.exec(m3u8_line)
158
- if (!matches) continue
159
- matching_landmark = matches[1]
160
-
161
- matches = regexs.m3u8_line_url.exec(m3u8_line)
162
- matching_url = matches
163
- ? matches[1]
164
- : null
165
-
166
- if (meta_data !== null)
167
- extract_meta_data(meta_data, m3u8_line, matching_landmark)
168
-
169
- for (let url_type in url_location_landmarks) {
170
- if (matching_url && (url_location_landmarks[url_type]['same_line'].indexOf(matching_landmark) >= 0)) {
171
- embedded_urls.push({
172
- line_index: i,
173
- url_indices: matches.indices[1],
174
- url_type: url_type,
175
- original_match_url: matching_url,
176
- resolved_match_url: (new URL(matching_url, m3u8_url)).href,
177
- redirected_url: null,
178
- referer_url: referer_url,
179
- encoded_url: null
180
- })
181
- break
182
- }
183
- if (has_next_m3u8_line && (url_location_landmarks[url_type]['next_line'].indexOf(matching_landmark) >= 0)) {
184
- next_m3u8_line = m3u8_lines[i+1].trim()
185
-
186
- if (next_m3u8_line && (next_m3u8_line[0] !== '#')) {
187
- i++
188
-
189
- embedded_urls.push({
190
- line_index: i,
191
- url_indices: null,
192
- url_type: url_type,
193
- original_match_url: next_m3u8_line,
194
- resolved_match_url: (new URL(next_m3u8_line, m3u8_url)).href,
195
- redirected_url: null,
196
- referer_url: referer_url,
197
- encoded_url: null
198
- })
199
- }
200
- break
201
- }
202
- }
203
- }
204
-
205
- return embedded_urls
206
- }
207
-
208
- const extract_meta_data = function(meta_data, m3u8_line, matching_landmark) {
209
- for (let meta_data_key in meta_data_location_landmarks) {
210
- if (meta_data_location_landmarks[meta_data_key]['same_line'].indexOf(matching_landmark) >= 0) {
211
- const func = meta_data_location_landmarks[meta_data_key]['resolve_value'][matching_landmark]
212
- if (typeof func === 'function') {
213
- const meta_data_value = func(m3u8_line, matching_landmark)
214
- if ((meta_data_value !== undefined) && (meta_data_value !== null)) {
215
- meta_data[meta_data_key] = meta_data_value
216
- }
217
- }
218
- }
219
- }
220
- }
221
-
222
- const redirect_embedded_url = function(embedded_url, hooks, m3u8_url, debug) {
223
- if (hooks && (hooks instanceof Object) && hooks.redirect && (typeof hooks.redirect === 'function')) {
224
- let url, url_type, referer_url, result
225
-
226
- url = embedded_url.resolved_match_url
227
- url_type = null
228
- referer_url = null
229
-
230
- debug(3, 'redirecting (pre-hook):', url)
231
-
232
- try {
233
- result = hooks.redirect(url, embedded_url.referer_url)
234
-
235
- if (result) {
236
- if (typeof result === 'string') {
237
- url = result
238
- }
239
- else if (result instanceof Object) {
240
- url = result.url
241
- url_type = result.url_type
242
- referer_url = result.referer_url
243
- }
244
- }
245
-
246
- if (typeof url !== 'string') throw new Error('bad return value')
247
-
248
- url = url.trim()
249
-
250
- if (url.length && (url.toLowerCase().indexOf('http') !== 0))
251
- url = (new URL(url, m3u8_url)).href
252
- }
253
- catch(e) {
254
- url = ''
255
- }
256
-
257
- if (url) {
258
- embedded_url.redirected_url = url
259
-
260
- if (typeof url_type === 'string') {
261
- url_type = url_type.toLowerCase().trim()
262
-
263
- if (url_type.length)
264
- embedded_url.url_type = url_type
265
- }
266
-
267
- if (typeof referer_url === 'string') {
268
- referer_url = referer_url.trim()
269
-
270
- if (referer_url.length && (referer_url.toLowerCase().indexOf('http') === 0))
271
- embedded_url.referer_url = referer_url
272
- }
273
-
274
- debug(3, 'redirecting (post-hook):', url)
275
- }
276
- else {
277
- embedded_url.redirected_url = ''
278
-
279
- debug(3, 'redirecting (post-hook):', 'URL filtered, removed from manifest')
280
- }
281
- }
282
- }
283
-
284
- const finalize_embedded_url = function(embedded_url, vod_start_at_ms, debug) {
285
- if (embedded_url.redirected_url === '') {
286
- embedded_url.unencoded_url = ''
287
- }
288
- else {
289
- const url = embedded_url.redirected_url || embedded_url.resolved_match_url
290
-
291
- if (embedded_url.url_type)
292
- debug(3, 'url type:', embedded_url.url_type)
293
-
294
- debug(2, 'redirecting:', url)
295
-
296
- if (vod_start_at_ms && (embedded_url.url_type === 'm3u8'))
297
- url += `#vod_start=${Math.floor(vod_start_at_ms/1000)}`
298
-
299
- if (embedded_url.referer_url)
300
- url += `|${embedded_url.referer_url}`
301
-
302
- embedded_url.unencoded_url = url
303
- }
304
- }
305
-
306
- const encode_embedded_url = function(embedded_url, redirected_base_url, debug) {
307
- embedded_url.encoded_url = (embedded_url.unencoded_url)
308
- ? `${redirected_base_url}/${ utils.base64_encode(embedded_url.unencoded_url) }.${embedded_url.url_type || 'other'}`
309
- : ''
310
-
311
- if (embedded_url.encoded_url)
312
- debug(3, 'redirecting (proxied):', embedded_url.encoded_url)
313
- }
314
-
315
- const get_prefetch_url = function(embedded_url, should_prefetch_url, prefetch_urls = []) {
316
- if (embedded_url.redirected_url !== '') {
317
- const url = embedded_url.redirected_url || embedded_url.resolved_match_url
318
-
319
- if (should_prefetch_url(url, embedded_url.url_type))
320
- prefetch_urls.push(url)
321
- }
322
- }
323
-
324
- const modify_m3u8_line = function(embedded_url, m3u8_lines) {
325
- const {line_index, url_indices, encoded_url} = embedded_url
326
-
327
- if (url_indices && Array.isArray(url_indices) && (url_indices.length === 2)) {
328
- const m3u8_line = m3u8_lines[line_index]
329
-
330
- m3u8_lines[line_index] = m3u8_line.substring(0, url_indices[0]) + encoded_url + m3u8_line.substring(url_indices[1])
331
- }
332
- else {
333
- m3u8_lines[line_index] = encoded_url
334
- }
335
- }
336
-
337
- const modify_m3u8_content = function(params, segment_cache, m3u8_content, m3u8_url, referer_url, redirected_base_url) {
338
- const {hooks, cache_segments, max_segments, debug_level} = params
339
-
340
- const {has_cache, get_time_since_last_access, is_expired, prefetch_segment} = segment_cache
341
-
342
- const debug = utils.debug.bind(null, params)
343
- const should_prefetch_url = utils.should_prefetch_url.bind(null, params)
344
-
345
- if (hooks && (hooks instanceof Object) && hooks.modify_m3u8_content && (typeof hooks.modify_m3u8_content === 'function')) {
346
- m3u8_content = hooks.modify_m3u8_content(m3u8_content, m3u8_url) || m3u8_content
347
- }
348
-
349
- const debug_divider = (debug_level >= 4)
350
- ? ('-').repeat(40)
351
- : ''
352
-
353
- if (debug_level >= 4) {
354
- debug(4, 'proxied response (original m3u8):', `\n${debug_divider}\n${m3u8_content}\n${debug_divider}`)
355
- }
356
-
357
- let is_vod, seg_duration_ms, prefetch_urls
358
-
359
- // only used with prefetch
360
- const vod_start_at_ms = (cache_segments)
361
- ? get_vod_start_at_ms(m3u8_url)
362
- : null
363
-
364
- // only used with prefetch
365
- const perform_prefetch = (cache_segments)
366
- ? (urls, dont_touch_access) => {
367
- if (!urls || !Array.isArray(urls) || !urls.length)
368
- return
369
-
370
- let promise
371
-
372
- if (is_vod || has_cache(m3u8_url)) {
373
- promise = Promise.resolve()
374
- }
375
- else {
376
- const matching_url = urls[0]
377
- urls[0] = undefined
378
-
379
- promise = prefetch_segment(m3u8_url, matching_url, referer_url, dont_touch_access)
380
- }
381
-
382
- promise.then(() => {
383
- urls.forEach((matching_url, index) => {
384
- if (matching_url) {
385
- prefetch_segment(m3u8_url, matching_url, referer_url, dont_touch_access)
386
-
387
- urls[index] = undefined
388
- }
389
- })
390
- })
391
- }
392
- : null
393
-
394
- {
395
- const parsed_manifest = parse_manifest(m3u8_content, m3u8_url, referer_url, hooks, cache_segments, debug, vod_start_at_ms, redirected_base_url, should_prefetch_url)
396
- is_vod = !!parsed_manifest.meta_data.is_vod // default: false => hls live stream
397
- seg_duration_ms = parsed_manifest.meta_data.seg_duration_ms || 10000 // default: 10 seconds in ms
398
- prefetch_urls = parsed_manifest.prefetch_urls
399
- m3u8_content = parsed_manifest.modified_m3u8
400
-
401
- if (debug_level >= 4) {
402
- debug(4, 'parsed manifest:', `\n${debug_divider}\n${JSON.stringify(parsed_manifest, null, 2)}\n${debug_divider}`)
403
- }
404
- }
405
-
406
- if (prefetch_urls.length) {
407
- if (is_vod && vod_start_at_ms) {
408
- // full video: prevent prefetch of URLs for skipped video segments
409
-
410
- const skip_segment_count = Math.floor(vod_start_at_ms / seg_duration_ms)
411
-
412
- prefetch_urls.splice(0, skip_segment_count)
413
- debug(3, 'prefetch (ignored):', `${skip_segment_count} URLs in m3u8 skipped to initialize vod prefetch timer from start position obtained from HLS manifest URL #hash`)
414
- }
415
- if (prefetch_urls.length > max_segments) {
416
- if (hooks && (hooks instanceof Object) && hooks.prefetch_segments && (typeof hooks.prefetch_segments === 'function')) {
417
- prefetch_urls = hooks.prefetch_segments(prefetch_urls, max_segments, is_vod, seg_duration_ms, perform_prefetch)
418
- }
419
- else {
420
- if (!is_vod) {
421
- // live stream: cache from the end
422
-
423
- const overflow = prefetch_urls.length - max_segments
424
-
425
- prefetch_urls.splice(0, overflow)
426
- debug(3, 'prefetch (ignored):', `${overflow} URLs in m3u8 skipped to prevent cache overflow`)
427
- }
428
- else {
429
- // full video: cache from the beginning w/ timer to update cache at rate of playback (assuming no pausing or seeking)
430
-
431
- const $prefetch_urls = [...prefetch_urls]
432
- const batch_size = Math.ceil(max_segments / 2)
433
- const batch_time = seg_duration_ms * batch_size
434
-
435
- const is_client_paused = () => {
436
- const time_since_last_access = get_time_since_last_access(m3u8_url)
437
-
438
- let inactivity_timeout
439
- inactivity_timeout = seg_duration_ms * 2
440
- inactivity_timeout = Math.floor(inactivity_timeout / 1000) // convert to seconds
441
-
442
- return (time_since_last_access < 0)
443
- ? false
444
- : (time_since_last_access >= inactivity_timeout)
445
- }
446
-
447
- const prefetch_next_batch = (is_cache_empty) => {
448
- is_cache_empty = (is_cache_empty === true)
449
-
450
- if (!is_cache_empty && is_expired(m3u8_url)) {
451
- debug(3, 'prefetch (stopped):', 'vod stream removed from cache due to inactivity longer than timeout; prefetch has stopped')
452
- return
453
- }
454
-
455
- if (!is_cache_empty && is_client_paused()) {
456
- debug(3, 'prefetch (skipped):', 'vod stream is paused; prefetch will continue after client playback resumes')
457
- setTimeout(prefetch_next_batch, batch_time)
458
- return
459
- }
460
-
461
- if ($prefetch_urls.length > batch_size) {
462
- const batch_urls = $prefetch_urls.splice(0, batch_size)
463
-
464
- perform_prefetch(batch_urls, !is_cache_empty)
465
- setTimeout(prefetch_next_batch, is_cache_empty ? 0 : batch_time)
466
- }
467
- else {
468
- perform_prefetch($prefetch_urls, !is_cache_empty)
469
- }
470
- }
471
-
472
- prefetch_urls = []
473
- prefetch_next_batch(true)
474
- }
475
- }
476
- }
477
- perform_prefetch(prefetch_urls)
478
- }
479
-
480
- if (debug_level >= 4) {
481
- debug(4, 'proxied response (modified m3u8):', `\n${debug_divider}\n${m3u8_content}\n${debug_divider}`)
482
- }
483
-
484
- return m3u8_content
485
- }
486
-
487
- module.exports = {
488
- modify_m3u8_content
489
- }
1
+ const {URL} = require('@warren-bank/url')
2
+ const utils = require('./utils')
3
+
4
+ const regexs = {
5
+ vod_start_at: /#vod_start(?:_prefetch_at)?=((?:\d+:)?(?:\d+:)?\d+)$/i,
6
+ m3u8_line_separator: /\s*[\r\n]+\s*/,
7
+ m3u8_line_landmark: /^(#[^:]+[:]?)/,
8
+ m3u8_line_url: /URI=["']([^"']+)["']/id
9
+ }
10
+
11
+ const url_location_landmarks = {
12
+ m3u8: {
13
+ same_line: [
14
+ '#EXT-X-MEDIA:',
15
+ '#EXT-X-I-FRAME-STREAM-INF:',
16
+ '#EXT-X-RENDITION-REPORT:',
17
+ '#EXT-X-DATERANGE:',
18
+ '#EXT-X-CONTENT-STEERING:'
19
+ ],
20
+ next_line: [
21
+ '#EXT-X-STREAM-INF:'
22
+ ]
23
+ },
24
+ ts: {
25
+ same_line: [
26
+ '#EXT-X-MAP:',
27
+ '#EXT-X-PART:',
28
+ '#EXT-X-PRELOAD-HINT:'
29
+ ],
30
+ next_line: [
31
+ '#EXTINF:'
32
+ ]
33
+ },
34
+ json: {
35
+ same_line: [
36
+ '#EXT-X-SESSION-DATA:'
37
+ ],
38
+ next_line: []
39
+ },
40
+ key: {
41
+ same_line: [
42
+ '#EXT-X-KEY:'
43
+ ],
44
+ next_line: []
45
+ },
46
+ other: {
47
+ same_line: [],
48
+ next_line: []
49
+ }
50
+ }
51
+
52
+ const meta_data_location_landmarks = {
53
+ is_vod: {
54
+ same_line: [
55
+ '#EXT-X-PLAYLIST-TYPE:',
56
+ '#EXT-X-ENDLIST'
57
+ ],
58
+ resolve_value: {
59
+ '#EXT-X-PLAYLIST-TYPE:': (m3u8_line, landmark) => {
60
+ const value = m3u8_line.substring(landmark.length, landmark.length + 3)
61
+ return (value.toUpperCase() === 'VOD')
62
+ },
63
+ '#EXT-X-ENDLIST': () => true
64
+ }
65
+ },
66
+ seg_duration_ms: {
67
+ same_line: [
68
+ '#EXT-X-TARGETDURATION:'
69
+ ],
70
+ resolve_value: {
71
+ '#EXT-X-TARGETDURATION:': (m3u8_line, landmark) => {
72
+ m3u8_line = m3u8_line.substring(landmark.length)
73
+ const value = parseInt(m3u8_line, 10)
74
+ return isNaN(value)
75
+ ? null
76
+ : (value * 1000) // convert seconds to ms
77
+ }
78
+ }
79
+ }
80
+ }
81
+
82
+ const get_vod_start_at_ms = function(m3u8_url) {
83
+ try {
84
+ const matches = regexs.vod_start_at.exec(m3u8_url)
85
+
86
+ if ((matches == null) || !Array.isArray(matches) || (matches.length < 2))
87
+ throw ''
88
+
89
+ let offset
90
+ offset = matches[1]
91
+ offset = parse_HHMMSS_to_seconds(offset)
92
+ offset = offset * 1000 // convert seconds to ms
93
+
94
+ return offset
95
+ }
96
+ catch(e) {
97
+ const def_offset = null
98
+
99
+ return def_offset
100
+ }
101
+ }
102
+
103
+ const parse_HHMMSS_to_seconds = function(str) {
104
+ const parts = str.split(':')
105
+ let seconds = 0
106
+ let multiplier = 1
107
+
108
+ while (parts.length > 0) {
109
+ seconds += multiplier * parseInt(parts.pop(), 10)
110
+ multiplier *= 60
111
+ }
112
+
113
+ return seconds
114
+ }
115
+
116
+ // returns: {
117
+ // meta_data: {is_vod, seg_duration_ms},
118
+ // embedded_urls: [{line_index, url_indices, url_type, original_match_url, resolved_match_url, redirected_url, unencoded_url, encoded_url, referer_url}],
119
+ // prefetch_urls: [],
120
+ // modified_m3u8: ''
121
+ // }
122
+ const parse_manifest = function(m3u8_content, m3u8_url, referer_url, hooks, cache_segments, debug, vod_start_at_ms, redirected_base_url, should_prefetch_url) {
123
+ const m3u8_lines = m3u8_content.split(regexs.m3u8_line_separator)
124
+ m3u8_content = null
125
+
126
+ const meta_data = {}
127
+ const embedded_urls = extract_embedded_urls(m3u8_lines, m3u8_url, referer_url, (cache_segments ? meta_data : null))
128
+ const prefetch_urls = []
129
+
130
+ if (embedded_urls && Array.isArray(embedded_urls) && embedded_urls.length) {
131
+ embedded_urls.forEach(embedded_url => {
132
+ redirect_embedded_url(embedded_url, hooks, m3u8_url, debug)
133
+ finalize_embedded_url(embedded_url, vod_start_at_ms, debug)
134
+ encode_embedded_url(embedded_url, redirected_base_url, debug)
135
+ get_prefetch_url(embedded_url, should_prefetch_url, prefetch_urls)
136
+ modify_m3u8_line(embedded_url, m3u8_lines)
137
+ })
138
+ }
139
+
140
+ return {
141
+ meta_data,
142
+ embedded_urls,
143
+ prefetch_urls,
144
+ modified_m3u8: m3u8_lines.filter(line => !!line).join("\n")
145
+ }
146
+ }
147
+
148
+ const extract_embedded_urls = function(m3u8_lines, m3u8_url, referer_url, meta_data) {
149
+ const embedded_urls = []
150
+
151
+ let m3u8_line, has_next_m3u8_line, next_m3u8_line, matches, matching_landmark, matching_url
152
+
153
+ for (let i=0; i < m3u8_lines.length; i++) {
154
+ m3u8_line = m3u8_lines[i]
155
+ has_next_m3u8_line = ((i+1) < m3u8_lines.length)
156
+
157
+ matches = regexs.m3u8_line_landmark.exec(m3u8_line)
158
+ if (!matches) continue
159
+ matching_landmark = matches[1]
160
+
161
+ matches = regexs.m3u8_line_url.exec(m3u8_line)
162
+ matching_url = matches
163
+ ? matches[1]
164
+ : null
165
+
166
+ if (meta_data !== null)
167
+ extract_meta_data(meta_data, m3u8_line, matching_landmark)
168
+
169
+ for (let url_type in url_location_landmarks) {
170
+ if (matching_url && (url_location_landmarks[url_type]['same_line'].indexOf(matching_landmark) >= 0)) {
171
+ embedded_urls.push({
172
+ line_index: i,
173
+ url_indices: matches.indices[1],
174
+ url_type: url_type,
175
+ original_match_url: matching_url,
176
+ resolved_match_url: (new URL(matching_url, m3u8_url)).href,
177
+ redirected_url: null,
178
+ referer_url: referer_url,
179
+ encoded_url: null
180
+ })
181
+ break
182
+ }
183
+ if (has_next_m3u8_line && (url_location_landmarks[url_type]['next_line'].indexOf(matching_landmark) >= 0)) {
184
+ next_m3u8_line = m3u8_lines[i+1].trim()
185
+
186
+ if (next_m3u8_line && (next_m3u8_line[0] !== '#')) {
187
+ i++
188
+
189
+ embedded_urls.push({
190
+ line_index: i,
191
+ url_indices: null,
192
+ url_type: url_type,
193
+ original_match_url: next_m3u8_line,
194
+ resolved_match_url: (new URL(next_m3u8_line, m3u8_url)).href,
195
+ redirected_url: null,
196
+ referer_url: referer_url,
197
+ encoded_url: null
198
+ })
199
+ }
200
+ break
201
+ }
202
+ }
203
+ }
204
+
205
+ return embedded_urls
206
+ }
207
+
208
+ const extract_meta_data = function(meta_data, m3u8_line, matching_landmark) {
209
+ for (let meta_data_key in meta_data_location_landmarks) {
210
+ if (meta_data_location_landmarks[meta_data_key]['same_line'].indexOf(matching_landmark) >= 0) {
211
+ const func = meta_data_location_landmarks[meta_data_key]['resolve_value'][matching_landmark]
212
+ if (typeof func === 'function') {
213
+ const meta_data_value = func(m3u8_line, matching_landmark)
214
+ if ((meta_data_value !== undefined) && (meta_data_value !== null)) {
215
+ meta_data[meta_data_key] = meta_data_value
216
+ }
217
+ }
218
+ }
219
+ }
220
+ }
221
+
222
+ const redirect_embedded_url = function(embedded_url, hooks, m3u8_url, debug) {
223
+ if (hooks && (hooks instanceof Object) && hooks.redirect && (typeof hooks.redirect === 'function')) {
224
+ let url, url_type, referer_url, result
225
+
226
+ url = embedded_url.resolved_match_url
227
+ url_type = null
228
+ referer_url = null
229
+
230
+ debug(3, 'redirecting (pre-hook):', url)
231
+
232
+ try {
233
+ result = hooks.redirect(url, embedded_url.referer_url)
234
+
235
+ if (result) {
236
+ if (typeof result === 'string') {
237
+ url = result
238
+ }
239
+ else if (result instanceof Object) {
240
+ url = result.url
241
+ url_type = result.url_type
242
+ referer_url = result.referer_url
243
+ }
244
+ }
245
+
246
+ if (typeof url !== 'string') throw new Error('bad return value')
247
+
248
+ url = url.trim()
249
+
250
+ if (url.length && (url.toLowerCase().indexOf('http') !== 0))
251
+ url = (new URL(url, m3u8_url)).href
252
+ }
253
+ catch(e) {
254
+ url = ''
255
+ }
256
+
257
+ if (url) {
258
+ embedded_url.redirected_url = url
259
+
260
+ if (typeof url_type === 'string') {
261
+ url_type = url_type.toLowerCase().trim()
262
+
263
+ if (url_type.length)
264
+ embedded_url.url_type = url_type
265
+ }
266
+
267
+ if (typeof referer_url === 'string') {
268
+ referer_url = referer_url.trim()
269
+
270
+ if (referer_url.length && (referer_url.toLowerCase().indexOf('http') === 0))
271
+ embedded_url.referer_url = referer_url
272
+ }
273
+
274
+ debug(3, 'redirecting (post-hook):', url)
275
+ }
276
+ else {
277
+ embedded_url.redirected_url = ''
278
+
279
+ debug(3, 'redirecting (post-hook):', 'URL filtered, removed from manifest')
280
+ }
281
+ }
282
+ }
283
+
284
+ const finalize_embedded_url = function(embedded_url, vod_start_at_ms, debug) {
285
+ if (embedded_url.redirected_url === '') {
286
+ embedded_url.unencoded_url = ''
287
+ }
288
+ else {
289
+ let url = embedded_url.redirected_url || embedded_url.resolved_match_url
290
+
291
+ if (embedded_url.url_type)
292
+ debug(3, 'url type:', embedded_url.url_type)
293
+
294
+ debug(2, 'redirecting:', url)
295
+
296
+ if (vod_start_at_ms && (embedded_url.url_type === 'm3u8'))
297
+ url += `#vod_start=${Math.floor(vod_start_at_ms/1000)}`
298
+
299
+ if (embedded_url.referer_url)
300
+ url += `|${embedded_url.referer_url}`
301
+
302
+ embedded_url.unencoded_url = url
303
+ }
304
+ }
305
+
306
+ const encode_embedded_url = function(embedded_url, redirected_base_url, debug) {
307
+ embedded_url.encoded_url = (embedded_url.unencoded_url)
308
+ ? `${redirected_base_url}/${ utils.base64_encode(embedded_url.unencoded_url) }.${embedded_url.url_type || 'other'}`
309
+ : ''
310
+
311
+ if (embedded_url.encoded_url)
312
+ debug(3, 'redirecting (proxied):', embedded_url.encoded_url)
313
+ }
314
+
315
+ const get_prefetch_url = function(embedded_url, should_prefetch_url, prefetch_urls = []) {
316
+ if (embedded_url.redirected_url !== '') {
317
+ const url = embedded_url.redirected_url || embedded_url.resolved_match_url
318
+
319
+ if (should_prefetch_url(url, embedded_url.url_type))
320
+ prefetch_urls.push(url)
321
+ }
322
+ }
323
+
324
+ const modify_m3u8_line = function(embedded_url, m3u8_lines) {
325
+ const {line_index, url_indices, encoded_url} = embedded_url
326
+
327
+ if (url_indices && Array.isArray(url_indices) && (url_indices.length === 2)) {
328
+ const m3u8_line = m3u8_lines[line_index]
329
+
330
+ m3u8_lines[line_index] = m3u8_line.substring(0, url_indices[0]) + encoded_url + m3u8_line.substring(url_indices[1])
331
+ }
332
+ else {
333
+ m3u8_lines[line_index] = encoded_url
334
+ }
335
+ }
336
+
337
+ const modify_m3u8_content = function(params, segment_cache, m3u8_content, m3u8_url, referer_url, redirected_base_url) {
338
+ const {hooks, cache_segments, max_segments, debug_level} = params
339
+
340
+ const {has_cache, get_time_since_last_access, is_expired, prefetch_segment} = segment_cache
341
+
342
+ const debug = utils.debug.bind(null, params)
343
+ const should_prefetch_url = utils.should_prefetch_url.bind(null, params)
344
+
345
+ if (hooks && (hooks instanceof Object) && hooks.modify_m3u8_content && (typeof hooks.modify_m3u8_content === 'function')) {
346
+ m3u8_content = hooks.modify_m3u8_content(m3u8_content, m3u8_url) || m3u8_content
347
+ }
348
+
349
+ const debug_divider = (debug_level >= 4)
350
+ ? ('-').repeat(40)
351
+ : ''
352
+
353
+ if (debug_level >= 4) {
354
+ debug(4, 'proxied response (original m3u8):', `\n${debug_divider}\n${m3u8_content}\n${debug_divider}`)
355
+ }
356
+
357
+ let is_vod, seg_duration_ms, prefetch_urls
358
+
359
+ // only used with prefetch
360
+ const vod_start_at_ms = (cache_segments)
361
+ ? get_vod_start_at_ms(m3u8_url)
362
+ : null
363
+
364
+ // only used with prefetch
365
+ const perform_prefetch = (cache_segments)
366
+ ? (urls, dont_touch_access) => {
367
+ if (!urls || !Array.isArray(urls) || !urls.length)
368
+ return
369
+
370
+ let promise
371
+
372
+ if (is_vod || has_cache(m3u8_url)) {
373
+ promise = Promise.resolve()
374
+ }
375
+ else {
376
+ const matching_url = urls[0]
377
+ urls[0] = undefined
378
+
379
+ promise = prefetch_segment(m3u8_url, matching_url, referer_url, dont_touch_access)
380
+ }
381
+
382
+ promise.then(() => {
383
+ urls.forEach((matching_url, index) => {
384
+ if (matching_url) {
385
+ prefetch_segment(m3u8_url, matching_url, referer_url, dont_touch_access)
386
+
387
+ urls[index] = undefined
388
+ }
389
+ })
390
+ })
391
+ }
392
+ : null
393
+
394
+ {
395
+ const parsed_manifest = parse_manifest(m3u8_content, m3u8_url, referer_url, hooks, cache_segments, debug, vod_start_at_ms, redirected_base_url, should_prefetch_url)
396
+ is_vod = !!parsed_manifest.meta_data.is_vod // default: false => hls live stream
397
+ seg_duration_ms = parsed_manifest.meta_data.seg_duration_ms || 10000 // default: 10 seconds in ms
398
+ prefetch_urls = parsed_manifest.prefetch_urls
399
+ m3u8_content = parsed_manifest.modified_m3u8
400
+
401
+ if (debug_level >= 4) {
402
+ debug(4, 'parsed manifest:', `\n${debug_divider}\n${JSON.stringify(parsed_manifest, null, 2)}\n${debug_divider}`)
403
+ }
404
+ }
405
+
406
+ if (prefetch_urls.length) {
407
+ if (is_vod && vod_start_at_ms) {
408
+ // full video: prevent prefetch of URLs for skipped video segments
409
+
410
+ const skip_segment_count = Math.floor(vod_start_at_ms / seg_duration_ms)
411
+
412
+ prefetch_urls.splice(0, skip_segment_count)
413
+ debug(3, 'prefetch (ignored):', `${skip_segment_count} URLs in m3u8 skipped to initialize vod prefetch timer from start position obtained from HLS manifest URL #hash`)
414
+ }
415
+ if (prefetch_urls.length > max_segments) {
416
+ if (hooks && (hooks instanceof Object) && hooks.prefetch_segments && (typeof hooks.prefetch_segments === 'function')) {
417
+ prefetch_urls = hooks.prefetch_segments(prefetch_urls, max_segments, is_vod, seg_duration_ms, perform_prefetch)
418
+ }
419
+ else {
420
+ if (!is_vod) {
421
+ // live stream: cache from the end
422
+
423
+ const overflow = prefetch_urls.length - max_segments
424
+
425
+ prefetch_urls.splice(0, overflow)
426
+ debug(3, 'prefetch (ignored):', `${overflow} URLs in m3u8 skipped to prevent cache overflow`)
427
+ }
428
+ else {
429
+ // full video: cache from the beginning w/ timer to update cache at rate of playback (assuming no pausing or seeking)
430
+
431
+ const $prefetch_urls = [...prefetch_urls]
432
+ const batch_size = Math.ceil(max_segments / 2)
433
+ const batch_time = seg_duration_ms * batch_size
434
+
435
+ const is_client_paused = () => {
436
+ const time_since_last_access = get_time_since_last_access(m3u8_url)
437
+
438
+ let inactivity_timeout
439
+ inactivity_timeout = seg_duration_ms * 2
440
+ inactivity_timeout = Math.floor(inactivity_timeout / 1000) // convert to seconds
441
+
442
+ return (time_since_last_access < 0)
443
+ ? false
444
+ : (time_since_last_access >= inactivity_timeout)
445
+ }
446
+
447
+ const prefetch_next_batch = (is_cache_empty) => {
448
+ is_cache_empty = (is_cache_empty === true)
449
+
450
+ if (!is_cache_empty && is_expired(m3u8_url)) {
451
+ debug(3, 'prefetch (stopped):', 'vod stream removed from cache due to inactivity longer than timeout; prefetch has stopped')
452
+ return
453
+ }
454
+
455
+ if (!is_cache_empty && is_client_paused()) {
456
+ debug(3, 'prefetch (skipped):', 'vod stream is paused; prefetch will continue after client playback resumes')
457
+ setTimeout(prefetch_next_batch, batch_time)
458
+ return
459
+ }
460
+
461
+ if ($prefetch_urls.length > batch_size) {
462
+ const batch_urls = $prefetch_urls.splice(0, batch_size)
463
+
464
+ perform_prefetch(batch_urls, !is_cache_empty)
465
+ setTimeout(prefetch_next_batch, is_cache_empty ? 0 : batch_time)
466
+ }
467
+ else {
468
+ perform_prefetch($prefetch_urls, !is_cache_empty)
469
+ }
470
+ }
471
+
472
+ prefetch_urls = []
473
+ prefetch_next_batch(true)
474
+ }
475
+ }
476
+ }
477
+ perform_prefetch(prefetch_urls)
478
+ }
479
+
480
+ if (debug_level >= 4) {
481
+ debug(4, 'proxied response (modified m3u8):', `\n${debug_divider}\n${m3u8_content}\n${debug_divider}`)
482
+ }
483
+
484
+ return m3u8_content
485
+ }
486
+
487
+ module.exports = {
488
+ modify_m3u8_content
489
+ }