@warren-bank/hls-proxy 2.0.0 → 2.0.2

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.
package/README.md CHANGED
@@ -528,6 +528,41 @@ curl --silent --insecure "$URL"
528
528
 
529
529
  - - - -
530
530
 
531
+ #### Major Versions:
532
+
533
+ * `v1.x`
534
+ - commit history is in branch: [`v01`](https://github.com/warren-bank/HLS-Proxy/commits/v01)
535
+ - summary:
536
+ * m3u8 manifest parser uses regex patterns to identify all URL patterns without any special knowledge of the m3u8 manifest specification
537
+ * internal `proxy` module exports a function that accepts an instance of [`http.Server`](https://nodejs.org/api/http.html#class-httpserver) and adds event listeners to process requests
538
+ - system requirements:
539
+ * Node.js v6.4.0 and higher
540
+ - required features: [`Proxy` constructor](https://node.green/#ES2015-built-ins-Proxy-constructor-requires-new), [`Proxy` 'apply' handler](https://node.green/#ES2015-built-ins-Proxy--apply--handler), [`Reflect.apply`](https://node.green/#ES2015-built-ins-Reflect-Reflect-apply)
541
+ * `v2.x`
542
+ - commit history is in branch: [`v02`](https://github.com/warren-bank/HLS-Proxy/commits/v02)
543
+ - summary:
544
+ * m3u8 manifest parser uses regex patterns to identify all URL patterns without any special knowledge of the m3u8 manifest specification
545
+ * internal `proxy` module exports an Object containing event listeners to process requests that can be either:
546
+ - added to an instance of [`http.Server`](https://nodejs.org/api/http.html#class-httpserver)
547
+ - added to an [`Express.js`](https://github.com/expressjs/express) application as middleware to handle a custom route
548
+ * important limitation: since `/` is a valid character in a base64 encoded URL, the path for a custom route needs to end with a character that is not allowed in base64 encoding (ex: `'/proxy_/*'`)
549
+ - system requirements:
550
+ * Node.js v6.4.0 and higher
551
+ - required features: [`Proxy` constructor](https://node.green/#ES2015-built-ins-Proxy-constructor-requires-new), [`Proxy` 'apply' handler](https://node.green/#ES2015-built-ins-Proxy--apply--handler), [`Reflect.apply`](https://node.green/#ES2015-built-ins-Reflect-Reflect-apply)
552
+ * `v3.x`
553
+ - commit history is in branch: [`v03`](https://github.com/warren-bank/HLS-Proxy/commits/v03)
554
+ - summary:
555
+ * m3u8 manifest parser uses special knowledge of the m3u8 manifest specification to contextually identify URLs
556
+ * internal `proxy` module exports an Object containing event listeners to process requests that can be either:
557
+ - added to an instance of [`http.Server`](https://nodejs.org/api/http.html#class-httpserver)
558
+ - added to an [`Express.js`](https://github.com/expressjs/express) application as middleware to handle a custom route
559
+ * important limitation: since `/` is a valid character in a base64 encoded URL, the path for a custom route needs to end with a character that is not allowed in base64 encoding (ex: `'/proxy_/*'`)
560
+ - system requirements:
561
+ * Node.js v16.0.0 and higher
562
+ - required features: [`Proxy` constructor](https://node.green/#ES2015-built-ins-Proxy-constructor-requires-new), [`Proxy` 'apply' handler](https://node.green/#ES2015-built-ins-Proxy--apply--handler), [`Reflect.apply`](https://node.green/#ES2015-built-ins-Reflect-Reflect-apply), [`RegExp` 'd' flag](https://node.green/#ES2022-features-RegExp-Match-Indices---hasIndices-----d--flag-)
563
+
564
+ - - - -
565
+
531
566
  #### Legal:
532
567
 
533
568
  * copyright: [Warren Bank](https://github.com/warren-bank)
@@ -1,335 +1,335 @@
1
- const utils = require('./utils')
2
-
3
- const regexs = {
4
- m3u8: new RegExp('\\.m3u8(?:[\\?#]|$)', 'i'),
5
- ts_duration: new RegExp('^#EXT-X-TARGETDURATION:(\\d+)(?:\\.\\d+)?$', 'im'),
6
- vod: new RegExp('^(?:#EXT-X-PLAYLIST-TYPE:VOD|#EXT-X-ENDLIST)$', 'im'),
7
- vod_start_at: new RegExp('#vod_start(?:_prefetch_at)?=((?:\\d+:)?(?:\\d+:)?\\d+)$', 'i'),
8
- urls: new RegExp('(^|(?<!(?:KEYFORMAT=))[\\s\'"])((?:https?:/)?/)?((?:[^\\?#/\\s\'"]*/)+?)?([^\\?#,/\\s\'"]+?)(\\.[^\\?#,/\\.\\s\'"]+(?:[\\?#][^\\s\'"]*)?)?([\\s\'"]|$)', 'img'),
9
- keys: new RegExp('(^#EXT-X-KEY:(?:.+,)?URI=")([^"]+)(".*$)', 'img')
10
- }
11
-
12
- const ts_regexs = {
13
- "file_ext": /^\.ts/i,
14
- "sequence_number": /[^\d](\d+)$/i
15
- }
16
-
17
- const get_ts_file_ext = function(file_name, file_ext) {
18
- let ts_file_ext, matches
19
-
20
- if (ts_regexs["file_ext"].test(file_ext)) {
21
- matches = ts_regexs["sequence_number"].exec(file_name)
22
- if (matches && matches.length) {
23
- ts_file_ext = `_${matches[1]}${file_ext}`
24
- }
25
- }
26
- return ts_file_ext
27
- }
28
-
29
- const get_seg_duration_ms = function(m3u8_content) {
30
- try {
31
- const matches = regexs.ts_duration.exec(m3u8_content)
32
-
33
- if ((matches == null) || !Array.isArray(matches) || (matches.length < 2))
34
- throw ''
35
-
36
- let duration
37
- duration = matches[1]
38
- duration = parseInt(duration, 10)
39
- duration = duration * 1000 // convert seconds to ms
40
-
41
- return duration
42
- }
43
- catch(e) {
44
- const def_duration = 10000 // 10 seconds in ms
45
-
46
- return def_duration
47
- }
48
- }
49
-
50
- const parse_HHMMSS_to_seconds = function(str) {
51
- const parts = str.split(':')
52
- let seconds = 0
53
- let multiplier = 1
54
-
55
- while (parts.length > 0) {
56
- seconds += multiplier * parseInt(parts.pop(), 10)
57
- multiplier *= 60
58
- }
59
-
60
- return seconds
61
- }
62
-
63
- const get_vod_start_at_ms = function(m3u8_url) {
64
- try {
65
- const matches = regexs.vod_start_at.exec(m3u8_url)
66
-
67
- if ((matches == null) || !Array.isArray(matches) || (matches.length < 2))
68
- throw ''
69
-
70
- let offset
71
- offset = matches[1]
72
- offset = parse_HHMMSS_to_seconds(offset)
73
- offset = offset * 1000 // convert seconds to ms
74
-
75
- return offset
76
- }
77
- catch(e) {
78
- const def_offset = null
79
-
80
- return def_offset
81
- }
82
- }
83
-
84
- const modify_m3u8_content = function(params, segment_cache, m3u8_content, m3u8_url, referer_url, redirected_base_url) {
85
- const {hooks, cache_segments, max_segments, debug_level} = params
86
-
87
- const {has_cache, get_time_since_last_access, is_expired, prefetch_segment} = segment_cache
88
-
89
- const debug = utils.debug.bind(null, params)
90
- const should_prefetch_url = utils.should_prefetch_url.bind(null, params)
91
-
92
- if (hooks && (hooks instanceof Object) && hooks.modify_m3u8_content && (typeof hooks.modify_m3u8_content === 'function')) {
93
- m3u8_content = hooks.modify_m3u8_content(m3u8_content, m3u8_url) || m3u8_content
94
- }
95
-
96
- const base_urls = {
97
- "relative": m3u8_url.replace(/[\?#].*$/, '').replace(/[^\/]+$/, ''),
98
- "absolute": m3u8_url.replace(/(:\/\/[^\/]+).*$/, '$1')
99
- }
100
-
101
- const debug_divider = (debug_level >= 4)
102
- ? ('-').repeat(40)
103
- : ''
104
-
105
- if (debug_level >= 4) {
106
- debug(4, 'proxied response (original m3u8):', `\n${debug_divider}\n${m3u8_content}\n${debug_divider}`)
107
- }
108
-
109
- if (debug_level >= 2) {
110
- m3u8_content = m3u8_content.replace(regexs.keys, function(match, head, key_url, tail) {
111
- debug(2, 'key:', key_url)
112
- return match
113
- })
114
- }
115
-
116
- // only used with prefetch
117
- const seg_duration_ms = (cache_segments)
118
- ? get_seg_duration_ms(m3u8_content)
119
- : null
120
-
121
- // only used with prefetch
122
- const vod_start_at_ms = (cache_segments)
123
- ? get_vod_start_at_ms(m3u8_url)
124
- : null
125
-
126
- // only used with prefetch
127
- const is_vod = (cache_segments)
128
- ? ((typeof vod_start_at_ms === 'number') || (!has_cache(m3u8_url) && regexs.vod.test(m3u8_content)))
129
- : null
130
-
131
- // only used with prefetch
132
- const perform_prefetch = (cache_segments)
133
- ? (urls, dont_touch_access) => {
134
- if (!urls || !Array.isArray(urls) || !urls.length)
135
- return
136
-
137
- let promise
138
-
139
- if (is_vod || has_cache(m3u8_url)) {
140
- promise = Promise.resolve()
141
- }
142
- else {
143
- const matching_url = urls[0]
144
- urls[0] = undefined
145
-
146
- promise = prefetch_segment(m3u8_url, matching_url, referer_url, dont_touch_access)
147
- }
148
-
149
- promise.then(() => {
150
- urls.forEach((matching_url, index) => {
151
- if (matching_url) {
152
- prefetch_segment(m3u8_url, matching_url, referer_url, dont_touch_access)
153
-
154
- urls[index] = undefined
155
- }
156
- })
157
- })
158
- }
159
- : null
160
-
161
- let prefetch_urls = []
162
-
163
- m3u8_content = m3u8_content.replace(regexs.urls, function(match, head, abs_path, rel_path, file_name, file_ext, tail) {
164
- if (
165
- ((head === `"`) || (head === `'`) || (tail === `"`) || (tail === `'`)) &&
166
- (head !== tail)
167
- ) return match
168
-
169
- if (
170
- !abs_path && (
171
- (!file_ext)
172
- || ( rel_path && ( rel_path.indexOf('#EXT') === 0))
173
- || (!rel_path && (file_name.indexOf('#EXT') === 0))
174
- )
175
- ) return match
176
-
177
- debug(3, 'modify (raw):', {match, head, abs_path, rel_path, file_name, file_ext, tail})
178
-
179
- let matching_url
180
- if (!abs_path) {
181
- matching_url = `${base_urls.relative}${rel_path || ''}${file_name}${file_ext || ''}`
182
- }
183
- else if (abs_path[0] === '/') {
184
- matching_url = `${base_urls.absolute}${abs_path}${rel_path || ''}${file_name}${file_ext || ''}`
185
- }
186
- else {
187
- matching_url = `${abs_path}${rel_path || ''}${file_name}${file_ext || ''}`
188
- }
189
- matching_url = matching_url.trim()
190
-
191
- if (hooks && (hooks instanceof Object) && hooks.redirect && (typeof hooks.redirect === 'function')) {
192
- debug(3, 'redirecting (pre-hook):', matching_url)
193
-
194
- try {
195
- let result = hooks.redirect(matching_url, referer_url)
196
-
197
- if (result) {
198
- if (typeof result === 'string') {
199
- matching_url = result
200
- }
201
- else if (result instanceof Object) {
202
- if (result.matching_url) matching_url = result.matching_url
203
- if (result.file_name) file_name = result.file_name
204
- if (result.file_ext) file_ext = result.file_ext
205
- if (result.referer_url) referer_url = result.referer_url
206
- }
207
- }
208
-
209
- if (typeof matching_url !== 'string') throw new Error('bad return value')
210
-
211
- if (matching_url.length && matching_url.toLowerCase().indexOf('http') !== 0) {
212
- matching_url = ( (matching_url[0] === '/') ? base_urls.absolute : base_urls.relative ) + matching_url
213
- }
214
- }
215
- catch(e) {
216
- matching_url = ''
217
- }
218
-
219
- if (!matching_url) {
220
- debug(3, 'redirecting (post-hook):', 'URL filtered, removed from manifest')
221
- return `${head}${tail}`
222
- }
223
- }
224
- debug(2, 'redirecting:', matching_url)
225
-
226
- // aggregate prefetch URLs into an array while iterating.
227
- // after the loop is complete, check the count.
228
- // if it exceeds the size of the cache, remove overflow elements from the beginning.
229
- if (should_prefetch_url(matching_url))
230
- prefetch_urls.push(matching_url)
231
-
232
- if (vod_start_at_ms && regexs.m3u8.test(matching_url))
233
- matching_url += `#vod_start=${Math.floor(vod_start_at_ms/1000)}`
234
-
235
- if (referer_url)
236
- matching_url += `|${referer_url}`
237
-
238
- let ts_file_ext = get_ts_file_ext(file_name, file_ext)
239
- let redirected_url = `${redirected_base_url}/${ utils.base64_encode(matching_url) }${ts_file_ext || file_ext || ''}`
240
- debug(3, 'redirecting (proxied):', redirected_url)
241
-
242
- return `${head}${redirected_url}${tail}`
243
- })
244
-
245
- if (prefetch_urls.length) {
246
- if (is_vod && vod_start_at_ms) {
247
- // full video: prevent prefetch of URLs for skipped video segments
248
-
249
- const skip_segment_count = Math.floor(vod_start_at_ms / seg_duration_ms)
250
-
251
- prefetch_urls.splice(0, skip_segment_count)
252
- 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`)
253
- }
254
- if (prefetch_urls.length > max_segments) {
255
- if (hooks && (hooks instanceof Object) && hooks.prefetch_segments && (typeof hooks.prefetch_segments === 'function')) {
256
- prefetch_urls = hooks.prefetch_segments(prefetch_urls, max_segments, is_vod, seg_duration_ms, perform_prefetch)
257
- }
258
- else {
259
- if (!is_vod) {
260
- // live stream: cache from the end
261
-
262
- const overflow = prefetch_urls.length - max_segments
263
-
264
- prefetch_urls.splice(0, overflow)
265
- debug(3, 'prefetch (ignored):', `${overflow} URLs in m3u8 skipped to prevent cache overflow`)
266
- }
267
- else {
268
- // full video: cache from the beginning w/ timer to update cache at rate of playback (assuming no pausing or seeking)
269
-
270
- const $prefetch_urls = [...prefetch_urls]
271
- const batch_size = Math.ceil(max_segments / 2)
272
- const batch_time = seg_duration_ms * batch_size
273
-
274
- const is_client_paused = () => {
275
- const time_since_last_access = get_time_since_last_access(m3u8_url)
276
-
277
- let inactivity_timeout
278
- inactivity_timeout = seg_duration_ms * 2
279
- inactivity_timeout = Math.floor(inactivity_timeout / 1000) // convert to seconds
280
-
281
- return (time_since_last_access < 0)
282
- ? false
283
- : (time_since_last_access >= inactivity_timeout)
284
- }
285
-
286
- const prefetch_next_batch = (is_cache_empty) => {
287
- is_cache_empty = (is_cache_empty === true)
288
-
289
- if (!is_cache_empty && is_expired(m3u8_url)) {
290
- debug(3, 'prefetch (stopped):', 'vod stream removed from cache due to inactivity longer than timeout; prefetch has stopped')
291
- return
292
- }
293
-
294
- if (!is_cache_empty && is_client_paused()) {
295
- debug(3, 'prefetch (skipped):', 'vod stream is paused; prefetch will continue after client playback resumes')
296
- setTimeout(prefetch_next_batch, batch_time)
297
- return
298
- }
299
-
300
- if ($prefetch_urls.length > batch_size) {
301
- const batch_urls = $prefetch_urls.splice(0, batch_size)
302
-
303
- perform_prefetch(batch_urls, !is_cache_empty)
304
- setTimeout(prefetch_next_batch, is_cache_empty ? 0 : batch_time)
305
- }
306
- else {
307
- perform_prefetch($prefetch_urls, !is_cache_empty)
308
- }
309
- }
310
-
311
- prefetch_urls = []
312
- prefetch_next_batch(true)
313
- }
314
- }
315
- }
316
- perform_prefetch(prefetch_urls)
317
- }
318
-
319
- if (debug_level >= 3) {
320
- m3u8_content = m3u8_content.replace(regexs.keys, function(match, head, key_url, tail) {
321
- debug(3, 'key (proxied):', key_url)
322
- return match
323
- })
324
- }
325
-
326
- if (debug_level >= 4) {
327
- debug(4, 'proxied response (modified m3u8):', `\n${debug_divider}\n${m3u8_content}\n${debug_divider}`)
328
- }
329
-
330
- return m3u8_content
331
- }
332
-
333
- module.exports = {
334
- modify_m3u8_content
335
- }
1
+ const utils = require('./utils')
2
+
3
+ const regexs = {
4
+ m3u8: new RegExp('\\.m3u8(?:[\\?#]|$)', 'i'),
5
+ ts_duration: new RegExp('^#EXT-X-TARGETDURATION:(\\d+)(?:\\.\\d+)?$', 'im'),
6
+ vod: new RegExp('^(?:#EXT-X-PLAYLIST-TYPE:VOD|#EXT-X-ENDLIST)$', 'im'),
7
+ vod_start_at: new RegExp('#vod_start(?:_prefetch_at)?=((?:\\d+:)?(?:\\d+:)?\\d+)$', 'i'),
8
+ urls: new RegExp('(^|(?<!(?:KEYFORMAT=))[\\s\'"])((?:https?:/)?/)?((?:[^\\?#/\\s\'"]*/)+?)?([^\\?#,/\\s\'"]+?)(\\.[^\\?#,/\\.\\s\'"]+(?:[\\?#][^\\s\'"]*)?)?([\\s\'"]|$)', 'img'),
9
+ keys: new RegExp('(^#EXT-X-KEY:(?:.+,)?URI=")([^"]+)(".*$)', 'img')
10
+ }
11
+
12
+ const ts_regexs = {
13
+ "file_ext": /^\.ts/i,
14
+ "sequence_number": /[^\d](\d+)$/i
15
+ }
16
+
17
+ const get_ts_file_ext = function(file_name, file_ext) {
18
+ let ts_file_ext, matches
19
+
20
+ if (ts_regexs["file_ext"].test(file_ext)) {
21
+ matches = ts_regexs["sequence_number"].exec(file_name)
22
+ if (matches && matches.length) {
23
+ ts_file_ext = `_${matches[1]}${file_ext}`
24
+ }
25
+ }
26
+ return ts_file_ext
27
+ }
28
+
29
+ const get_seg_duration_ms = function(m3u8_content) {
30
+ try {
31
+ const matches = regexs.ts_duration.exec(m3u8_content)
32
+
33
+ if ((matches == null) || !Array.isArray(matches) || (matches.length < 2))
34
+ throw ''
35
+
36
+ let duration
37
+ duration = matches[1]
38
+ duration = parseInt(duration, 10)
39
+ duration = duration * 1000 // convert seconds to ms
40
+
41
+ return duration
42
+ }
43
+ catch(e) {
44
+ const def_duration = 10000 // 10 seconds in ms
45
+
46
+ return def_duration
47
+ }
48
+ }
49
+
50
+ const parse_HHMMSS_to_seconds = function(str) {
51
+ const parts = str.split(':')
52
+ let seconds = 0
53
+ let multiplier = 1
54
+
55
+ while (parts.length > 0) {
56
+ seconds += multiplier * parseInt(parts.pop(), 10)
57
+ multiplier *= 60
58
+ }
59
+
60
+ return seconds
61
+ }
62
+
63
+ const get_vod_start_at_ms = function(m3u8_url) {
64
+ try {
65
+ const matches = regexs.vod_start_at.exec(m3u8_url)
66
+
67
+ if ((matches == null) || !Array.isArray(matches) || (matches.length < 2))
68
+ throw ''
69
+
70
+ let offset
71
+ offset = matches[1]
72
+ offset = parse_HHMMSS_to_seconds(offset)
73
+ offset = offset * 1000 // convert seconds to ms
74
+
75
+ return offset
76
+ }
77
+ catch(e) {
78
+ const def_offset = null
79
+
80
+ return def_offset
81
+ }
82
+ }
83
+
84
+ const modify_m3u8_content = function(params, segment_cache, m3u8_content, m3u8_url, referer_url, redirected_base_url) {
85
+ const {hooks, cache_segments, max_segments, debug_level} = params
86
+
87
+ const {has_cache, get_time_since_last_access, is_expired, prefetch_segment} = segment_cache
88
+
89
+ const debug = utils.debug.bind(null, params)
90
+ const should_prefetch_url = utils.should_prefetch_url.bind(null, params)
91
+
92
+ if (hooks && (hooks instanceof Object) && hooks.modify_m3u8_content && (typeof hooks.modify_m3u8_content === 'function')) {
93
+ m3u8_content = hooks.modify_m3u8_content(m3u8_content, m3u8_url) || m3u8_content
94
+ }
95
+
96
+ const base_urls = {
97
+ "relative": m3u8_url.replace(/[\?#].*$/, '').replace(/[^\/]+$/, ''),
98
+ "absolute": m3u8_url.replace(/(:\/\/[^\/]+).*$/, '$1')
99
+ }
100
+
101
+ const debug_divider = (debug_level >= 4)
102
+ ? ('-').repeat(40)
103
+ : ''
104
+
105
+ if (debug_level >= 4) {
106
+ debug(4, 'proxied response (original m3u8):', `\n${debug_divider}\n${m3u8_content}\n${debug_divider}`)
107
+ }
108
+
109
+ if (debug_level >= 2) {
110
+ m3u8_content = m3u8_content.replace(regexs.keys, function(match, head, key_url, tail) {
111
+ debug(2, 'key:', key_url)
112
+ return match
113
+ })
114
+ }
115
+
116
+ // only used with prefetch
117
+ const seg_duration_ms = (cache_segments)
118
+ ? get_seg_duration_ms(m3u8_content)
119
+ : null
120
+
121
+ // only used with prefetch
122
+ const vod_start_at_ms = (cache_segments)
123
+ ? get_vod_start_at_ms(m3u8_url)
124
+ : null
125
+
126
+ // only used with prefetch
127
+ const is_vod = (cache_segments)
128
+ ? ((typeof vod_start_at_ms === 'number') || (!has_cache(m3u8_url) && regexs.vod.test(m3u8_content)))
129
+ : null
130
+
131
+ // only used with prefetch
132
+ const perform_prefetch = (cache_segments)
133
+ ? (urls, dont_touch_access) => {
134
+ if (!urls || !Array.isArray(urls) || !urls.length)
135
+ return
136
+
137
+ let promise
138
+
139
+ if (is_vod || has_cache(m3u8_url)) {
140
+ promise = Promise.resolve()
141
+ }
142
+ else {
143
+ const matching_url = urls[0]
144
+ urls[0] = undefined
145
+
146
+ promise = prefetch_segment(m3u8_url, matching_url, referer_url, dont_touch_access)
147
+ }
148
+
149
+ promise.then(() => {
150
+ urls.forEach((matching_url, index) => {
151
+ if (matching_url) {
152
+ prefetch_segment(m3u8_url, matching_url, referer_url, dont_touch_access)
153
+
154
+ urls[index] = undefined
155
+ }
156
+ })
157
+ })
158
+ }
159
+ : null
160
+
161
+ let prefetch_urls = []
162
+
163
+ m3u8_content = m3u8_content.replace(regexs.urls, function(match, head, abs_path, rel_path, file_name, file_ext, tail) {
164
+ if (
165
+ ((head === `"`) || (head === `'`) || (tail === `"`) || (tail === `'`)) &&
166
+ (head !== tail)
167
+ ) return match
168
+
169
+ if (
170
+ !abs_path && (
171
+ (!file_ext)
172
+ || ( rel_path && ( rel_path.indexOf('#EXT') === 0))
173
+ || (!rel_path && (file_name.indexOf('#EXT') === 0))
174
+ )
175
+ ) return match
176
+
177
+ debug(3, 'modify (raw):', {match, head, abs_path, rel_path, file_name, file_ext, tail})
178
+
179
+ let matching_url
180
+ if (!abs_path) {
181
+ matching_url = `${base_urls.relative}${rel_path || ''}${file_name}${file_ext || ''}`
182
+ }
183
+ else if (abs_path[0] === '/') {
184
+ matching_url = `${base_urls.absolute}${abs_path}${rel_path || ''}${file_name}${file_ext || ''}`
185
+ }
186
+ else {
187
+ matching_url = `${abs_path}${rel_path || ''}${file_name}${file_ext || ''}`
188
+ }
189
+ matching_url = matching_url.trim()
190
+
191
+ if (hooks && (hooks instanceof Object) && hooks.redirect && (typeof hooks.redirect === 'function')) {
192
+ debug(3, 'redirecting (pre-hook):', matching_url)
193
+
194
+ try {
195
+ let result = hooks.redirect(matching_url, referer_url)
196
+
197
+ if (result) {
198
+ if (typeof result === 'string') {
199
+ matching_url = result
200
+ }
201
+ else if (result instanceof Object) {
202
+ if (result.matching_url) matching_url = result.matching_url
203
+ if (result.file_name) file_name = result.file_name
204
+ if (result.file_ext) file_ext = result.file_ext
205
+ if (result.referer_url) referer_url = result.referer_url
206
+ }
207
+ }
208
+
209
+ if (typeof matching_url !== 'string') throw new Error('bad return value')
210
+
211
+ if (matching_url.length && matching_url.toLowerCase().indexOf('http') !== 0) {
212
+ matching_url = ( (matching_url[0] === '/') ? base_urls.absolute : base_urls.relative ) + matching_url
213
+ }
214
+ }
215
+ catch(e) {
216
+ matching_url = ''
217
+ }
218
+
219
+ if (!matching_url) {
220
+ debug(3, 'redirecting (post-hook):', 'URL filtered, removed from manifest')
221
+ return `${head}${tail}`
222
+ }
223
+ }
224
+ debug(2, 'redirecting:', matching_url)
225
+
226
+ // aggregate prefetch URLs into an array while iterating.
227
+ // after the loop is complete, check the count.
228
+ // if it exceeds the size of the cache, remove overflow elements from the beginning.
229
+ if (should_prefetch_url(matching_url))
230
+ prefetch_urls.push(matching_url)
231
+
232
+ if (vod_start_at_ms && regexs.m3u8.test(matching_url))
233
+ matching_url += `#vod_start=${Math.floor(vod_start_at_ms/1000)}`
234
+
235
+ if (referer_url)
236
+ matching_url += `|${referer_url}`
237
+
238
+ let ts_file_ext = get_ts_file_ext(file_name, file_ext)
239
+ let redirected_url = `${redirected_base_url}/${ utils.base64_encode(matching_url) }${ts_file_ext || file_ext || ''}`
240
+ debug(3, 'redirecting (proxied):', redirected_url)
241
+
242
+ return `${head}${redirected_url}${tail}`
243
+ })
244
+
245
+ if (prefetch_urls.length) {
246
+ if (is_vod && vod_start_at_ms) {
247
+ // full video: prevent prefetch of URLs for skipped video segments
248
+
249
+ const skip_segment_count = Math.floor(vod_start_at_ms / seg_duration_ms)
250
+
251
+ prefetch_urls.splice(0, skip_segment_count)
252
+ 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`)
253
+ }
254
+ if (prefetch_urls.length > max_segments) {
255
+ if (hooks && (hooks instanceof Object) && hooks.prefetch_segments && (typeof hooks.prefetch_segments === 'function')) {
256
+ prefetch_urls = hooks.prefetch_segments(prefetch_urls, max_segments, is_vod, seg_duration_ms, perform_prefetch)
257
+ }
258
+ else {
259
+ if (!is_vod) {
260
+ // live stream: cache from the end
261
+
262
+ const overflow = prefetch_urls.length - max_segments
263
+
264
+ prefetch_urls.splice(0, overflow)
265
+ debug(3, 'prefetch (ignored):', `${overflow} URLs in m3u8 skipped to prevent cache overflow`)
266
+ }
267
+ else {
268
+ // full video: cache from the beginning w/ timer to update cache at rate of playback (assuming no pausing or seeking)
269
+
270
+ const $prefetch_urls = [...prefetch_urls]
271
+ const batch_size = Math.ceil(max_segments / 2)
272
+ const batch_time = seg_duration_ms * batch_size
273
+
274
+ const is_client_paused = () => {
275
+ const time_since_last_access = get_time_since_last_access(m3u8_url)
276
+
277
+ let inactivity_timeout
278
+ inactivity_timeout = seg_duration_ms * 2
279
+ inactivity_timeout = Math.floor(inactivity_timeout / 1000) // convert to seconds
280
+
281
+ return (time_since_last_access < 0)
282
+ ? false
283
+ : (time_since_last_access >= inactivity_timeout)
284
+ }
285
+
286
+ const prefetch_next_batch = (is_cache_empty) => {
287
+ is_cache_empty = (is_cache_empty === true)
288
+
289
+ if (!is_cache_empty && is_expired(m3u8_url)) {
290
+ debug(3, 'prefetch (stopped):', 'vod stream removed from cache due to inactivity longer than timeout; prefetch has stopped')
291
+ return
292
+ }
293
+
294
+ if (!is_cache_empty && is_client_paused()) {
295
+ debug(3, 'prefetch (skipped):', 'vod stream is paused; prefetch will continue after client playback resumes')
296
+ setTimeout(prefetch_next_batch, batch_time)
297
+ return
298
+ }
299
+
300
+ if ($prefetch_urls.length > batch_size) {
301
+ const batch_urls = $prefetch_urls.splice(0, batch_size)
302
+
303
+ perform_prefetch(batch_urls, !is_cache_empty)
304
+ setTimeout(prefetch_next_batch, is_cache_empty ? 0 : batch_time)
305
+ }
306
+ else {
307
+ perform_prefetch($prefetch_urls, !is_cache_empty)
308
+ }
309
+ }
310
+
311
+ prefetch_urls = []
312
+ prefetch_next_batch(true)
313
+ }
314
+ }
315
+ }
316
+ perform_prefetch(prefetch_urls)
317
+ }
318
+
319
+ if (debug_level >= 3) {
320
+ m3u8_content = m3u8_content.replace(regexs.keys, function(match, head, key_url, tail) {
321
+ debug(3, 'key (proxied):', key_url)
322
+ return match
323
+ })
324
+ }
325
+
326
+ if (debug_level >= 4) {
327
+ debug(4, 'proxied response (modified m3u8):', `\n${debug_divider}\n${m3u8_content}\n${debug_divider}`)
328
+ }
329
+
330
+ return m3u8_content
331
+ }
332
+
333
+ module.exports = {
334
+ modify_m3u8_content
335
+ }
@@ -3,12 +3,13 @@ const parser = require('./manifest_parser')
3
3
  const utils = require('./utils')
4
4
 
5
5
  const regexs = {
6
- wrap: new RegExp('^(.*)/([^\\._/\\?#]+)(?:[\\._][^/\\?#]*)?(?:[\\?#].*)?$', 'i'),
6
+ wrap: new RegExp('^(.*?)/([a-zA-Z0-9\\+/=%]+)(?:[\\._][^/\\?#]*)?(?:[\\?#].*)?$'),
7
7
  m3u8: new RegExp('\\.m3u8(?:[\\?#]|$)', 'i')
8
8
  }
9
9
 
10
10
  const get_middleware = function(params) {
11
- const {is_secure, host, cache_segments, acl_whitelist} = params
11
+ const {is_secure, host, cache_segments} = params
12
+ let {acl_whitelist} = params
12
13
 
13
14
  const segment_cache = require('./segment_cache')(params)
14
15
  const {get_segment, add_listener} = segment_cache
@@ -47,7 +48,7 @@ const get_middleware = function(params) {
47
48
 
48
49
  let url, url_lc, index
49
50
 
50
- url = utils.base64_decode( req.url.replace(regexs.wrap, '$2') ).trim()
51
+ url = utils.base64_decode( decodeURIComponent( req.url.replace(regexs.wrap, '$2') ) ).trim()
51
52
  url_lc = url.toLowerCase()
52
53
 
53
54
  index = url_lc.indexOf('http')
@@ -1,100 +1,100 @@
1
- const parse_url = require('url').parse
2
-
3
- const regexs = {
4
- origin: new RegExp('^(https?://[^/]+)(?:/.*)?$', 'i'),
5
- ts: new RegExp('\\.ts(?:[\\?#]|$)', 'i')
6
- }
7
-
8
- // btoa
9
- const base64_encode = function(str) {
10
- return Buffer.from(str, 'binary').toString('base64')
11
- }
12
-
13
- // atob
14
- const base64_decode = function(str) {
15
- return Buffer.from(str, 'base64').toString('binary')
16
- }
17
-
18
- const add_CORS_headers = function(res) {
19
- res.setHeader('Access-Control-Allow-Origin', '*')
20
- res.setHeader('Access-Control-Allow-Methods', '*')
21
- res.setHeader('Access-Control-Allow-Headers', '*')
22
- res.setHeader('Access-Control-Allow-Credentials', 'true')
23
- res.setHeader('Access-Control-Max-Age', '86400')
24
- }
25
-
26
- const debug = function() {
27
- const args = [...arguments]
28
- const params = args.shift()
29
- const verbosity = args.shift()
30
- const append_LF = true
31
-
32
- const {debug_level} = params
33
-
34
- if (append_LF) args.push("\n")
35
-
36
- if (debug_level >= verbosity) {
37
- console.log.apply(console.log, args)
38
- }
39
- }
40
-
41
- const get_request_options = function(params, url, is_m3u8, referer_url) {
42
- const {req_headers, req_options, hooks} = params
43
-
44
- const additional_req_options = (hooks && (hooks instanceof Object) && hooks.add_request_options && (typeof hooks.add_request_options === 'function'))
45
- ? hooks.add_request_options(url, is_m3u8)
46
- : null
47
-
48
- const additional_req_headers = (hooks && (hooks instanceof Object) && hooks.add_request_headers && (typeof hooks.add_request_headers === 'function'))
49
- ? hooks.add_request_headers(url, is_m3u8)
50
- : null
51
-
52
- if (!req_options && !additional_req_options && !req_headers && !additional_req_headers && !referer_url) return url
53
-
54
- const request_options = Object.assign(
55
- {},
56
- parse_url(url),
57
- (req_options || {}),
58
- (additional_req_options || {})
59
- )
60
-
61
- request_options.headers = Object.assign(
62
- {},
63
- (( req_options && req_options.headers) ? req_options.headers : {}),
64
- ((additional_req_options && additional_req_options.headers) ? additional_req_options.headers : {}),
65
- (req_headers || {}),
66
- (additional_req_headers || {}),
67
- (referer_url ? {"referer": referer_url, "origin": referer_url.replace(regexs.origin, '$1')} : {})
68
- )
69
-
70
- return request_options
71
- }
72
-
73
- const should_prefetch_url = function(params, url) {
74
- const {hooks, cache_segments} = params
75
-
76
- let do_prefetch = !!cache_segments
77
-
78
- if (do_prefetch) {
79
- do_prefetch = regexs.ts.test(url)
80
-
81
- if (hooks && (hooks instanceof Object) && hooks.prefetch && (typeof hooks.prefetch === 'function')) {
82
- const override_prefetch = hooks.prefetch(url)
83
-
84
- if ((typeof override_prefetch === 'boolean') && (override_prefetch !== do_prefetch)) {
85
- debug(params, 3, 'prefetch override:', (override_prefetch ? 'allow' : 'deny'), url)
86
- do_prefetch = override_prefetch
87
- }
88
- }
89
- }
90
- return do_prefetch
91
- }
92
-
93
- module.exports = {
94
- base64_encode,
95
- base64_decode,
96
- add_CORS_headers,
97
- debug,
98
- get_request_options,
99
- should_prefetch_url
100
- }
1
+ const parse_url = require('url').parse
2
+
3
+ const regexs = {
4
+ origin: new RegExp('^(https?://[^/]+)(?:/.*)?$', 'i'),
5
+ ts: new RegExp('\\.ts(?:[\\?#]|$)', 'i')
6
+ }
7
+
8
+ // btoa
9
+ const base64_encode = function(str) {
10
+ return Buffer.from(str, 'binary').toString('base64')
11
+ }
12
+
13
+ // atob
14
+ const base64_decode = function(str) {
15
+ return Buffer.from(str, 'base64').toString('binary')
16
+ }
17
+
18
+ const add_CORS_headers = function(res) {
19
+ res.setHeader('Access-Control-Allow-Origin', '*')
20
+ res.setHeader('Access-Control-Allow-Methods', '*')
21
+ res.setHeader('Access-Control-Allow-Headers', '*')
22
+ res.setHeader('Access-Control-Allow-Credentials', 'true')
23
+ res.setHeader('Access-Control-Max-Age', '86400')
24
+ }
25
+
26
+ const debug = function() {
27
+ const args = [...arguments]
28
+ const params = args.shift()
29
+ const verbosity = args.shift()
30
+ const append_LF = true
31
+
32
+ const {debug_level} = params
33
+
34
+ if (append_LF) args.push("\n")
35
+
36
+ if (debug_level >= verbosity) {
37
+ console.log.apply(console.log, args)
38
+ }
39
+ }
40
+
41
+ const get_request_options = function(params, url, is_m3u8, referer_url) {
42
+ const {req_headers, req_options, hooks} = params
43
+
44
+ const additional_req_options = (hooks && (hooks instanceof Object) && hooks.add_request_options && (typeof hooks.add_request_options === 'function'))
45
+ ? hooks.add_request_options(url, is_m3u8)
46
+ : null
47
+
48
+ const additional_req_headers = (hooks && (hooks instanceof Object) && hooks.add_request_headers && (typeof hooks.add_request_headers === 'function'))
49
+ ? hooks.add_request_headers(url, is_m3u8)
50
+ : null
51
+
52
+ if (!req_options && !additional_req_options && !req_headers && !additional_req_headers && !referer_url) return url
53
+
54
+ const request_options = Object.assign(
55
+ {},
56
+ parse_url(url),
57
+ (req_options || {}),
58
+ (additional_req_options || {})
59
+ )
60
+
61
+ request_options.headers = Object.assign(
62
+ {},
63
+ (( req_options && req_options.headers) ? req_options.headers : {}),
64
+ ((additional_req_options && additional_req_options.headers) ? additional_req_options.headers : {}),
65
+ (req_headers || {}),
66
+ (additional_req_headers || {}),
67
+ (referer_url ? {"referer": referer_url, "origin": referer_url.replace(regexs.origin, '$1')} : {})
68
+ )
69
+
70
+ return request_options
71
+ }
72
+
73
+ const should_prefetch_url = function(params, url) {
74
+ const {hooks, cache_segments} = params
75
+
76
+ let do_prefetch = !!cache_segments
77
+
78
+ if (do_prefetch) {
79
+ do_prefetch = regexs.ts.test(url)
80
+
81
+ if (hooks && (hooks instanceof Object) && hooks.prefetch && (typeof hooks.prefetch === 'function')) {
82
+ const override_prefetch = hooks.prefetch(url)
83
+
84
+ if ((typeof override_prefetch === 'boolean') && (override_prefetch !== do_prefetch)) {
85
+ debug(params, 3, 'prefetch override:', (override_prefetch ? 'allow' : 'deny'), url)
86
+ do_prefetch = override_prefetch
87
+ }
88
+ }
89
+ }
90
+ return do_prefetch
91
+ }
92
+
93
+ module.exports = {
94
+ base64_encode,
95
+ base64_decode,
96
+ add_CORS_headers,
97
+ debug,
98
+ get_request_options,
99
+ should_prefetch_url
100
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@warren-bank/hls-proxy",
3
3
  "description": "Node.js server to proxy HLS video streams",
4
- "version": "2.0.0",
4
+ "version": "2.0.2",
5
5
  "scripts": {
6
6
  "start": "node hls-proxy/bin/hlsd.js",
7
7
  "sudo": "sudo node hls-proxy/bin/hlsd.js"