@warren-bank/hls-proxy 3.3.0 → 3.4.0

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
@@ -128,6 +128,8 @@ options:
128
128
  --max-segments <number>
129
129
  --cache-timeout <number>
130
130
  --cache-key <number>
131
+ --cache-storage <adapter>
132
+ --cache-storage-fs-dirpath <dirpath>
131
133
  -v <number>
132
134
  --acl-whitelist <ip_address_list>
133
135
  --http-proxy <http[s]://[user:pass@]hostname:port>
@@ -328,6 +330,15 @@ options:
328
330
  * full filename of .ts file
329
331
  * `2`:
330
332
  * full URL of .ts file
333
+ * _--cache-storage_ selects a storage adapter that is used to hold the cache of prefetched video segments
334
+ * this option is only meaningful when _--prefetch_ is enabled
335
+ * `memory` (default):
336
+ * uses RAM
337
+ * `filesystem`:
338
+ * each video segment is written to a new file within a specified directory
339
+ * filenames are random and unique
340
+ * _--cache-storage-fs-dirpath_ specifies the directory in which to save video segments when using a filesystem-based cache storage adapter
341
+ * this option is only meaningful when _--prefetch_ is enabled and _--cache-storage_ is `filesystem`
331
342
  * _-v_ sets logging verbosity level:
332
343
  * `-1`:
333
344
  * silent
@@ -32,18 +32,20 @@ const server = (use_tls)
32
32
  })
33
33
 
34
34
  const middleware = require('../proxy')({
35
- is_secure: use_tls,
36
- host: normalize_host(argv_vals["--host"], argv_vals["--port"]),
37
- req_headers: argv_vals["--req-headers"],
38
- req_options: argv_vals["--req-options"],
39
- hooks: argv_vals["--hooks"],
40
- cache_segments: argv_vals["--prefetch"],
41
- max_segments: argv_vals["--max-segments"],
42
- cache_timeout: argv_vals["--cache-timeout"],
43
- cache_key: argv_vals["--cache-key"],
44
- debug_level: argv_vals["-v"],
45
- acl_whitelist: argv_vals["--acl-whitelist"],
46
- http_proxy: argv_vals["--http-proxy"]
35
+ is_secure: use_tls,
36
+ host: normalize_host(argv_vals["--host"], argv_vals["--port"]),
37
+ req_headers: argv_vals["--req-headers"],
38
+ req_options: argv_vals["--req-options"],
39
+ hooks: argv_vals["--hooks"],
40
+ cache_segments: argv_vals["--prefetch"],
41
+ max_segments: argv_vals["--max-segments"],
42
+ cache_timeout: argv_vals["--cache-timeout"],
43
+ cache_key: argv_vals["--cache-key"],
44
+ cache_storage: argv_vals["--cache-storage"],
45
+ cache_storage_fs_dirpath: argv_vals["--cache-storage-fs-dirpath"],
46
+ debug_level: argv_vals["-v"],
47
+ acl_whitelist: argv_vals["--acl-whitelist"],
48
+ http_proxy: argv_vals["--http-proxy"]
47
49
  })
48
50
 
49
51
  if (middleware.connection)
@@ -24,6 +24,8 @@ options:
24
24
  --max-segments <number>
25
25
  --cache-timeout <number>
26
26
  --cache-key <number>
27
+ --cache-storage <adapter>
28
+ --cache-storage-fs-dirpath <dirpath>
27
29
  -v <number>
28
30
  --acl-whitelist <ip_address_list>
29
31
  --http-proxy <http[s]://[user:pass@]hostname:port>
@@ -29,6 +29,8 @@ const argv_flags = {
29
29
  "--max-segments": {num: "int"},
30
30
  "--cache-timeout": {num: "int"},
31
31
  "--cache-key": {num: "int"},
32
+ "--cache-storage": {enum: ["memory", "filesystem"]},
33
+ "--cache-storage-fs-dirpath": {file: "path-exists"},
32
34
 
33
35
  "-v": {num: "int"},
34
36
  "--acl-whitelist": {},
@@ -121,10 +121,12 @@ const parse_manifest = function(m3u8_content, m3u8_url, referer_url, hooks, cach
121
121
  if (embedded_urls && Array.isArray(embedded_urls) && embedded_urls.length) {
122
122
  embedded_urls.forEach(embedded_url => {
123
123
  redirect_embedded_url(embedded_url, hooks, m3u8_url, debug)
124
- finalize_embedded_url(embedded_url, vod_start_at_ms, debug)
125
- encode_embedded_url(embedded_url, redirected_base_url, debug)
126
- get_prefetch_url(embedded_url, should_prefetch_url, prefetch_urls)
127
- modify_m3u8_line(embedded_url, m3u8_lines)
124
+ if (validate_embedded_url(embedded_url)) {
125
+ finalize_embedded_url(embedded_url, vod_start_at_ms, debug)
126
+ encode_embedded_url(embedded_url, redirected_base_url, debug)
127
+ get_prefetch_url(embedded_url, should_prefetch_url, prefetch_urls)
128
+ modify_m3u8_line(embedded_url, m3u8_lines)
129
+ }
128
130
  })
129
131
  }
130
132
 
@@ -291,6 +293,21 @@ const redirect_embedded_url = function(embedded_url, hooks, m3u8_url, debug) {
291
293
  }
292
294
  }
293
295
 
296
+ const validate_embedded_url = function(embedded_url) {
297
+ if (embedded_url.redirected_url === '') {
298
+ return true
299
+ }
300
+ else {
301
+ const url = new URL(
302
+ embedded_url.redirected_url || embedded_url.resolved_match_url
303
+ )
304
+
305
+ const supported_protocols = ['http:','https:']
306
+
307
+ return (url.protocol && (supported_protocols.indexOf(url.protocol.toLowerCase()) >= 0))
308
+ }
309
+ }
310
+
294
311
  const finalize_embedded_url = function(embedded_url, vod_start_at_ms, debug) {
295
312
  if (embedded_url.redirected_url === '') {
296
313
  embedded_url.unencoded_url = ''
@@ -35,7 +35,7 @@ const get_middleware = function(params) {
35
35
  }
36
36
 
37
37
  // Create an HTTP tunneling proxy
38
- middleware.request = (req, res) => {
38
+ middleware.request = async (req, res) => {
39
39
  debug(3, 'proxying (raw):', req.url)
40
40
 
41
41
  utils.add_CORS_headers(res)
@@ -56,13 +56,13 @@ const get_middleware = function(params) {
56
56
  }
57
57
 
58
58
  if (cache_segments && !is_m3u8) {
59
- let segment = get_segment(url, url_type) // Buffer (cached segment data), false (prefetch is pending: add callback), undefined (no prefetch is pending)
59
+ let segment = await get_segment(url, url_type) // Buffer (cached segment data), false (prefetch is pending: add callback), undefined (no prefetch is pending)
60
60
 
61
- if (segment && segment.length) { // Buffer (cached segment data)
61
+ if (segment && segment.length) { // Buffer (cached segment data)
62
62
  send_cache_segment(segment)
63
63
  return
64
64
  }
65
- else if (segment === false) { // false (prefetch is pending: add callback)
65
+ else if (segment === false) { // false (prefetch is pending: add callback)
66
66
  add_listener(url, url_type, send_cache_segment)
67
67
  return
68
68
  }
@@ -7,9 +7,10 @@ module.exports = function(params) {
7
7
 
8
8
  if (!cache_segments) return {}
9
9
 
10
- const debug = utils.debug.bind(null, params)
11
- const get_request_options = utils.get_request_options.bind(null, params)
12
- const should_prefetch_url = utils.should_prefetch_url.bind(null, params)
10
+ const debug = utils.debug.bind(null, params)
11
+ const get_request_options = utils.get_request_options.bind(null, params)
12
+ const should_prefetch_url = utils.should_prefetch_url.bind(null, params)
13
+ const cache_storage_adapter = require('./segment_cache_storage')(params)
13
14
 
14
15
  // maps: "m3u8_url" => {access: timestamp, ts: []}
15
16
  const cache = {}
@@ -35,10 +36,10 @@ module.exports = function(params) {
35
36
  }
36
37
 
37
38
  const clear_ts = function(m3u8_url) {
38
- const data = get_cache(m3u8_url)
39
- if (!data) return
39
+ const ts = get_ts(m3u8_url)
40
+ if (!ts || !ts.length) return
40
41
 
41
- data.ts = []
42
+ ts_garbage_collect(m3u8_url, 0, ts.length - 1)
42
43
  }
43
44
 
44
45
  const get_timestamp = function() {
@@ -78,13 +79,20 @@ module.exports = function(params) {
78
79
 
79
80
  const ts_garbage_collect = function(m3u8_url, start, count) {
80
81
  const ts = get_ts(m3u8_url)
81
- if (!ts) return
82
+ if (!ts || !ts.length || (start >= ts.length)) return
82
83
 
83
84
  for (let i=start; i < (start + count); i++) {
84
85
  if (i >= ts.length) break
85
86
 
86
- ts[i].databuffer = undefined
87
+ const segment = ts[i]
88
+
89
+ if (segment.has)
90
+ cache_storage_adapter.remove(segment.state)
91
+
92
+ segment.has = false
93
+ segment.cb = []
87
94
  }
95
+
88
96
  ts.splice(start, count)
89
97
  }
90
98
 
@@ -132,7 +140,7 @@ module.exports = function(params) {
132
140
  let i, segment
133
141
 
134
142
  for (i=(ts.length - 1); i>=0; i--) {
135
- segment = ts[i] // {key, databuffer}
143
+ segment = ts[i] // {key, has, cb, state}
136
144
  if (segment && (segment.key === key)) {
137
145
  index = i
138
146
  break
@@ -173,7 +181,7 @@ module.exports = function(params) {
173
181
 
174
182
  // placeholder to prevent multiple download requests
175
183
  index = ts.length
176
- ts[index] = {key: get_privatekey_from_url(url), databuffer: false}
184
+ ts[index] = {key: get_privatekey_from_url(url), has: false, cb: [], state: {}}
177
185
 
178
186
  let options = get_request_options(url, /* is_m3u8= */ false, referer_url)
179
187
  promise = request(options, '', {binary: true, stream: false, cookieJar: cookies.getCookieJar()})
@@ -188,15 +196,17 @@ module.exports = function(params) {
188
196
  if (!dont_touch_access)
189
197
  touch_access(m3u8_url)
190
198
 
191
- let segment = ts[index].databuffer
192
- if (segment && (segment instanceof Array)) {
193
- segment.forEach((cb) => {
199
+ const segment = ts[index]
200
+ segment.has = true
201
+ cache_storage_adapter.set(segment.state, response)
202
+ if (segment.cb.length) {
203
+ segment.cb.forEach((cb) => {
194
204
  cb(response)
195
205
 
196
206
  debug(1, 'cache (callback complete):', debug_url)
197
207
  })
208
+ segment.cb = []
198
209
  }
199
- ts[index].databuffer = response
200
210
 
201
211
  // cleanup: prune length of ts[] so it contains no more than "max_segments"
202
212
  if (ts.length > max_segments) {
@@ -217,34 +227,38 @@ module.exports = function(params) {
217
227
  return promise
218
228
  }
219
229
 
220
- const get_segment = function(url, url_type) {
230
+ const get_segment = async function(url, url_type) {
221
231
  if (! should_prefetch_url(url, url_type)) return undefined
222
232
 
223
233
  let debug_url = (debug_level >= 3) ? url : get_publickey_from_url(url)
234
+ let segment = find_segment(url)
224
235
 
225
- let segment = find_segment(url)
226
236
  if (segment !== undefined) {
227
237
  const {m3u8_url, index} = segment
228
238
  const ts = get_ts(m3u8_url)
229
239
  touch_access(m3u8_url)
230
240
 
231
- segment = ts[index].databuffer
241
+ segment = ts[index]
242
+
243
+ if (segment.has) {
244
+ debug(1, 'cache (hit):', debug_url)
245
+
246
+ segment = await cache_storage_adapter.get(segment.state)
232
247
 
233
- if ((segment === false) || (segment instanceof Array)) {
248
+ // cleanup: remove all previous segments
249
+ // =====================================
250
+ // todo:
251
+ // - why does this sometimes cause the video player to get stuck.. repeatedly request the .m3u8 file, but stop requesting any .ts segments?
252
+ // - is it a coincidence that commenting this line appears to stop such behavior?
253
+ // - could it possibly be a race condition? cleanup also occurs asynchronously when prefetch responses are received, but javascript (node) is single threaded.. and this code doesn't yield or use a timer.
254
+ // =====================================
255
+ // ts_garbage_collect(m3u8_url, 0, (index + 1))
256
+ }
257
+ else {
234
258
  debug(1, 'cache (pending prefetch):', debug_url)
235
259
 
236
- return false
260
+ segment = false
237
261
  }
238
- debug(1, 'cache (hit):', debug_url)
239
-
240
- // cleanup: remove all previous segments
241
- // =====================================
242
- // todo:
243
- // - why does this sometimes cause the video player to get stuck.. repeatedly request the .m3u8 file, but stop requesting any .ts segments?
244
- // - is it a coincidence that commenting this line appears to stop such behavior?
245
- // - could it possibly be a race condition? cleanup also occurs asynchronously when prefetch responses are received, but javascript (node) is single threaded.. and this code doesn't yield or use a timer.
246
- // =====================================
247
- // ts_garbage_collect(m3u8_url, 0, (index + 1))
248
262
  }
249
263
  else {
250
264
  debug(1, 'cache (miss):', debug_url)
@@ -263,22 +277,17 @@ module.exports = function(params) {
263
277
  const ts = get_ts(m3u8_url)
264
278
  touch_access(m3u8_url)
265
279
 
266
- segment = ts[index].databuffer
267
-
268
- if (segment === false) {
269
- ts[index].databuffer = [cb]
280
+ segment = ts[index]
270
281
 
271
- debug(1, 'cache (callback added):', debug_url)
272
- }
273
- else if (segment instanceof Array) {
274
- ts[index].databuffer.push(cb)
282
+ if (segment.has) {
283
+ cb(segment)
275
284
 
276
- debug(1, 'cache (callback added):', debug_url)
285
+ debug(1, 'cache (callback complete):', debug_url)
277
286
  }
278
287
  else {
279
- cb(segment)
288
+ segment.cb.push(cb)
280
289
 
281
- debug(1, 'cache (callback complete):', debug_url)
290
+ debug(1, 'cache (callback added):', debug_url)
282
291
  }
283
292
  }
284
293
  return true
@@ -0,0 +1,56 @@
1
+ const crypto = require('crypto')
2
+ const fs = require('fs')
3
+ const path = require('path')
4
+
5
+ const {denodeify} = require('@warren-bank/node-request')
6
+
7
+ const $fs = {
8
+ writeFile: denodeify(fs.writeFile),
9
+ readFile: denodeify(fs.readFile),
10
+ rm: denodeify(fs.rm)
11
+ }
12
+
13
+ module.exports = function(dirpath) {
14
+
15
+ // synchronous (private)
16
+ const get_random_filename = (state) => {
17
+ let random_bytes, fname, fpath
18
+
19
+ while (true) {
20
+ random_bytes = get_random_bytes()
21
+ fname = convert_random_bytes_to_filename(random_bytes)
22
+ fpath = path.join(dirpath, fname)
23
+
24
+ if (!fs.existsSync(fpath)) {
25
+ state.fpath = fpath
26
+ return
27
+ }
28
+ }
29
+ }
30
+
31
+ // synchronous (private)
32
+ const get_random_bytes = () => crypto.randomBytes(30)
33
+
34
+ // synchronous (private)
35
+ const convert_random_bytes_to_filename = (buffer) => buffer.toString('base64').replaceAll('/', '_')
36
+
37
+ // async
38
+ const set = async (state, blob) => {
39
+ get_random_filename(state)
40
+
41
+ await $fs.writeFile(state.fpath, blob)
42
+ }
43
+
44
+ // async
45
+ const get = async (state) => {
46
+ return await $fs.readFile(state.fpath, {encoding: null})
47
+ }
48
+
49
+ // async
50
+ const remove = async (state) => {
51
+ await $fs.rm(state.fpath, {force: true})
52
+ delete state.fpath
53
+ }
54
+
55
+ return {set, get, remove}
56
+ }
@@ -0,0 +1,14 @@
1
+ module.exports = function(params) {
2
+ const {cache_storage, cache_storage_fs_dirpath} = params
3
+
4
+ if (cache_storage) {
5
+ if (cache_storage === 'memory')
6
+ return require('./memory')()
7
+
8
+ if ((cache_storage === 'filesystem') && cache_storage_fs_dirpath)
9
+ return require('./filesystem')(cache_storage_fs_dirpath)
10
+ }
11
+
12
+ // default
13
+ return require('./memory')()
14
+ }
@@ -0,0 +1,17 @@
1
+ module.exports = function() {
2
+
3
+ // async
4
+ const set = async (state, blob) => {
5
+ state.databuffer = blob
6
+ }
7
+
8
+ // async
9
+ const get = async (state) => state.databuffer
10
+
11
+ // async
12
+ const remove = async (state) => {
13
+ delete state.databuffer
14
+ }
15
+
16
+ return {set, get, remove}
17
+ }
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": "3.3.0",
4
+ "version": "3.4.0",
5
5
  "scripts": {
6
6
  "start": "node hls-proxy/bin/hlsd.js",
7
7
  "sudo": "sudo node hls-proxy/bin/hlsd.js"
@@ -12,7 +12,7 @@
12
12
  "dependencies": {
13
13
  "@warren-bank/node-process-argv": "^1.2.1",
14
14
  "@warren-bank/node-request": "^2.0.12",
15
- "@warren-bank/url": "^3.1.0",
15
+ "@warren-bank/url": "^3.1.2",
16
16
  "hpagent": "^1.2.0",
17
17
  "tough-cookie": "^3.0.1"
18
18
  },