stremiok 2.0.0 → 2.2.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/service/index.js CHANGED
@@ -20,6 +20,14 @@
20
20
  * /aiom/addons?base=<AIOManager address> (+ X-AIOM-Key: the account's API key) -> JSON
21
21
  * { addons: [{ url, name, enabled }] }: the account's addons (its Hydra
22
22
  * API), StremioK registered as a Pull app; StremioK only (entryAllowed)
23
+ * /epg/load?u=<guide>&k=<bitset>[&force=1] (or POSTed: the same in its body, as StremioK
24
+ * asks) -> JSON { state, at, channels, programmes, partial,
25
+ * error }: a playlist's TV guide (XMLTV) read for StremioK's channels
26
+ * only; /epg/get?u=<guide>&ids=<key>|<key>&from&to[&max] -> JSON { <key>:
27
+ * [[start, stop, title, desc, picture?, about?]], _icons: { <key>: logo } }: their programs and
28
+ * the guide's logos for them (see epgControl)
29
+ * /epg/ticket -> JSON { ticket }: for guide POSTs from an app without the token (see hasTicket)
30
+ * /epg/files -> JSON { count, bytes }: the saved guides; /epg/clear (POST) -> { cleared }: deleted
23
31
  * /live/open?u=<playlist>[&ua=&ref=&force=1] -> JSON { fix, sure, why, url }: is a live HLS
24
32
  * channel's AAC sound cut mid-frame (the TV's player stutters on it)?
25
33
  * If so, url: a relay that repacks it (/live/<id>/m|s?n=, see liveControl)
@@ -57,6 +65,7 @@ var tls = require('tls');
57
65
  var zlib = require('zlib');
58
66
  var crypto = require('crypto');
59
67
  var urllib = require('url');
68
+ var StringDecoder = require('string_decoder').StringDecoder;
60
69
 
61
70
  var UNDER_TIZENBREW = typeof __filename === 'undefined'; // TizenBrew's sandbox has no module paths
62
71
  var PORT = UNDER_TIZENBREW ? 8098 : 8099;
@@ -117,6 +126,31 @@ function videoHost(h) { return /(^|\.)googlevideo\.com$/i.test(h) || youtubeHost
117
126
  // channel's /live URL redirects to /watch), each to a host `allow(host)` accepts; gives up
118
127
  // after TIMEOUT_MS of silence or MAX_BODY bytes; an answer other than 200 is an error.
119
128
  // Returns abort() (the hop under way, and no further one). Only through httpReq's gate.
129
+ // The body of an answer read straight off the connection: only when it's complete. Chunked:
130
+ // every chunk's size line, its bytes and their CRLF, up to the closing zero-size chunk; else its
131
+ // declared Content-Length, all of it. { body } or { error }.
132
+ function rawBody(hd, buf) {
133
+ if (/chunked/i.test(hd['transfer-encoding'] || '')) {
134
+ var out = [], pos = 0;
135
+ for (;;) {
136
+ var nl = buf.indexOf('\r\n', pos);
137
+ if (nl === -1) { return { error: 'incomplete answer' }; }
138
+ var line = buf.slice(pos, nl).toString('binary').split(';')[0].trim();
139
+ if (!/^[0-9a-f]{1,8}$/i.test(line)) { return { error: 'malformed answer' }; }
140
+ var n = parseInt(line, 16);
141
+ if (n === 0) { return { body: Buffer.concat(out) }; } // (the end: trailers, if any, not needed)
142
+ if (nl + 2 + n + 2 > buf.length || buf[nl + 2 + n] !== 13 || buf[nl + 3 + n] !== 10) { return { error: 'incomplete answer' }; }
143
+ out.push(buf.slice(nl + 2, nl + 2 + n));
144
+ pos = nl + 2 + n + 2;
145
+ }
146
+ }
147
+ if (hd['content-length'] !== undefined) {
148
+ var len = +hd['content-length'];
149
+ if (!(len >= 0) || buf.length < len) { return { error: 'incomplete answer' }; }
150
+ return { body: buf.slice(0, len) };
151
+ }
152
+ return { body: buf }; // (no length: what came before the connection closed)
153
+ }
120
154
  function rawReq(method, urlStr, headers, body, cb, allow, _n, _at) {
121
155
  cb = once(cb);
122
156
  var at = _at || {};
@@ -218,19 +252,10 @@ function bigHeaderGet(urlStr, headers, cb, allow, at, _n) {
218
252
  bigHeaderGet(urllib.resolve(urlStr, hd.location), headers, cb, allow, at, (_n || 0) + 1);
219
253
  return;
220
254
  }
221
- if (/chunked/i.test(hd['transfer-encoding'] || '')) {
222
- var out = [], pos = 0;
223
- while (pos < bodyBuf.length) {
224
- var nl = bodyBuf.indexOf('\r\n', pos);
225
- if (nl === -1) { break; }
226
- var n = parseInt(bodyBuf.slice(pos, nl).toString('binary'), 16);
227
- if (!(n > 0)) { break; }
228
- out.push(bodyBuf.slice(nl + 2, nl + 2 + n));
229
- pos = nl + 2 + n + 2;
230
- }
231
- bodyBuf = Buffer.concat(out);
232
- } else if (hd['content-length']) { bodyBuf = bodyBuf.slice(0, +hd['content-length']); }
255
+ var whole = rawBody(hd, bodyBuf);
233
256
  if (sc !== 200) { end(new Error('HTTP ' + sc)); return; }
257
+ if (whole.error) { end(new Error(whole.error)); return; }
258
+ bodyBuf = whole.body;
234
259
  if (bodyBuf.length > MAX_BODY) { end(new Error('response too large')); return; }
235
260
  end(null, sc, bodyBuf.toString('utf8'));
236
261
  });
@@ -316,6 +341,27 @@ function cacheSet(page, master, links) {
316
341
  _ytCache[page] = { master: master, links: links || [], exp: now + 240000 }; // 4 min
317
342
  }
318
343
 
344
+ // A YouTube channel's other pages to try for its live stream (@handle, /channel/, /c/, /user/,
345
+ // on any tab): its /live, then its Featured tab, leaving out the one given. A video's link: none.
346
+ function ytLivePages(u) {
347
+ var m = /^(https?:\/\/(?:www\.|m\.)?youtube\.com\/(?:@[^\/?#]+|(?:channel|c|user)\/[^\/?#]+))(\/[^?#]*)?(?:[?#].*)?$/i.exec(String(u || ''));
348
+ if (!m) { return []; }
349
+ var tab = (m[2] || '').replace(/\/+$/, '').toLowerCase();
350
+ return ['/live', '/featured'].filter(function (t) { return t !== tab; }).map(function (t) { return m[1] + t; });
351
+ }
352
+ // The page as given first; with no live stream there, the channel's others in turn (ytLivePages).
353
+ // Which one leads to the stream on now varies: a channel's /live can point at one that isn't on
354
+ // while its Featured tab leads with the live one, or the other way round.
355
+ function resolveYtLive(page, fresh, cb) {
356
+ var tries = [page].concat(ytLivePages(page)), first = null;
357
+ (function next(i) {
358
+ resolveYtMaster(tries[i], fresh, function (err, master, links) {
359
+ if (!err) { cb(null, master, links); return; }
360
+ first = first || err;
361
+ if (i + 1 < tries.length) { next(i + 1); } else { cb(first); }
362
+ });
363
+ })(0);
364
+ }
319
365
  function resolveYtMaster(ytPage, fresh, cb) {
320
366
  cb = once(cb);
321
367
  var hit = _ytCache[ytPage];
@@ -382,7 +428,7 @@ function limitedResolve(page, fresh, cb) {
382
428
  _inflight[key] = [cb];
383
429
  function run() {
384
430
  _active++; _recent.push(Date.now());
385
- resolveYtMaster(page, fresh, function (err, master, links) {
431
+ resolveYtLive(page, fresh, function (err, master, links) {
386
432
  _active--;
387
433
  var waiting = _inflight[key] || [];
388
434
  delete _inflight[key];
@@ -577,10 +623,24 @@ function vttTime(sec) {
577
623
  // YouTube's caption file (its "srv1" form: <text start dur>…</text>) as WebVTT. Automatic
578
624
  // captions roll (each line stays up while the next comes): each ends when the next starts,
579
625
  // one line at a time.
626
+ // Tags taken out, a stray < with them: one pass, in time with the text's length however it's
627
+ // made (a run of < with no > after it used to take seconds); nothing left can start a tag.
628
+ function noTags(t) {
629
+ var out = '', i = 0, n = t.length;
630
+ while (i < n) {
631
+ var lt = t.indexOf('<', i);
632
+ if (lt === -1) { out += t.slice(i); break; }
633
+ out += t.slice(i, lt);
634
+ var gt = t.indexOf('>', lt + 1);
635
+ if (gt === -1) { out += t.slice(lt + 1).split('<').join(''); break; } // (no tag ends after it: the rest, its < gone)
636
+ i = gt + 1;
637
+ }
638
+ return out;
639
+ }
580
640
  function srv1ToVtt(xml, auto) {
581
641
  var cues = [], re = /<text start="([\d.]+)"(?: dur="([\d.]+)")?[^>]*>([\s\S]*?)<\/text>/g, m;
582
642
  while ((m = re.exec(xml)) && cues.length < 20000) {
583
- var text = decodeEntities(decodeEntities(m[3])).replace(/<[^>]*>/g, '').replace(/\r/g, '').trim(); // (YouTube escapes them twice)
643
+ var text = noTags(decodeEntities(decodeEntities(m[3]))).replace(/\r/g, '').trim(); // (YouTube escapes them twice)
584
644
  if (!text) { continue; }
585
645
  var st = +m[1], en = st + (+m[2] || 2);
586
646
  cues.push({ st: st, en: en, text: text });
@@ -590,7 +650,7 @@ function srv1ToVtt(xml, auto) {
590
650
  var next = cues[i + 1], en = c.en;
591
651
  if (auto && next && next.st < en) { en = next.st; }
592
652
  if (en <= c.st) { en = c.st + 0.5; }
593
- out.push(vttTime(c.st) + ' --> ' + vttTime(en), c.text.replace(/-->/g, '->'), '');
653
+ out.push(vttTime(c.st) + ' --> ' + vttTime(en), c.text.replace(/--!?>/g, '->'), '');
594
654
  });
595
655
  return out.join('\n');
596
656
  }
@@ -1296,16 +1356,90 @@ function dlName(n) { // a file name: no folders, nothing a drive refuses, not
1296
1356
  var s = String(n || '').replace(/[\/\\:*?"<>|\x00-\x1f]+/g, ' ').replace(/\s+/g, ' ').trim().replace(/^\.+/, '');
1297
1357
  return s.slice(0, 200);
1298
1358
  }
1299
- function dlHostOk(h) {
1300
- h = String(h || '').toLowerCase().replace(/^\[|\]$/g, '');
1301
- return !!h && h !== 'localhost' && !/\.localhost$/.test(h) && !/^127\./.test(h) && !/^0\./.test(h) && h !== '::1' && h !== '0:0:0:0:0:0:0:1' && !/^169\.254\./.test(h);
1359
+ // The TV itself (loopback, unspecified, link-local), however an address is written: dotted,
1360
+ // decimal, hex or octal IPv4 ("2130706433", "0x7f.1", "0177.0.0.1"), IPv6 and IPv4-mapped IPv6
1361
+ // ("::ffff:127.0.0.1", "::ffff:7f00:1"), "localhost". Home-network addresses (a NAS) are fine.
1362
+ function ipv4Int(h) { // inet_aton's forms: 1 to 4 parts, each decimal, 0x hex or 0 octal; else -1
1363
+ var parts = h.split('.');
1364
+ if (!parts.length || parts.length > 4) { return -1; }
1365
+ var nums = [];
1366
+ for (var i = 0; i < parts.length; i++) {
1367
+ var t = parts[i], v;
1368
+ if (/^0x[0-9a-f]+$/i.test(t)) { v = parseInt(t.slice(2), 16); }
1369
+ else if (/^0[0-7]*$/.test(t)) { v = parseInt(t, 8); }
1370
+ else if (/^[1-9][0-9]*$/.test(t)) { v = parseInt(t, 10); }
1371
+ else { return -1; }
1372
+ if (!isFinite(v)) { return -1; }
1373
+ nums.push(v);
1374
+ }
1375
+ var last = nums.pop(), max = [4294967296, 16777216, 65536, 256][nums.length], n = 0;
1376
+ if (last >= max || nums.some(function (x) { return x > 255; })) { return -1; }
1377
+ nums.forEach(function (x, k) { n += x * Math.pow(256, 3 - k); });
1378
+ return n + last;
1379
+ }
1380
+ function ipv6Words(h) { // 8 numbers, or null
1381
+ var tail = /(\d+\.\d+\.\d+\.\d+)$/.exec(h), extra = null;
1382
+ if (tail) { // (a dotted IPv4 at the end: its two words)
1383
+ var v = ipv4Int(tail[1]);
1384
+ if (v < 0) { return null; }
1385
+ extra = [Math.floor(v / 65536), v % 65536];
1386
+ h = h.slice(0, -tail[1].length) + '0:0';
1387
+ }
1388
+ var halves = h.split('::');
1389
+ if (halves.length > 2) { return null; }
1390
+ var a = halves[0] ? halves[0].split(':') : [], b = halves.length === 2 && halves[1] ? halves[1].split(':') : [], all;
1391
+ if (halves.length === 1) { all = a; }
1392
+ else {
1393
+ var fill = 8 - a.length - b.length, zeros = [];
1394
+ if (fill < 1) { return null; }
1395
+ while (zeros.length < fill) { zeros.push('0'); }
1396
+ all = a.concat(zeros, b);
1397
+ }
1398
+ if (all.length !== 8 || !all.every(function (w) { return /^[0-9a-f]{1,4}$/i.test(w); })) { return null; }
1399
+ all = all.map(function (w) { return parseInt(w, 16); });
1400
+ if (extra) { all[6] = extra[0]; all[7] = extra[1]; }
1401
+ return all;
1402
+ }
1403
+ function isSelfAddr(host) {
1404
+ var h = String(host || '').toLowerCase().replace(/^\[|\]$/g, '').replace(/%.*$/, '').replace(/\.$/, '');
1405
+ if (!h || h === 'localhost' || /\.localhost$/.test(h)) { return true; }
1406
+ function v4self(n) { var a = Math.floor(n / 16777216); return a === 127 || a === 0 || Math.floor(n / 65536) === 0xA9FE; }
1407
+ if (h.indexOf(':') === -1) { var n = ipv4Int(h); return n >= 0 && v4self(n); }
1408
+ var w = ipv6Words(h);
1409
+ if (!w) { return false; }
1410
+ var zero5 = w[0] === 0 && w[1] === 0 && w[2] === 0 && w[3] === 0 && w[4] === 0;
1411
+ if (zero5 && (w[5] === 0xffff || w[5] === 0) && !(w[5] === 0 && w[6] === 0 && w[7] <= 1)) { return v4self(w[6] * 65536 + w[7]); } // (IPv4-mapped, or the old IPv4-compatible)
1412
+ if (zero5 && w[5] === 0 && w[6] === 0 && w[7] <= 1) { return true; } // (::1, ::)
1413
+ return (w[0] & 0xffc0) === 0xfe80; // (link-local)
1414
+ }
1415
+ function dlHostOk(h) { return !!String(h || '') && !isSelfAddr(h); }
1416
+ // Checked again where a name actually leads: the address it resolves to, before connecting, and
1417
+ // the one connected to (a name that resolves to the TV itself, now or after a change, is refused).
1418
+ function guardSelf(req, fail) {
1419
+ req.on('socket', function (sock) {
1420
+ sock.on('lookup', function (err, addr) { if (!err && isSelfAddr(addr)) { fail(); } });
1421
+ sock.on('connect', function () { if (isSelfAddr(sock.remoteAddress)) { fail(); } });
1422
+ });
1302
1423
  }
1303
- function dfOf(dir) { // { free, total } in bytes, or null
1424
+ // A drive's free space: df run directly (execFile: the path an argument, never a shell), without
1425
+ // holding up the service. cb({ free, total } in bytes, or null).
1426
+ function dfOf(dir, cb) {
1304
1427
  try {
1305
- var line = String(require('child_process').execSync('df -k "' + dir.replace(/"/g, '') + '"', { timeout: 5000 })).trim().split('\n').slice(-1)[0].split(/\s+/);
1306
- var total = +line[1] * 1024, free = +line[3] * 1024;
1307
- return isFinite(total) && isFinite(free) ? { total: total, free: free } : null;
1308
- } catch (e) { return null; }
1428
+ require('child_process').execFile('df', ['-k', dir], { timeout: 5000 }, function (err, out) {
1429
+ if (err) { cb(null); return; }
1430
+ var line = String(out || '').trim().split('\n').slice(-1)[0].split(/\s+/);
1431
+ var total = +line[1] * 1024, free = +line[3] * 1024;
1432
+ cb(isFinite(total) && isFinite(free) ? { total: total, free: free } : null);
1433
+ });
1434
+ } catch (e) { cb(null); }
1435
+ }
1436
+ // A USB drive that's actually mounted (in /proc/mounts, its mount point's escapes undone).
1437
+ function isMounted(mount) {
1438
+ try {
1439
+ return String(require('fs').readFileSync('/proc/mounts', 'utf8')).split('\n').some(function (l) {
1440
+ return (l.split(' ')[1] || '').replace(/\\([0-7]{3})/g, function (a, o) { return String.fromCharCode(parseInt(o, 8)); }) === mount;
1441
+ });
1442
+ } catch (e) { return false; }
1309
1443
  }
1310
1444
  function fsTypeOf(mount) {
1311
1445
  try {
@@ -1313,6 +1447,30 @@ function fsTypeOf(mount) {
1313
1447
  return hit ? hit.split(' ')[2] : '';
1314
1448
  } catch (e) { return ''; }
1315
1449
  }
1450
+ // /fsfile: a file on a USB drive, with ranges: only where it really is (fsRealOk: a link that
1451
+ // leads off the drive is refused), opened once, and served only if that's a regular file.
1452
+ function fsFileServe(req, res, path) {
1453
+ var fs = require('fs');
1454
+ fsRealOk(path, function (bad, real) {
1455
+ if (bad) { send(res, bad.status === 403 ? 403 : 404, 'text/plain', bad.status === 403 ? 'not on the USB drive' : 'no such file'); return; }
1456
+ fs.open(real, 'r', function (oe, fd) {
1457
+ if (oe) { send(res, 404, 'text/plain', 'no such file'); return; }
1458
+ fs.fstat(fd, function (se, st) {
1459
+ if (se || !st.isFile()) { fs.close(fd, function () {}); send(res, 404, 'text/plain', 'no such file'); return; }
1460
+ var size = st.size, m = /bytes=(\d*)-(\d*)/.exec(req.headers.range || ''), a0 = 0, b0 = size - 1, part = false;
1461
+ if (m) { part = true; if (m[1] !== '') { a0 = +m[1]; if (m[2] !== '') { b0 = Math.min(+m[2], size - 1); } } else if (m[2] !== '') { a0 = Math.max(0, size - (+m[2])); } }
1462
+ if (a0 > b0 || a0 >= size) { fs.close(fd, function () {}); res.writeHead(416, { 'Content-Range': 'bytes */' + size }); res.end(); return; }
1463
+ var h = { 'Content-Type': /\.mp4$|\.m4v$/i.test(real) ? 'video/mp4' : 'video/x-matroska', 'Accept-Ranges': 'bytes', 'Content-Length': b0 - a0 + 1, 'Cache-Control': 'no-store' };
1464
+ if (part) { h['Content-Range'] = 'bytes ' + a0 + '-' + b0 + '/' + size; }
1465
+ res.writeHead(part ? 206 : 200, h);
1466
+ var rs = fs.createReadStream(real, { fd: fd, start: a0, end: b0 });
1467
+ rs.on('error', function () { try { res.end(); } catch (e) {} });
1468
+ res.on('close', function () { try { rs.destroy(); } catch (e) {} });
1469
+ rs.pipe(res);
1470
+ });
1471
+ });
1472
+ });
1473
+ }
1316
1474
  function dlView(e) {
1317
1475
  return { id: e.id, title: e.title, name: e.name, dir: e.dir, size: e.size || 0, got: e.got || 0, speed: Math.round(e.speed || 0), state: e.state, err: e.err || '', at: e.at, tries: e.tries || 0 };
1318
1476
  }
@@ -1322,15 +1480,14 @@ function dlControl(path, query, res) {
1322
1480
  if (path === '/fsdl/list') { send(res, 200, 'application/json', JSON.stringify({ items: _dls.map(dlView) })); return; }
1323
1481
  if (path === '/fsdl/space') {
1324
1482
  var m = mountOf(q.path || '');
1325
- if (!m || !fsDirOk(m)) { send(res, 400, 'text/plain', 'not a USB drive'); return; }
1326
- var df = dfOf(m);
1327
- send(res, 200, 'application/json', JSON.stringify({ free: df ? df.free : null, total: df ? df.total : null, fs: fsTypeOf(m) }));
1483
+ if (!m || !fsDirOk(m) || !isMounted(m)) { send(res, 400, 'text/plain', 'not a USB drive'); return; }
1484
+ dfOf(m, function (df) { send(res, 200, 'application/json', JSON.stringify({ free: df ? df.free : null, total: df ? df.total : null, fs: fsTypeOf(m) })); });
1328
1485
  return;
1329
1486
  }
1330
1487
  if (path === '/fsdl/add') {
1331
1488
  var dir = q.dir || '', name = dlName(q.name), u = q.url || '', pu = null;
1332
1489
  try { pu = urllib.parse(u); } catch (e) {}
1333
- if (!fsDirOk(dir) || dir.split('/').length > 6) { send(res, 400, 'text/plain', 'not a folder on a USB drive'); return; }
1490
+ if (!fsDirOk(dir) || dir.split('/').length > 6 || !isMounted(mountOf(dir))) { send(res, 400, 'text/plain', 'not a folder on a USB drive'); return; }
1334
1491
  if (!name) { send(res, 400, 'text/plain', 'no file name'); return; }
1335
1492
  if (!pu || (pu.protocol !== 'https:' && pu.protocol !== 'http:') || !dlHostOk(pu.hostname)) { send(res, 400, 'text/plain', 'not a link it can download'); return; }
1336
1493
  var same = _dls.filter(function (x) { return x.dir === dir && x.name === name && (x.state === 'queued' || x.state === 'downloading' || x.state === 'waiting'); })[0];
@@ -1340,9 +1497,14 @@ function dlControl(path, query, res) {
1340
1497
  headers: { 'User-Agent': String(q.ua || 'Mozilla/5.0').slice(0, 300) } };
1341
1498
  if (/^https?:\/\//.test(q.ref || '')) { e.headers.Referer = String(q.ref).slice(0, 2000); }
1342
1499
  _dls = _dls.filter(function (x) { return !(x.dir === dir && x.name === name); }); // (a done / failed one of the same file: replaced)
1343
- _dls.push(e);
1344
- dlPump();
1345
- send(res, 200, 'application/json', JSON.stringify(dlView(e)));
1500
+ // Asked again by the app (this service restarted meanwhile): one that had already finished
1501
+ // (its file there, a regular file at its size, no partial file left) is done, not downloaded twice.
1502
+ dlFinishedBefore(q.again === '1' ? e : null, function (done) {
1503
+ if (done) { e.state = 'done'; e.got = e.size = done.size; }
1504
+ _dls.push(e);
1505
+ if (!done) { dlPump(); }
1506
+ send(res, 200, 'application/json', JSON.stringify(dlView(e)));
1507
+ });
1346
1508
  return;
1347
1509
  }
1348
1510
  var x = _dls.filter(function (y) { return y.id === q.id; })[0];
@@ -1377,6 +1539,56 @@ function dlEnd(e, state, err) {
1377
1539
  if (_dlActive === e) { _dlActive = null; }
1378
1540
  setTimeout(dlPump, 0);
1379
1541
  }
1542
+ // cb({ size }) when e's file is already on the drive, finished: a regular file, at its size (when
1543
+ // known), with no partial file beside it; else cb(null).
1544
+ function dlFinishedBefore(e, cb) {
1545
+ if (!e) { cb(null); return; }
1546
+ var fs = require('fs'), file = e.dir + '/' + e.name;
1547
+ fs.lstat(file + '.part', function (pe) {
1548
+ if (!pe) { cb(null); return; } // (a partial file: picked up where it stopped instead)
1549
+ fs.lstat(file, function (fe, st) {
1550
+ if (fe || !st.isFile() || (e.size && st.size !== e.size)) { cb(null); return; }
1551
+ cb({ size: st.size });
1552
+ });
1553
+ });
1554
+ }
1555
+ // A partial answer's range ("bytes <first>-<last>/<total or *>"): { first, last, total (0:
1556
+ // unknown) }, or null when it isn't one that can be (last before first, past the total).
1557
+ function rangeOf(contentRange) {
1558
+ var m = /^bytes (\d+)-(\d+)\/(\d+|\*)$/.exec(String(contentRange || '').trim());
1559
+ if (!m) { return null; }
1560
+ var r = { first: +m[1], last: +m[2], total: m[3] === '*' ? 0 : +m[3] };
1561
+ return (r.last < r.first || (r.total && r.last >= r.total)) ? null : r;
1562
+ }
1563
+ // A partial answer must go on exactly where the file so far ends (have: 0 for a new one).
1564
+ function resumeOk(have, contentRange) {
1565
+ var r = rangeOf(contentRange);
1566
+ return !!r && r.first === have;
1567
+ }
1568
+ // A streamed playlist (HLS, DASH) isn't a file to download: by its type, or its first bytes.
1569
+ function isPlaylistAnswer(type, first) {
1570
+ if (/mpegurl|dash\+xml/i.test(String(type || ''))) { return true; }
1571
+ var t = String(first || '').replace(/^\uFEFF/, '').replace(/^\s+/, '');
1572
+ return t.indexOf('#EXTM3U') === 0 || (/^<\?xml/.test(t) && /<MPD[\s>]/.test(t.slice(0, 512))) || t.indexOf('<MPD') === 0;
1573
+ }
1574
+ // The partial file opened without following a link (O_NOFOLLOW where the TV's Node has it),
1575
+ // and only if it's a regular file. cb(err, fd).
1576
+ function openPart(fs, part, append, cb) {
1577
+ var C = {};
1578
+ try { C = require('constants'); } catch (e) {}
1579
+ var flags = append ? 'a' : 'w';
1580
+ if (C.O_WRONLY && C.O_CREAT) { flags = C.O_WRONLY | C.O_CREAT | (append ? C.O_APPEND : C.O_TRUNC) | (C.O_NOFOLLOW || 0); }
1581
+ fs.lstat(part, function (le, lst) {
1582
+ if (!le && !lst.isFile()) { cb(new Error('the partial file isn\'t a regular file')); return; }
1583
+ fs.open(part, flags, 420, function (oe, fd) {
1584
+ if (oe) { cb(oe); return; }
1585
+ fs.fstat(fd, function (fe, st) {
1586
+ if (fe || !st.isFile()) { fs.close(fd, function () {}); cb(fe || new Error('the partial file isn\'t a regular file')); return; }
1587
+ cb(null, fd);
1588
+ });
1589
+ });
1590
+ });
1591
+ }
1380
1592
  function dlRun(e) {
1381
1593
  var fs = require('fs'), part = e.dir + '/' + e.name + '.part';
1382
1594
  e.state = 'downloading'; e.err = '';
@@ -1391,23 +1603,29 @@ function dlRun(e) {
1391
1603
  if (bad) { dlEnd(e, 'failed', 'the folder: ' + bad.message); return; }
1392
1604
  fs.stat(part, function (se, st) {
1393
1605
  var have = se ? 0 : st.size;
1394
- get(e.url, 0, have);
1606
+ if (!have || e.answered) { get(e.url, 0, have); return; } // (this one's own, from this run: picked up as it was)
1607
+ fs.readFile(part + '.v', 'utf8', function (ve, v) { // (a partial file from before a restart: resumed only with its validator)
1608
+ e.validator = ve ? '' : String(v).trim().slice(0, 300);
1609
+ if (e.validator) { get(e.url, 0, have); } else { dropPart(function () { get(e.url, 0, 0); }); }
1610
+ });
1395
1611
  });
1396
1612
  });
1397
1613
  });
1614
+ function dropPart(then) { fs.unlink(part + '.v', function () { fs.unlink(part, function () { then(); }); }); }
1398
1615
  function get(u, hops, have) {
1399
1616
  if (e.state !== 'downloading') { return; }
1400
1617
  var pu = urllib.parse(u), mod = pu.protocol === 'https:' ? https : http;
1401
1618
  if ((pu.protocol !== 'https:' && pu.protocol !== 'http:') || !dlHostOk(pu.hostname)) { dlEnd(e, 'failed', 'a link it can\'t follow'); return; }
1402
1619
  var h = {};
1403
1620
  Object.keys(e.headers).forEach(function (k) { h[k] = e.headers[k]; });
1404
- if (have > 0) { h.Range = 'bytes=' + have + '-'; }
1621
+ if (have > 0) { h.Range = 'bytes=' + have + '-'; if (e.validator) { h['If-Range'] = e.validator; } } // (the same file, or the whole of it again)
1405
1622
  var opts = { hostname: pu.hostname, port: pu.port, path: pu.path, headers: h };
1406
1623
  if (pu.protocol === 'https:' && caList().length) { opts.ca = caList(); }
1407
1624
  var r, ws = null, closed = false;
1408
1625
  function stop() { closed = true; try { r.abort(); } catch (er) {} if (ws) { try { ws.end(); } catch (er2) {} } }
1409
1626
  e.stop = stop;
1410
1627
  try { r = mod.get(opts, onResp); } catch (er) { again('couldn\'t connect: ' + (er.code || er.message)); return; }
1628
+ guardSelf(r, function () { if (!closed) { stop(); dlEnd(e, 'failed', 'a link to the TV itself'); } });
1411
1629
  r.on('error', function (er) { if (!closed) { closed = true; if (ws) { try { ws.end(); } catch (x) {} } again('connection: ' + (er.code || er.message)); } });
1412
1630
  r.setTimeout(DL_SILENCE_MS, function () { if (!closed) { stop(); again('no data for ' + (DL_SILENCE_MS / 1000) + ' s'); } });
1413
1631
  function onResp(resp) {
@@ -1416,17 +1634,52 @@ function dlRun(e) {
1416
1634
  if (sc >= 400 && sc < 500) { resp.resume(); closed = true; dlEnd(e, 'failed', 'the server refused it (HTTP ' + sc + '): the link may have expired'); return; }
1417
1635
  if (sc >= 500) { resp.resume(); closed = true; again('the server: HTTP ' + sc); return; }
1418
1636
  if (sc !== 200 && sc !== 206) { resp.resume(); closed = true; dlEnd(e, 'failed', 'HTTP ' + sc); return; }
1637
+ if (sc === 206 && !resumeOk(have, resp.headers['content-range'])) { // (not where the file so far ends, or a range that can't be)
1638
+ resp.resume(); closed = true;
1639
+ if (have > 0) { dropPart(function () { if (e.state === 'downloading') { get(e.url, 0, 0); } }); } // (started over)
1640
+ else { dlEnd(e, 'failed', 'the server sent part of the file, not from its start'); }
1641
+ return;
1642
+ }
1643
+ var range = sc === 206 ? rangeOf(resp.headers['content-range']) : null, expect = range ? range.last - range.first + 1 : 0, came = 0;
1419
1644
  var resumed = sc === 206 && have > 0;
1420
1645
  if (!resumed) { have = 0; }
1646
+ e.answered = true; // (its partial file is this run's: see dlRun)
1647
+ if (!resumed) { // (asked again on a resume: If-Range; kept beside the partial file, for one after a restart)
1648
+ var et = resp.headers.etag;
1649
+ e.validator = et && !/^W\//.test(et) ? et : (resp.headers['last-modified'] || '');
1650
+ fs.writeFile(part + '.v', e.validator, { mode: 384 }, function () {});
1651
+ }
1652
+ if (isPlaylistAnswer(resp.headers['content-type'], '')) { resp.resume(); closed = true; dlEnd(e, 'failed', 'a streamed playlist (HLS or DASH), not a file: it can\'t be downloaded'); return; }
1421
1653
  var total = 0, cr = /\/(\d+)\s*$/.exec(resp.headers['content-range'] || '');
1422
1654
  if (cr) { total = +cr[1]; } else if (resp.headers['content-length']) { total = have + (+resp.headers['content-length']); }
1423
1655
  if (total) { e.size = total; }
1424
- var need = (e.size || 0) - have, mount = mountOf(e.dir), df = dfOf(e.dir), fsType = fsTypeOf(mount);
1656
+ var need = (e.size || 0) - have, mount = mountOf(e.dir), fsType = fsTypeOf(mount);
1425
1657
  if (e.size > FAT_MAX && /^(vfat|msdos|fat)/.test(fsType)) { resp.resume(); closed = true; dlEnd(e, 'failed', 'the drive is FAT32, which can\'t hold a file over 4 GB'); return; }
1426
- if (df && need > 0 && df.free < need + DL_SPARE) { resp.resume(); closed = true; dlEnd(e, 'failed', 'not enough space on the drive'); return; }
1427
- try { ws = fs.createWriteStream(part, { flags: resumed ? 'a' : 'w' }); }
1658
+ dfOf(e.dir, function (df) { // (the answer waits, unread, meanwhile)
1659
+ if (closed || e.state !== 'downloading') { resp.resume(); return; }
1660
+ if (df && need > 0 && df.free < need + DL_SPARE) { resp.resume(); closed = true; dlEnd(e, 'failed', 'not enough space on the drive'); return; }
1661
+ openPart(fs, part, resumed, function (oe, fd) {
1662
+ if (closed || e.state !== 'downloading') { if (fd) { fs.close(fd, function () {}); } resp.resume(); return; }
1663
+ if (oe) { resp.resume(); closed = true; dlEnd(e, 'failed', 'can\'t write to the drive: ' + (oe.code || oe.message)); return; }
1664
+ write(fd);
1665
+ });
1666
+ });
1667
+ function write(fd) {
1668
+ try { ws = fs.createWriteStream(part, { fd: fd }); }
1428
1669
  catch (er) { resp.resume(); closed = true; dlEnd(e, 'failed', 'can\'t write to the drive: ' + (er.code || er.message)); return; }
1429
1670
  e.got = have;
1671
+ var seen = resumed, head = [], headLen = 0; // (a playlist's first bytes, where its type didn't say: refused; up to 600, however they come)
1672
+ function lookAtHead() {
1673
+ seen = true;
1674
+ if (isPlaylistAnswer('', Buffer.concat(head).slice(0, 600).toString('utf8'))) { stop(); dropPart(function () {}); dlEnd(e, 'failed', 'a streamed playlist (HLS or DASH), not a file: it can\'t be downloaded'); }
1675
+ head = null;
1676
+ }
1677
+ resp.on('data', function (c) {
1678
+ came += c.length;
1679
+ if (seen) { return; }
1680
+ head.push(c); headLen += c.length;
1681
+ if (headLen >= 600) { lookAtHead(); }
1682
+ });
1430
1683
  var tick = { at: Date.now(), got: have };
1431
1684
  ws.on('error', function (er) { if (!closed) { stop(); dlEnd(e, 'failed', 'can\'t write to the drive: ' + (er.code || er.message)); } });
1432
1685
  resp.on('data', function (c) {
@@ -1436,7 +1689,7 @@ function dlRun(e) {
1436
1689
  });
1437
1690
  // (the server closing before the end: a dropped connection, picked up where it stopped)
1438
1691
  var ended = false;
1439
- resp.on('end', function () { ended = true; });
1692
+ resp.on('end', function () { ended = true; if (!seen) { lookAtHead(); } });
1440
1693
  function dropped() {
1441
1694
  // (all of it came: on the 2017 TV's Node a small answer without a size — a
1442
1695
  // subtitle, sent chunked — can report its connection closed before its end
@@ -1453,17 +1706,21 @@ function dlRun(e) {
1453
1706
  ws.on('finish', function () {
1454
1707
  if (closed || e.state !== 'downloading') { return; }
1455
1708
  closed = true;
1709
+ if (expect && came > expect) { dropPart(function () {}); dlEnd(e, 'failed', 'more came than the server said it sent: started again it may work'); return; }
1456
1710
  if (e.size && e.got < e.size) { again('the connection dropped'); return; } // (picked up where it stopped)
1711
+ if (e.size && e.got > e.size) { dropPart(function () {}); dlEnd(e, 'failed', 'more came than the file\'s size: started again it may work'); return; }
1457
1712
  var final = e.dir + '/' + e.name;
1458
- fs.stat(final, function (fe) {
1713
+ fs.lstat(final, function (fe) { // (anything there, a link too: not replaced)
1459
1714
  if (!fe) { final = e.dir + '/' + e.name.replace(/(\.[^.]+)?$/, ' (' + Date.now().toString(36) + ')$1'); } // (one already there: not overwritten)
1460
1715
  fs.rename(part, final, function (re) {
1461
1716
  if (re) { dlEnd(e, 'failed', 'couldn\'t finish the file: ' + (re.code || re.message)); return; }
1462
1717
  e.name = final.split('/').pop(); e.tries = 0;
1718
+ fs.unlink(part + '.v', function () {});
1463
1719
  dlEnd(e, 'done');
1464
1720
  });
1465
1721
  });
1466
1722
  });
1723
+ }
1467
1724
  }
1468
1725
  }
1469
1726
  }
@@ -1489,17 +1746,22 @@ function handle(req, res) {
1489
1746
  res.__noCors = isFs;
1490
1747
  if (!isFs) {
1491
1748
  res.setHeader('Access-Control-Allow-Origin', '*');
1492
- res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
1749
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
1493
1750
  res.setHeader('Access-Control-Allow-Headers', 'X-StremioK, X-AIOM-Key, *'); // (named: older engines don't take *)
1494
1751
  }
1495
1752
  res.setHeader('Cache-Control', 'no-store');
1496
1753
  res.setHeader('X-Content-Type-Options', 'nosniff');
1497
1754
  if (path === '/health' && req.method === 'GET') { send(res, 200, 'text/plain', 'ok'); return; }
1498
1755
  var who = callerProblem(req);
1756
+ // The app's own guide POSTs: a browser sends an Origin with every POST (the app's page is
1757
+ // file://: "null"); let through with the token, or the guide ticket (for an app that can't
1758
+ // read the token: see /epg/ticket), which no web page has (/epg/ only).
1759
+ if (who === 'origin' && req.method === 'POST' && /^\/epg\/(load|get|clear)$/.test(path) && /^(null|file:\/\/)$/.test(String(req.headers.origin || '')) && (hasToken(parts[1]) || hasTicket(parts[1]))) { who = null; }
1499
1760
  if (who) { send(res, 403, 'text/plain', 'not allowed (' + who + ')'); return; }
1500
1761
  if (req.method === 'OPTIONS') { if (isFs) { send(res, 403, 'text/plain', 'not allowed'); return; } res.writeHead(200); res.end(); return; }
1501
- if (req.method !== 'GET') { send(res, 405, 'text/plain', 'GET only'); return; } // (before any work)
1502
- if (req.url.length > (/^\/fsdl\/add$/.test(path) ? 8192 : 2048)) { send(res, 414, 'text/plain', 'request too long'); return; } // (a download's link can be long)
1762
+ var epgPost = req.method === 'POST' && /^\/epg\/(load|get|clear)$/.test(path); // (a guide's channels: in the body, see epgControl)
1763
+ if (req.method !== 'GET' && !epgPost) { send(res, 405, 'text/plain', 'GET only'); return; } // (before any work)
1764
+ if (req.url.length > (/^\/epg\//.test(path) ? 32768 : /^\/fsdl\/add$/.test(path) ? 8192 : 2048)) { send(res, 414, 'text/plain', 'request too long'); return; } // (a download's link can be long; a guide's channels, a bitset)
1503
1765
  // (StremioK gave up on it — a screen left, the player closed: what it still waits for is
1504
1766
  // dropped; 'close' before the response finished means that on every Node)
1505
1767
  var leaving = [];
@@ -1512,6 +1774,15 @@ function handle(req, res) {
1512
1774
 
1513
1775
  if (/^\/entry\//.test(path)) { entryControl(req, res, path, parts[1]); return; }
1514
1776
  if (/^\/live\//.test(path)) { liveControl(req, res, path, parts[1], leaving); return; }
1777
+ if (epgPost) { // what's asked, in the body (to EPG_BODY_MAX), after the address's own (its token)
1778
+ if (+(req.headers['content-length'] || 0) > EPG_BODY_MAX) { send(res, 413, 'text/plain', 'too much asked'); req.resume(); return; }
1779
+ var body = '', over = false;
1780
+ req.setEncoding('utf8');
1781
+ req.on('data', function (c) { if (over) { return; } body += c; if (body.length > EPG_BODY_MAX) { over = true; send(res, 413, 'text/plain', 'too much asked'); } });
1782
+ req.on('end', function () { if (!over) { epgControl(req, res, path, (parts[1] ? parts[1] + '&' : '') + body); } });
1783
+ return;
1784
+ }
1785
+ if (/^\/epg\//.test(path)) { epgControl(req, res, path, parts[1]); return; } // (a playlist's TV guide: see epgControl)
1515
1786
  if (path === '/aiom/addons') { aiomControl(req, res, parts[1], leaving); return; } // (AIOManager's addons: see aiomControl) // (a live channel's sound repacked: see liveControl) // (the phone's one-time page: see below)
1516
1787
  if (path === '/fsfile') { // a USB file over HTTP, with Range: the player reads a big one from here (see fsfile in the header)
1517
1788
  var whyF = fsAllowed(req, parts[1]);
@@ -1519,20 +1790,7 @@ function handle(req, res) {
1519
1790
  var fq = {};
1520
1791
  (parts[1] || '').split('&').forEach(function (kv) { var i = kv.indexOf('='); if (i > 0) { try { fq[kv.slice(0, i)] = decodeURIComponent(kv.slice(i + 1)); } catch (e) {} } });
1521
1792
  if (!fsDirOk(fq.path || '')) { send(res, 400, 'text/plain', 'not on a USB drive'); return; }
1522
- var ffs = require('fs');
1523
- ffs.stat(fq.path, function (se, st) {
1524
- if (se || !st.isFile()) { send(res, 404, 'text/plain', 'no such file'); return; }
1525
- var size = st.size, m = /bytes=(\d*)-(\d*)/.exec(req.headers.range || ''), a0 = 0, b0 = size - 1, part = false;
1526
- if (m) { part = true; if (m[1] !== '') { a0 = +m[1]; if (m[2] !== '') { b0 = Math.min(+m[2], size - 1); } } else if (m[2] !== '') { a0 = Math.max(0, size - (+m[2])); } }
1527
- if (a0 > b0 || a0 >= size) { res.writeHead(416, { 'Content-Range': 'bytes */' + size }); res.end(); return; }
1528
- var h = { 'Content-Type': /\.mp4$|\.m4v$/i.test(fq.path) ? 'video/mp4' : 'video/x-matroska', 'Accept-Ranges': 'bytes', 'Content-Length': b0 - a0 + 1, 'Cache-Control': 'no-store' };
1529
- if (part) { h['Content-Range'] = 'bytes ' + a0 + '-' + b0 + '/' + size; }
1530
- res.writeHead(part ? 206 : 200, h);
1531
- var rs = ffs.createReadStream(fq.path, { start: a0, end: b0 });
1532
- rs.on('error', function () { try { res.end(); } catch (e) {} });
1533
- res.on('close', function () { try { rs.destroy(); } catch (e) {} });
1534
- rs.pipe(res);
1535
- });
1793
+ fsFileServe(req, res, fq.path);
1536
1794
  return;
1537
1795
  }
1538
1796
  if (/^\/fsdl\//.test(path)) { // (downloads to a USB drive: see dlControl)
@@ -1656,14 +1914,25 @@ function handle(req, res) {
1656
1914
  // its segments, only the links its own playlists named (by number), each fetched with the
1657
1915
  // channel's User-Agent and Referer. A segment comes back with its sound repacked (liveFix:
1658
1916
  // whole ADTS frames per packet, each packet with its first frame's own time), everything else
1659
- // as it was. &force=1: the relay without the look (StremioK already knows the channel).
1917
+ // as it was. &force=1: the relay without the look (StremioK already knows the channel); &fix=0
1918
+ // with it: the sound as it is. &cc=: the relay with closed captions too (see ccSegment).
1919
+ var HLS_MASTER_MAX = 524288; // (an HLS quality list: /live/master)
1660
1920
  var LIVE_MAX = 4, LIVE_IDLE_MS = 5 * 60 * 1000, LIVE_LINKS = 400, LIVE_LIST_MAX = 1048576, LIVE_SEG_MAX = 32 * 1048576, LIVE_PROBE = 1536 * 1024, LIVE_MS = 20000;
1661
1921
  var ADTS_RATES = [96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350];
1662
- var _live = {};
1922
+ var _live = Object.create(null); // (no prototype: whatever key it's asked for, nothing shared is reached)
1663
1923
  // A link's body: cb(err, Buffer, the link it ended at). part: stop at max and keep what came
1664
1924
  // (the look at a segment's start), else more than max is an error. Returns abort().
1925
+ // For all the relay's downloads together: LIVE_ACTIVE_MAX at once, LIVE_HELD_MAX bytes held
1926
+ // between them (a segment can be 32 MB; four channels' sessions don't mean four downloads):
1927
+ // past either, "busy" (503), which the TV's player asks again; one let go stops at once.
1928
+ var LIVE_ACTIVE_MAX = 6, LIVE_HELD_MAX = 64 * 1048576, _liveActive = 0, _liveHeld = 0;
1665
1929
  function liveGet(u, who, max, cb, part) {
1666
- cb = once(cb);
1930
+ if (_liveActive >= LIVE_ACTIVE_MAX) { cb(upError('busy: too many at once', 503)); return function () {}; }
1931
+ _liveActive++;
1932
+ var held = 0, freed = false;
1933
+ function free() { if (freed) { return; } freed = true; _liveActive--; _liveHeld -= held; held = 0; }
1934
+ var answer = cb;
1935
+ cb = once(function (err, body, at) { free(); answer(err, body, at); });
1667
1936
  var hops = 0, r = null, gone = false;
1668
1937
  function get(url) {
1669
1938
  var pu;
@@ -1692,17 +1961,19 @@ function liveGet(u, who, max, cb, part) {
1692
1961
  if (sc !== 200) { resp.resume(); end(upError('HTTP ' + sc, sc >= 400 && sc < 600 ? sc : 502)); return; }
1693
1962
  resp.on('data', function (c) {
1694
1963
  if (done) { return; }
1695
- chunks.push(c); size += c.length;
1964
+ chunks.push(c); size += c.length; held += c.length; _liveHeld += c.length;
1696
1965
  if (size >= max) { if (part) { end(null); } else { end(upError('too large', 502)); } }
1966
+ else if (_liveHeld > LIVE_HELD_MAX) { end(upError('busy: too much at once', 503)); }
1697
1967
  });
1698
1968
  resp.on('end', function () { end(null); });
1699
1969
  resp.on('error', function (e) { end(e); });
1700
1970
  });
1701
1971
  r.on('error', function (e) { end(e); });
1972
+ guardSelf(r, function () { end(upError('a link to the TV itself', 400)); });
1702
1973
  } catch (e) { end(e); }
1703
1974
  }
1704
1975
  get(u);
1705
- return function () { gone = true; try { if (r) { r.abort(); } } catch (e) {} };
1976
+ return function () { gone = true; try { if (r) { r.abort(); } } catch (e) {} free(); };
1706
1977
  }
1707
1978
 
1708
1979
  // MPEG-TS: a packet's PID, where its payload starts (-1: none).
@@ -1713,8 +1984,8 @@ function tsPayload(b, i) {
1713
1984
  if (afc & 2) { o += 1 + b[i + 4]; }
1714
1985
  return o < i + 188 ? o : -1;
1715
1986
  }
1716
- // The program's first AAC (ADTS) stream and the PID its clock is on: { audio, pcr } or null.
1717
- function tsAudio(b) {
1987
+ // The program's streams and the PID its clock is on: { pcr, list: [{ type, pid }] } or null.
1988
+ function tsProgram(b) {
1718
1989
  var pmt = -1;
1719
1990
  for (var i = 0; i + 188 <= b.length; i += 188) {
1720
1991
  if (b[i] !== 0x47 || !(b[i + 1] & 0x40)) { continue; }
@@ -1724,15 +1995,22 @@ function tsAudio(b) {
1724
1995
  if (pid === 0 && b[o] === 0) {
1725
1996
  for (k = o + 8; k + 4 <= last; k += 4) { if ((b[k] << 8 | b[k + 1]) !== 0) { pmt = ((b[k + 2] & 0x1f) << 8) | b[k + 3]; break; } }
1726
1997
  } else if (pid === pmt && b[o] === 2) {
1727
- var pcr = ((b[o + 8] & 0x1f) << 8) | b[o + 9], audio = null;
1998
+ var list = [];
1728
1999
  for (k = o + 12 + (((b[o + 10] & 15) << 8) | b[o + 11]); k + 5 <= last; k += 5 + (((b[k + 3] & 15) << 8) | b[k + 4])) {
1729
- if (b[k] === 0x0f && audio === null) { audio = ((b[k + 1] & 0x1f) << 8) | b[k + 2]; }
2000
+ list.push({ type: b[k], pid: ((b[k + 1] & 0x1f) << 8) | b[k + 2] });
1730
2001
  }
1731
- return { audio: audio, pcr: pcr };
2002
+ return { pcr: ((b[o + 8] & 0x1f) << 8) | b[o + 9], list: list };
1732
2003
  }
1733
2004
  }
1734
2005
  return null;
1735
2006
  }
2007
+ // The program's first AAC (ADTS) stream and the PID its clock is on: { audio, pcr } or null.
2008
+ function tsAudio(b) {
2009
+ var pr = tsProgram(b), audio = null;
2010
+ if (!pr) { return null; }
2011
+ for (var k = 0; k < pr.list.length && audio === null; k++) { if (pr.list[k].type === 0x0f) { audio = pr.list[k].pid; } }
2012
+ return { audio: audio, pcr: pr.pcr };
2013
+ }
1736
2014
  function readPts(b, o) { return ((b[o] >> 1) & 7) * 1073741824 + ((b[o + 1] << 7) | (b[o + 2] >> 1)) * 32768 + ((b[o + 3] << 7) | (b[o + 4] >> 1)); }
1737
2015
  function writePts(b, o, v) {
1738
2016
  v = Math.round(v) % 8589934592; // (33 bits: wraps)
@@ -1855,17 +2133,307 @@ function liveFix(s, b, seq) {
1855
2133
  if (group.length) { out.push(livePes(a, group)); }
1856
2134
  return Buffer.concat(out);
1857
2135
  }
2136
+
2137
+ // ---- A live channel's closed captions (CEA-608, inside its picture) ----
2138
+ // Some channels carry their subtitles as closed captions in the video itself (ATSC A/53: in each
2139
+ // picture's own data, 'GA94'), which the TV's player doesn't show. The look (/live/open) says
2140
+ // which caption channels have text (cc: [{ ch: 'CC1', lang }]); asked for (&cc=CC1-en,CC3), the
2141
+ // relay adds a subtitle track for each: a WebVTT playlist (/live/<id>/c?n=) whose segments
2142
+ // (/live/<id>/v?n=) hold the captions of the picture's own segments, read from them as they pass
2143
+ // (one download for both: liveShared). CC1/CC2 are the first field's two channels, CC3/CC4 the
2144
+ // second's. Pop-on captions (films) become a cue each; roll-up ones (live shows) a cue per
2145
+ // finished line, with the lines above it.
2146
+ var CC_KEEP = 30, CC_BODIES = 2, CC_BODY_MAX = 8 * 1048576, CC_BODY_MS = 30000, CC_PROBE = 8 * 1048576;
2147
+ var CC_VIDEO = { 0x1b: true, 0x24: true, 0x02: false }; // (H.264, HEVC: their bytes escaped; MPEG-2 video: not)
2148
+ var CC_ROW = [11, 1, 3, 12, 14, 5, 7, 9]; // (a row code's first byte, low 3 bits: its row, 1–15; the next one down with 0x20 in the second)
2149
+ var CC_BASIC = { 0x2a: 'á', 0x5c: 'é', 0x5e: 'í', 0x5f: 'ó', 0x60: 'ú', 0x7b: 'ç', 0x7c: '÷', 0x7d: 'Ñ', 0x7e: 'ñ', 0x7f: '█' };
2150
+ var CC_SPECIAL = '®°½¿™¢£♪à èâêîôû';
2151
+ var CC_EXT = ['ÁÉÓÚÜü‘¡*’—©℠•“”ÀÂÇÈÊËëÎÏïÔÙùÛ«»',
2152
+ 'ÃãÍÌìÒòÕõ{}\\^_|~ÄäÖöߥ¤¦Å娸┌┐└┘'];
2153
+ var CC_WORDS = { en: / (the|you|and|that|what|this|it|is|to|of|don't|i'm|we|are)(?= )/g, es: / (que|el|los|las|es|por|una|con|para|está|qué|no|lo|se|del)(?= )/g, pt: / (não|você|uma|com|para|isso|está|que|o|é|do|da)(?= )/g };
2154
+ var GA94 = toBuffer('GA94');
2155
+ var _ccTag = 0;
2156
+ // A picture's caption pairs: [field, b1, b2, …] (parity off; empty ones left out).
2157
+ function ccPairs(d, esc) {
2158
+ var out = [], k = 0;
2159
+ while ((k = d.indexOf(GA94, k)) >= 0) {
2160
+ var at = k, w, i;
2161
+ k += 4;
2162
+ if (!((at >= 3 && d[at - 3] === 0xb5 && d[at - 2] === 0 && d[at - 1] === 0x31) || (at >= 4 && d[at - 4] === 0 && d[at - 3] === 0 && d[at - 2] === 1 && d[at - 1] === 0xb2))) { continue; }
2163
+ if (d[k] !== 3) { continue; }
2164
+ w = d.slice(k + 1, k + 1 + 2 + 31 * 3 + 12);
2165
+ if (esc) { // (H.264 / HEVC: a 3 after two zeros is only there to break a start code)
2166
+ var raw = w, z = 0;
2167
+ w = [];
2168
+ for (i = 0; i < raw.length; i++) { if (z >= 2 && raw[i] === 3) { z = 0; continue; } w.push(raw[i]); z = raw[i] === 0 ? z + 1 : 0; }
2169
+ }
2170
+ if (w.length < 2 || !(w[0] & 0x40)) { continue; }
2171
+ for (i = 0; i < (w[0] & 0x1f) && 2 + 3 * i + 2 < w.length; i++) {
2172
+ var m = w[2 + 3 * i], b1 = w[3 + 3 * i] & 0x7f, b2 = w[4 + 3 * i] & 0x7f;
2173
+ if ((m & 4) && (m & 3) < 2 && (b1 || b2)) { out.push(m & 3, b1, b2); }
2174
+ }
2175
+ }
2176
+ return out;
2177
+ }
2178
+ // A segment's pictures with captions, in the order they're shown: { start, end, frames: [{ pts,
2179
+ // pairs }] } (times unwrapped from the first picture's), or null (no video it can read).
2180
+ function ccFrames(b) {
2181
+ var pr = tsProgram(b), v = null, i;
2182
+ if (!pr) { return null; }
2183
+ for (i = 0; i < pr.list.length && !v; i++) { if (CC_VIDEO[pr.list[i].type] !== undefined) { v = pr.list[i]; } }
2184
+ if (!v) { return null; }
2185
+ var esc = CC_VIDEO[v.type], frames = [], cur = null, base = null, lo = null, hi = null, n = 0;
2186
+ function done() {
2187
+ if (!cur) { return; }
2188
+ var d = Buffer.concat(cur.parts);
2189
+ cur.parts = null;
2190
+ if (cur.pts !== null) {
2191
+ if (base === null) { base = cur.pts; }
2192
+ var r = cur.pts - base;
2193
+ if (r > 4294967296) { r -= 8589934592; } else if (r < -4294967296) { r += 8589934592; }
2194
+ cur.pts = base + r; n++;
2195
+ if (lo === null || cur.pts < lo) { lo = cur.pts; }
2196
+ if (hi === null || cur.pts > hi) { hi = cur.pts; }
2197
+ cur.pairs = ccPairs(d, esc);
2198
+ if (cur.pairs.length) { frames.push(cur); }
2199
+ }
2200
+ cur = null;
2201
+ }
2202
+ for (i = 0; i + 188 <= b.length; i += 188) {
2203
+ if (b[i] !== 0x47) { break; }
2204
+ if (tsPid(b, i) !== v.pid) { continue; }
2205
+ var p = tsPayload(b, i);
2206
+ if (p < 0) { continue; }
2207
+ if (b[i + 1] & 0x40) {
2208
+ done();
2209
+ if (b[p] !== 0 || b[p + 1] !== 0 || b[p + 2] !== 1 || p + 9 + b[p + 8] > i + 188) { continue; }
2210
+ cur = { pts: (b[p + 7] & 0x80) ? readPts(b, p + 9) : null, parts: [b.slice(p + 9 + b[p + 8], i + 188)] };
2211
+ } else if (cur) { cur.parts.push(b.slice(p, i + 188)); }
2212
+ }
2213
+ done();
2214
+ if (lo === null) { return null; }
2215
+ frames.sort(function (x, y) { return x.pts - y.pts; });
2216
+ return { start: lo, end: hi + (n > 1 ? (hi - lo) / (n - 1) : 3003), frames: frames };
2217
+ }
2218
+ function ccRows() { var r = []; for (var i = 0; i < 15; i++) { r.push([]); } return r; }
2219
+ // One caption channel: what's on screen (rows) and what's being written off it (back, pop-on),
2220
+ // the cursor, and the cue it shows since when.
2221
+ function ccChan() { return { mode: 'pop', rows: ccRows(), back: ccRows(), roll: 2, row: 14, col: 0, shown: '', since: 0, out: null, seen: '' }; }
2222
+ function ccField() { return { ch: 0, last: -1, xds: false }; }
2223
+ function ccState() { return { f: [ccField(), ccField()], d: [ccChan(), ccChan(), ccChan(), ccChan()], seq: null, segs: Object.create(null), order: [] }; }
2224
+ function ccText(rows) {
2225
+ var out = [];
2226
+ for (var r = 0; r < rows.length; r++) { var s = rows[r].join('').replace(/^\s+|\s+$/g, ''); if (s) { out.push(s); } }
2227
+ return out.join('\n');
2228
+ }
2229
+ // The screen changed (or may have): the cue up to now out, a new one from now.
2230
+ function ccCommit(c, t) {
2231
+ var s = ccText(c.rows);
2232
+ if (s === c.shown) { return; }
2233
+ if (c.shown && c.out && t > c.since) { c.out.push({ from: c.since, to: t, text: c.shown }); }
2234
+ c.shown = s; c.since = t;
2235
+ }
2236
+ function ccPut(c, ch) {
2237
+ if (c.mode === 'text') { return; }
2238
+ var line = (c.mode === 'pop' ? c.back : c.rows)[c.row];
2239
+ while (line.length < c.col) { line.push(' '); }
2240
+ line[c.col] = ch;
2241
+ if (c.col < 31) { c.col++; }
2242
+ if (c.seen.length < 4000) { c.seen += ch; }
2243
+ }
2244
+ function ccRoll(c) { // (roll-up: the lines move up one, the bottom one starts empty)
2245
+ var top = c.row - c.roll + 1, r;
2246
+ for (r = 0; r < 15; r++) { if (r < top || r > c.row) { c.rows[r] = []; } }
2247
+ for (r = Math.max(top, 0); r < c.row; r++) { c.rows[r] = c.rows[r + 1]; }
2248
+ c.rows[c.row] = []; c.col = 0;
2249
+ }
2250
+ // A control code (its channel bit off: 0x10–0x17 first).
2251
+ function ccControl(c, b1, b2, t) {
2252
+ if ((b1 === 0x14 || b1 === 0x15) && b2 >= 0x20 && b2 <= 0x2f) {
2253
+ var line = (c.mode === 'pop' ? c.back : c.rows)[c.row];
2254
+ if (b2 === 0x20) { c.mode = 'pop'; } // (resume loading: pop-on)
2255
+ else if (b2 === 0x21) { if (c.col > 0) { c.col--; if (line.length > c.col) { line[c.col] = ' '; } } } // (backspace)
2256
+ else if (b2 === 0x24) { if (line.length > c.col) { line.length = c.col; } } // (delete to the end of the row)
2257
+ else if (b2 >= 0x25 && b2 <= 0x27) { // (roll-up, 2–4 rows)
2258
+ if (c.mode !== 'roll') { c.rows = ccRows(); c.back = ccRows(); c.row = 14; c.col = 0; }
2259
+ c.mode = 'roll'; c.roll = b2 - 0x23;
2260
+ ccCommit(c, t);
2261
+ }
2262
+ else if (b2 === 0x29) { c.mode = 'paint'; } // (paint-on)
2263
+ else if (b2 === 0x2a || b2 === 0x2b) { c.mode = 'text'; } // (text mode: not captions)
2264
+ else if (b2 === 0x2c) { c.rows = ccRows(); ccCommit(c, t); } // (erase what's shown)
2265
+ else if (b2 === 0x2d) { if (c.mode === 'roll') { ccCommit(c, t); ccRoll(c); } else { if (c.mode === 'paint') { ccCommit(c, t); } c.col = 0; } } // (carriage return: roll-up shows the lines as they stand once one's finished)
2266
+ else if (b2 === 0x2e) { c.back = ccRows(); } // (erase what's being written)
2267
+ else if (b2 === 0x2f) { var x = c.rows; c.rows = c.back; c.back = x; c.mode = 'pop'; ccCommit(c, t); } // (end of caption: it shows)
2268
+ return;
2269
+ }
2270
+ if (b1 === 0x17 && b2 >= 0x21 && b2 <= 0x23) { c.col = Math.min(31, c.col + b2 - 0x20); return; } // (tab)
2271
+ if (b1 === 0x11 && b2 >= 0x20 && b2 <= 0x2f) { ccPut(c, ' '); return; } // (a style change: shows as a space)
2272
+ if (b1 === 0x11 && b2 >= 0x30 && b2 <= 0x3f) { ccPut(c, CC_SPECIAL.charAt(b2 - 0x30)); return; }
2273
+ if ((b1 === 0x12 || b1 === 0x13) && b2 >= 0x20 && b2 <= 0x3f) { // (an accented letter: in place of the plain one sent before it)
2274
+ if (c.col > 0) { c.col--; }
2275
+ ccPut(c, CC_EXT[b1 - 0x12].charAt(b2 - 0x20));
2276
+ return;
2277
+ }
2278
+ if (b2 >= 0x40 && b2 <= 0x7f && !(b1 === 0x10 && b2 >= 0x60)) { // (a row and indent)
2279
+ var row = CC_ROW[b1 & 7] + ((b2 & 0x20) ? 1 : 0) - 1;
2280
+ if (c.mode === 'roll' && row !== c.row) { // (the roll-up lines move to it)
2281
+ var moved = ccRows();
2282
+ for (var k = 0; k < c.roll; k++) { if (c.row - k >= 0 && row - k >= 0) { moved[row - k] = c.rows[c.row - k]; } }
2283
+ c.rows = moved;
2284
+ }
2285
+ if (c.mode === 'paint') { ccCommit(c, t); }
2286
+ c.row = row; c.col = (b2 & 0x10) ? ((b2 & 0x0e) >> 1) * 4 : 0;
2287
+ }
2288
+ }
2289
+ function ccFeed(st, field, b1, b2, t) {
2290
+ var f = st.f[field], c;
2291
+ if (b1 >= 0x01 && b1 <= 0x0f) { f.xds = b1 !== 0x0f; f.last = -1; return; } // (programme data, not captions)
2292
+ if (b1 >= 0x10 && b1 <= 0x1f) {
2293
+ f.xds = false;
2294
+ var code = (b1 << 8) | b2;
2295
+ if (code === f.last) { f.last = -1; return; } // (each control code is sent twice)
2296
+ f.last = code; f.ch = (b1 & 0x08) ? 1 : 0;
2297
+ ccControl(st.d[field * 2 + f.ch], b1 & 0xf7, b2, t);
2298
+ return;
2299
+ }
2300
+ if (f.xds) { return; }
2301
+ f.last = -1; c = st.d[field * 2 + f.ch];
2302
+ if (b1 >= 0x20) { ccPut(c, CC_BASIC[b1] || String.fromCharCode(b1)); }
2303
+ if (b2 >= 0x20) { ccPut(c, CC_BASIC[b2] || String.fromCharCode(b2)); }
2304
+ }
2305
+ // A segment's cues per caption channel ({ start, cues: [[…CC1], …] }), read once (by number:
2306
+ // asked again, the same), the channels going on from the segment before when it's the next one.
2307
+ function ccSegment(st, b, seq) {
2308
+ if (st.segs[seq]) { return st.segs[seq]; }
2309
+ var fr = ccFrames(b), seg = { start: fr ? fr.start : 0, cues: [[], [], [], []] }, k;
2310
+ if (!fr || st.seq === null || seq !== st.seq + 1) { st.f = [ccField(), ccField()]; st.d = [ccChan(), ccChan(), ccChan(), ccChan()]; }
2311
+ st.seq = fr ? seq : null;
2312
+ if (fr) {
2313
+ for (k = 0; k < 4; k++) { st.d[k].out = seg.cues[k]; st.d[k].since = fr.start; }
2314
+ fr.frames.forEach(function (f) { for (var j = 0; j + 2 < f.pairs.length; j += 3) { ccFeed(st, f.pairs[j], f.pairs[j + 1], f.pairs[j + 2], f.pts); } });
2315
+ for (k = 0; k < 4; k++) { // (what's still on: up to the segment's end, and on from the next one's start)
2316
+ var c = st.d[k];
2317
+ if (c.shown && fr.end > c.since) { c.out.push({ from: c.since, to: fr.end, text: c.shown }); }
2318
+ c.out = null;
2319
+ }
2320
+ }
2321
+ st.segs[seq] = seg; st.order.push(seq);
2322
+ while (st.order.length > CC_KEEP) { delete st.segs[st.order.shift()]; }
2323
+ return seg;
2324
+ }
2325
+ // Which caption channels have text in a segment, and their language when the words say it:
2326
+ // [{ ch: 'CC1', lang: 'en' }] (null: no captions in it at all).
2327
+ function ccFound(b) {
2328
+ var fr = ccFrames(b);
2329
+ if (!fr || !fr.frames.length) { return null; }
2330
+ var st = ccState(), out = [];
2331
+ fr.frames.forEach(function (f) { for (var j = 0; j + 2 < f.pairs.length; j += 3) { ccFeed(st, f.pairs[j], f.pairs[j + 1], f.pairs[j + 2], f.pts); } });
2332
+ st.d.forEach(function (c, k) {
2333
+ var words = ' ' + c.seen.replace(/[^A-Za-zÀ-ÿ']+/g, ' ').toLowerCase() + ' ';
2334
+ if (!/[a-zà-ÿ]{2}/.test(words)) { return; }
2335
+ var best = '', n1 = 0, n2 = 0;
2336
+ Object.keys(CC_WORDS).forEach(function (l) {
2337
+ var n = (words.match(CC_WORDS[l]) || []).length;
2338
+ if (n > n1) { n2 = n1; n1 = n; best = l; } else if (n > n2) { n2 = n; }
2339
+ });
2340
+ out.push({ ch: 'CC' + (k + 1), lang: n1 >= 3 && n1 >= n2 * 1.5 ? best : '' });
2341
+ });
2342
+ return out;
2343
+ }
2344
+ function ccTime(v) {
2345
+ v = Math.max(0, Math.round(v / 90));
2346
+ function two(x) { return (x < 10 ? '0' : '') + x; }
2347
+ var ms = v % 1000;
2348
+ return two(Math.floor(v / 3600000)) + ':' + two(Math.floor(v / 60000) % 60) + ':' + two(Math.floor(v / 1000) % 60) + '.' + (ms < 100 ? (ms < 10 ? '00' : '0') : '') + ms;
2349
+ }
2350
+ // A segment's WebVTT for caption channel k (0–3): its times on the picture's own clock.
2351
+ function ccVtt(seg, k) {
2352
+ var out = ['WEBVTT', 'X-TIMESTAMP-MAP=MPEGTS:' + ((Math.round(seg.start) % 8589934592) + 8589934592) % 8589934592 + ',LOCAL:00:00:00.000', ''];
2353
+ seg.cues[k].forEach(function (q) {
2354
+ out.push(ccTime(q.from - seg.start) + ' --> ' + ccTime(q.to - seg.start), q.text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'), '');
2355
+ });
2356
+ return out.join('\n');
2357
+ }
2358
+ // The caption channels asked for: [{ k: 0–3, lang }] from 'CC1-en,CC3' (others left out).
2359
+ function ccAsked(v) {
2360
+ var out = [];
2361
+ String(v || '').split(',').forEach(function (x) {
2362
+ var m = /^CC([1-4])(?:-([a-z]{2,3}))?$/.exec(x);
2363
+ if (m && !out.some(function (o) { return o.k === m[1] - 1; })) { out.push({ k: m[1] - 1, lang: m[2] || '' }); }
2364
+ });
2365
+ return out;
2366
+ }
2367
+ // A segment downloaded once for its picture and its captions, whichever asks first: kept a
2368
+ // little (CC_BODIES, each up to CC_BODY_MAX, CC_BODY_MS) for the other. Returns its leave():
2369
+ // the download stops when no one waits for it any more.
2370
+ function liveShared(url, s, max, cb) { // (as liveGet: s, the relay, is who asks)
2371
+ var now = Date.now(), e = s.got[url];
2372
+ Object.keys(s.got).forEach(function (u) { var x = s.got[u]; if (x.body && now - x.t > CC_BODY_MS) { delete s.got[u]; } });
2373
+ e = s.got[url];
2374
+ if (e && e.body) { cb(null, e.body, e.at); return function () {}; }
2375
+ if (!e) {
2376
+ e = s.got[url] = { cbs: [], tag: ++_ccTag };
2377
+ e.stop = liveGet(url, s, max, function (err, body, at) {
2378
+ var cbs = e.cbs;
2379
+ e.cbs = [];
2380
+ if (s.got[url] === e) {
2381
+ if (err || body.length > CC_BODY_MAX) { delete s.got[url]; }
2382
+ else {
2383
+ e.body = body; e.at = at; e.t = Date.now();
2384
+ var kept = Object.keys(s.got).filter(function (u) { return s.got[u].body; }).sort(function (x, y) { return s.got[x].tag - s.got[y].tag; });
2385
+ while (kept.length > CC_BODIES) { delete s.got[kept.shift()]; }
2386
+ }
2387
+ }
2388
+ cbs.forEach(function (f) { f(err, body, at); });
2389
+ });
2390
+ }
2391
+ e.cbs.push(cb);
2392
+ return function () {
2393
+ var i = e.cbs.indexOf(cb);
2394
+ if (i >= 0) { e.cbs.splice(i, 1); }
2395
+ if (!e.cbs.length && !e.body) { if (s.got[url] === e) { delete s.got[url]; } e.stop(); }
2396
+ };
2397
+ }
2398
+ // The top playlist with the captions as subtitle tracks: a quality list gets them added (each
2399
+ // quality naming them); a plain one is put under a made-up quality list of its own (p).
2400
+ function ccMaster(s, text, base) {
2401
+ var pre = liveBase(s), lines = String(text).split(/\r?\n/), uris = lines.map(function (l) { return l.trim(); }).filter(function (l) { return l && l.charAt(0) !== '#'; });
2402
+ if (!uris.length || /#EXT-X-STREAM-INF:[^\n]*SUBTITLES=|#EXT-X-KEY:METHOD=(?!NONE)|#EXT-X-MAP/.test(text)) { return liveRewrite(s, text, base); }
2403
+ var master = /#EXT-X-STREAM-INF/.test(text), media = master ? urllib.resolve(base, uris[0]) : base;
2404
+ var tracks = s.cc.map(function (c) {
2405
+ var name = 'Closed captions (CC' + (c.k + 1) + ')';
2406
+ return '#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="cc",NAME="' + name + '",' + (c.lang ? 'LANGUAGE="' + c.lang + '",' : '') + 'DEFAULT=NO,AUTOSELECT=YES,FORCED=NO,URI="' + pre + 'c?n=' + liveLink(s, media, 'c', undefined, c.k) + '"';
2407
+ });
2408
+ if (!master) {
2409
+ return ['#EXTM3U'].concat(tracks, ['#EXT-X-STREAM-INF:BANDWIDTH=2000000,SUBTITLES="cc"', pre + 'p?n=' + liveLink(s, base, 'p')]).join('\n') + '\n';
2410
+ }
2411
+ var out = liveRewrite(s, text, base).split('\n').map(function (l) { return /^#EXT-X-STREAM-INF:/.test(l.trim()) ? l.replace(/\s+$/, '') + ',SUBTITLES="cc"' : l; });
2412
+ var head = 0;
2413
+ while (head < out.length && !/^#EXTM3U/.test(out[head].trim())) { head++; }
2414
+ return out.slice(0, head + 1).concat(tracks, out.slice(head + 1)).join('\n');
2415
+ }
2416
+ // A caption track's playlist: the picture's own segments, each as its captions (v).
2417
+ function ccList(s, text, base, k) {
2418
+ var sq = /#EXT-X-MEDIA-SEQUENCE:\s*(\d+)/.exec(text), seq = sq ? +sq[1] : 0, pre = liveBase(s);
2419
+ return String(text).split(/\r?\n/).filter(function (ln) { return !/^#EXT-X-(KEY|MAP|BYTERANGE|STREAM-INF|MEDIA:|I-FRAME)/.test(ln.trim()); }).map(function (ln) {
2420
+ var t = ln.trim();
2421
+ if (!t || t.charAt(0) === '#') { return ln; }
2422
+ return pre + 'v?n=' + liveLink(s, urllib.resolve(base, t), 'v', seq++, k);
2423
+ }).join('\n');
2424
+ }
2425
+
1858
2426
  // A playlist with its links through the relay: a master's variants and renditions (m), a media
1859
2427
  // playlist's segments (s, numbered from its media sequence); encrypted or fMP4 segments, keys:
1860
2428
  // fetched directly (their absolute links).
1861
- function liveLink(s, url, kind, seq) {
1862
- var key = kind + ' ' + url;
2429
+ function liveLink(s, url, kind, seq, ch) {
2430
+ var key = kind + (ch !== undefined ? ch : '') + ' ' + url;
1863
2431
  if (s.at[key] !== undefined) { return s.at[key]; }
1864
2432
  var n = s.next++;
1865
- s.links[n] = { url: url, kind: kind, seq: seq };
2433
+ s.links[n] = { url: url, kind: kind, seq: seq, ch: ch, key: key };
1866
2434
  s.at[key] = n;
1867
2435
  var old = s.links[n - LIVE_LINKS];
1868
- if (old) { delete s.at[old.kind + ' ' + old.url]; delete s.links[n - LIVE_LINKS]; }
2436
+ if (old) { delete s.at[old.key]; delete s.links[n - LIVE_LINKS]; }
1869
2437
  return n;
1870
2438
  }
1871
2439
  function liveBase(s) { return 'http://localhost:' + PORT + '/live/' + s.id + '/'; }
@@ -1887,13 +2455,15 @@ function liveRewrite(s, text, base) {
1887
2455
  return pre + 's?n=' + liveLink(s, abs, 's', seq++);
1888
2456
  }).join('\n');
1889
2457
  }
1890
- function liveSession(u, who) {
2458
+ // opts: { fix: the sound repacked, cc: the caption channels added (ccAsked) }.
2459
+ function liveSession(u, who, opts) {
1891
2460
  var ids = Object.keys(_live);
1892
2461
  if (ids.length >= LIVE_MAX) { // (the one asked for longest ago goes)
1893
2462
  ids.sort(function (x, y) { return _live[x].last - _live[y].last; });
1894
2463
  delete _live[ids[0]];
1895
2464
  }
1896
- var s = { id: crypto.randomBytes(16).toString('hex'), ua: who.ua, ref: who.ref, links: {}, at: {}, next: 0, last: Date.now(), a: liveAudio() };
2465
+ var s = { id: crypto.randomBytes(16).toString('hex'), ua: who.ua, ref: who.ref, links: {}, at: {}, next: 0, last: Date.now(), a: liveAudio(),
2466
+ fix: opts.fix !== false, cc: opts.cc && opts.cc.length ? opts.cc : null, c: ccState(), got: Object.create(null) };
1897
2467
  _live[s.id] = s;
1898
2468
  s.top = liveLink(s, u, 'm');
1899
2469
  return s;
@@ -1903,7 +2473,8 @@ function livePurge() {
1903
2473
  Object.keys(_live).forEach(function (id) { if (now - _live[id].last > LIVE_IDLE_MS) { delete _live[id]; } });
1904
2474
  }
1905
2475
  // The look: the playlist (a master's first variant), then the start of the segment a player
1906
- // starts with (the third from the end).
2476
+ // starts with (the third from the end); with captions in that start, the whole segment (up to
2477
+ // CC_PROBE) for which caption channels have text: cc, ccSure (false: none had text in it).
1907
2478
  function liveProbe(u, who, cb, leaving) {
1908
2479
  var depth = 0;
1909
2480
  function list(url) {
@@ -1919,9 +2490,17 @@ function liveProbe(u, who, cb, leaving) {
1919
2490
  }
1920
2491
  if (/#EXT-X-KEY:METHOD=(?!NONE)|#EXT-X-MAP/.test(text)) { cb(null, { fix: false, sure: true, why: 'encrypted or fMP4 segments' }); return; }
1921
2492
  if (!uris.length) { cb(null, { fix: false, sure: false, why: 'no segments yet' }); return; }
1922
- leaving.push(liveGet(urllib.resolve(at, uris[Math.max(0, uris.length - 3)]), who, LIVE_PROBE, function (e2, seg) {
2493
+ var su = urllib.resolve(at, uris[Math.max(0, uris.length - 3)]);
2494
+ leaving.push(liveGet(su, who, LIVE_PROBE, function (e2, seg) {
1923
2495
  if (e2) { cb(e2); return; }
1924
- cb(null, liveCut(seg));
2496
+ var v = liveCut(seg), found = null;
2497
+ try { found = ccFound(seg); } catch (e) { found = null; }
2498
+ if (!found) { v.cc = []; v.ccSure = true; cb(null, v); return; }
2499
+ leaving.push(liveGet(su, who, CC_PROBE, function (e3, whole) {
2500
+ try { found = e3 ? found : (ccFound(whole) || found); } catch (e) {}
2501
+ v.cc = found; v.ccSure = found.length > 0;
2502
+ cb(null, v);
2503
+ }, true));
1925
2504
  }, true));
1926
2505
  }));
1927
2506
  }
@@ -1932,30 +2511,53 @@ function liveControl(req, res, path, query, leaving) {
1932
2511
  livePurge();
1933
2512
  var q = {};
1934
2513
  (query || '').split('&').forEach(function (kv) { var i = kv.indexOf('='); if (i > 0) { try { q[kv.slice(0, i)] = decodeURIComponent(kv.slice(i + 1)); } catch (e) {} } });
2514
+ if (path === '/live/master') { // an HLS stream's quality list, asked with its own User-Agent / Referer (which a web page can't send)
2515
+ if (!entryAllowed(req, query)) { send(res, 403, 'text/plain', 'not allowed (token)'); return; }
2516
+ var mu = q.u || '';
2517
+ if (!/^https?:\/\/[^\s]+$/i.test(mu) || mu.length > 2000) { send(res, 400, 'text/plain', 'bad link'); return; }
2518
+ leaving.push(liveGet(mu, { ua: liveHeader(q.ua), ref: liveHeader(q.ref) }, HLS_MASTER_MAX, function (err, buf) {
2519
+ if (err) { send(res, err.status || 502, 'text/plain', 'failed: ' + (err.message || 'unknown')); return; }
2520
+ var text = buf.toString('utf8').replace(/^\uFEFF/, '');
2521
+ if (text.replace(/^\s+/, '').indexOf('#EXTM3U') !== 0) { send(res, 502, 'text/plain', 'not an HLS playlist'); return; }
2522
+ send(res, 200, 'application/vnd.apple.mpegurl', text);
2523
+ }));
2524
+ return;
2525
+ }
1935
2526
  if (path === '/live/open') {
1936
2527
  if (!entryAllowed(req, query)) { send(res, 403, 'text/plain', 'not allowed (token)'); return; }
1937
2528
  var u = q.u || '', who = { ua: liveHeader(q.ua), ref: liveHeader(q.ref) };
1938
2529
  if (!/^https?:\/\/[^\s]+$/i.test(u) || u.length > 2000) { send(res, 400, 'text/plain', 'bad link'); return; }
2530
+ var cc = ccAsked(q.cc);
1939
2531
  if (q.force === '1') {
1940
- var sf = liveSession(u, who);
2532
+ var sf = liveSession(u, who, { fix: q.fix !== '0', cc: cc });
1941
2533
  send(res, 200, 'application/json', JSON.stringify({ fix: true, sure: true, why: 'asked for', url: liveBase(sf) + 'm?n=' + sf.top }));
1942
2534
  return;
1943
2535
  }
1944
2536
  liveProbe(u, who, function (err, v) {
1945
2537
  if (err) { send(res, 200, 'application/json', JSON.stringify({ fix: false, sure: false, why: 'couldn\'t check: ' + (err.message || 'unknown') })); return; }
1946
- if (v.fix) { var s = liveSession(u, who); v.url = liveBase(s) + 'm?n=' + s.top; }
2538
+ if (v.fix) { var s = liveSession(u, who, { fix: true, cc: cc }); v.url = liveBase(s) + 'm?n=' + s.top; }
1947
2539
  send(res, 200, 'application/json', JSON.stringify(v));
1948
2540
  }, leaving);
1949
2541
  return;
1950
2542
  }
1951
- var m = /^\/live\/([0-9a-f]{32})\/(m|s)$/.exec(path), s = m && _live[m[1]], link = s && /^\d+$/.test(q.n || '') ? s.links[+q.n] : null;
2543
+ // (m: a playlist; p: the top one under its made-up quality list; s: a segment; c: a caption
2544
+ // track's playlist; v: a segment's captions)
2545
+ var m = /^\/live\/([0-9a-f]{32})\/(m|p|s|c|v)$/.exec(path), s = m && _live[m[1]], link = s && /^\d+$/.test(q.n || '') ? s.links[+q.n] : null;
1952
2546
  if (!link || link.kind !== m[2]) { send(res, 404, 'text/plain', 'not found'); return; }
1953
2547
  s.last = Date.now();
1954
- leaving.push(liveGet(link.url, s, m[2] === 'm' ? LIVE_LIST_MAX : LIVE_SEG_MAX, function (err, body, at) {
2548
+ var kind = m[2], seg = kind === 's' || kind === 'v';
2549
+ function captions(body) {
2550
+ try { return ccSegment(s.c, body, link.seq); } catch (e) { log('live captions: ' + (e && e.message)); s.c = ccState(); return null; }
2551
+ }
2552
+ if (kind === 'v' && s.c.segs[link.seq]) { send(res, 200, 'text/vtt; charset=utf-8', ccVtt(s.c.segs[link.seq], link.ch)); return; }
2553
+ leaving.push((seg && s.cc ? liveShared : liveGet)(link.url, s, seg ? LIVE_SEG_MAX : LIVE_LIST_MAX, function (err, body, at) {
1955
2554
  if (err) { send(res, err.status || 502, 'text/plain', 'failed: ' + (err.message || 'unknown')); return; }
1956
- if (m[2] === 'm') { send(res, 200, 'application/vnd.apple.mpegurl', liveRewrite(s, body.toString('utf8'), at)); return; }
2555
+ if (kind === 'v') { var cs = captions(body); send(res, 200, 'text/vtt; charset=utf-8', cs ? ccVtt(cs, link.ch) : 'WEBVTT\n'); return; }
2556
+ if (kind === 'c') { send(res, 200, 'application/vnd.apple.mpegurl', ccList(s, body.toString('utf8'), at, link.ch)); return; }
2557
+ if (kind !== 's') { send(res, 200, 'application/vnd.apple.mpegurl', s.cc && kind === 'm' && +q.n === s.top ? ccMaster(s, body.toString('utf8'), at) : liveRewrite(s, body.toString('utf8'), at)); return; }
1957
2558
  var out = body;
1958
- try { out = liveFix(s, body, link.seq); } catch (e) { log('live: ' + (e && e.message)); s.a = liveAudio(); }
2559
+ if (s.cc) { captions(body); }
2560
+ if (s.fix) { try { out = liveFix(s, body, link.seq); } catch (e) { log('live: ' + (e && e.message)); s.a = liveAudio(); } }
1959
2561
  send(res, 200, 'video/mp2t', out);
1960
2562
  }));
1961
2563
  }
@@ -1967,35 +2569,55 @@ function liveControl(req, res, path, query, leaving) {
1967
2569
  // AIOManager's Connections, with when it last read). Here rather than in the app: an instance
1968
2570
  // only answers the web origins it's set to (CORS_ORIGINS). Nothing is written to AIOManager.
1969
2571
  var AIOM_MS = 20000, AIOM_MAX = 2 * 1048576;
1970
- function hydraReq(method, url, key, body, cb, _hops) {
2572
+ // A redirect it follows with the key: the same server only (its name and port; plain HTTP may
2573
+ // move to HTTPS on the default ports, never the other way). The key goes nowhere else.
2574
+ function sameServerRedirect(from, to) {
2575
+ if (!to || (to.protocol !== 'http:' && to.protocol !== 'https:') || String(to.hostname || '').toLowerCase() !== String(from.hostname || '').toLowerCase()) { return false; }
2576
+ function port(u) { return +(u.port || (u.protocol === 'https:' ? 443 : 80)); }
2577
+ if (from.protocol === to.protocol) { return port(from) === port(to); }
2578
+ return from.protocol === 'http:' && to.protocol === 'https:' && !from.port && !to.port;
2579
+ }
2580
+ // One request (with its redirects) to an AIOManager instance: one deadline for the whole chain,
2581
+ // and the abort it returns stops whichever request is under way.
2582
+ function hydraReq(method, url, key, body, cb) {
1971
2583
  cb = once(cb);
1972
- var pu;
1973
- try { pu = urllib.parse(url); } catch (e) { cb(upError('bad address', 400)); return noop; }
1974
- if ((pu.protocol !== 'http:' && pu.protocol !== 'https:') || !dlHostOk(pu.hostname)) { cb(upError('an address it can\'t reach', 400)); return noop; }
1975
- var h = { 'X-API-Key': key, 'Accept': 'application/json', 'User-Agent': 'StremioK' }, data = body ? toBuffer(JSON.stringify(body)) : null;
1976
- if (data) { h['Content-Type'] = 'application/json'; h['Content-Length'] = data.length; }
1977
- var opts = { hostname: pu.hostname, port: pu.port, path: pu.path, method: method, headers: h };
1978
- if (pu.protocol === 'https:' && caList().length) { opts.ca = caList(); }
1979
- var r, done = false, chunks = [], size = 0;
1980
- var timer = setTimeout(function () { end(upError('AIOManager didn\'t answer', 504)); }, AIOM_MS);
1981
- function end(err, sc) { if (done) { return; } done = true; clearTimeout(timer); try { r.abort(); } catch (e) {} cb(err, sc, err ? null : Buffer.concat(chunks, size).toString('utf8')); }
1982
- try {
1983
- r = (pu.protocol === 'https:' ? https : http).request(opts, function (resp) {
1984
- var sc = resp.statusCode;
1985
- if (sc >= 300 && sc < 400 && resp.headers.location && method === 'GET' && (_hops || 0) < 4) {
1986
- done = true; clearTimeout(timer); resp.resume();
1987
- hydraReq(method, urllib.resolve(url, resp.headers.location), key, body, cb, (_hops || 0) + 1);
1988
- return;
1989
- }
1990
- resp.on('data', function (c) { if (done) { return; } chunks.push(c); size += c.length; if (size > AIOM_MAX) { end(upError('too large an answer', 502)); } });
1991
- resp.on('end', function () { if (sc !== 200) { end(upError('HTTP ' + sc, sc === 401 || sc === 403 ? 401 : 502)); return; } end(null, sc); });
1992
- resp.on('error', function (e) { end(e); });
1993
- });
1994
- r.on('error', function (e) { end(upError('couldn\'t reach it (' + (e.code || e.message) + ')', 502)); });
1995
- if (data) { r.write(data); }
1996
- r.end();
1997
- } catch (e) { end(e); }
1998
- return function () { if (!done) { done = true; clearTimeout(timer); try { r.abort(); } catch (e2) {} } };
2584
+ var ctl = { r: null, done: false }, first = null;
2585
+ try { first = urllib.parse(url); } catch (e) { first = null; }
2586
+ var timer = setTimeout(function () { finish(upError('AIOManager didn\'t answer', 504)); }, AIOM_MS);
2587
+ function finish(err, sc, text) { if (ctl.done) { return; } ctl.done = true; clearTimeout(timer); try { if (ctl.r) { ctl.r.abort(); } } catch (e) {} cb(err, sc, text); }
2588
+ function go(u, hops) {
2589
+ var pu;
2590
+ try { pu = urllib.parse(u); } catch (e) { finish(upError('bad address', 400)); return; }
2591
+ if ((pu.protocol !== 'http:' && pu.protocol !== 'https:') || !dlHostOk(pu.hostname)) { finish(upError('an address it can\'t reach', 400)); return; }
2592
+ var h = { 'X-API-Key': key, 'Accept': 'application/json', 'User-Agent': 'StremioK' }, data = body ? toBuffer(JSON.stringify(body)) : null;
2593
+ if (data) { h['Content-Type'] = 'application/json'; h['Content-Length'] = data.length; }
2594
+ var opts = { hostname: pu.hostname, port: pu.port, path: pu.path, method: method, headers: h };
2595
+ if (pu.protocol === 'https:' && caList().length) { opts.ca = caList(); }
2596
+ var chunks = [], size = 0;
2597
+ try {
2598
+ var r = ctl.r = (pu.protocol === 'https:' ? https : http).request(opts, function (resp) {
2599
+ if (ctl.done || ctl.r !== r) { resp.resume(); return; }
2600
+ var sc = resp.statusCode;
2601
+ if (sc >= 300 && sc < 400 && resp.headers.location && method === 'GET' && hops < 4) {
2602
+ resp.resume();
2603
+ var next = urllib.resolve(u, resp.headers.location), np = null;
2604
+ try { np = urllib.parse(next); } catch (e) { np = null; }
2605
+ if (!sameServerRedirect(first, np)) { finish(upError('AIOManager redirected to another server: not followed (your API key stays with it)', 502)); return; }
2606
+ go(next, hops + 1);
2607
+ return;
2608
+ }
2609
+ resp.on('data', function (c) { if (ctl.done) { return; } chunks.push(c); size += c.length; if (size > AIOM_MAX) { finish(upError('too large an answer', 502)); } });
2610
+ resp.on('end', function () { if (sc !== 200) { finish(upError('HTTP ' + sc, sc === 401 || sc === 403 ? 401 : 502)); return; } finish(null, sc, Buffer.concat(chunks, size).toString('utf8')); });
2611
+ resp.on('error', function (e) { finish(e); });
2612
+ });
2613
+ r.on('error', function (e) { if (ctl.r === r) { finish(upError('couldn\'t reach it (' + (e.code || e.message) + ')', 502)); } });
2614
+ guardSelf(r, function () { finish(upError('an address it can\'t reach', 400)); });
2615
+ if (data) { r.write(data); }
2616
+ r.end();
2617
+ } catch (e) { finish(e); }
2618
+ }
2619
+ go(url, 0);
2620
+ return function () { if (!ctl.done) { ctl.done = true; clearTimeout(timer); try { if (ctl.r) { ctl.r.abort(); } } catch (e) {} } };
1999
2621
  }
2000
2622
  function aiomControl(req, res, query, leaving) {
2001
2623
  if (!entryAllowed(req, query)) { send(res, 403, 'text/plain', 'not allowed (token)'); return; }
@@ -2022,6 +2644,407 @@ function aiomControl(req, res, query, leaving) {
2022
2644
  }));
2023
2645
  }
2024
2646
 
2647
+ // ---- TV guide (/epg/load, /epg/get) ----
2648
+ // A playlist's XMLTV guide, read for StremioK's channels only. The app sends their keys (epgKey:
2649
+ // "i:" and a tvg-id, "n:" and a name without capitals, accents, spaces or HD / FHD / 4K tags) as
2650
+ // a bitset (k: base64url; a key's FNV-1a hash picks its bit), and a guide's channel is kept when
2651
+ // its id or one of its names is in it (a few others may slip through: harmless). Downloaded
2652
+ // (gzipped or not) up to EPG_MAX, read a piece at a time up to EPG_XML_MAX unpacked, within
2653
+ // EPG_MS (past a limit: what was read, "partial"); programs from EPG_BEFORE ago to EPG_AFTER
2654
+ // ahead, EPG_PER a channel, EPG_TOTAL in all. One guide read at a time, EPG_GUIDES kept. On the
2655
+ // sideloaded app saved in its folder (epg-<hash>.json), read again after EPG_FRESH_MS; under
2656
+ // TizenBrew (no folder) kept in memory. While a guide is read again, the last one still answers.
2657
+ var EPG_MAX = 60 * 1048576, EPG_XML_MAX = 200 * 1048576, EPG_MS = 5 * 60000, EPG_BEFORE = 2 * 3600, EPG_AFTER = 30 * 3600;
2658
+ var EPG_GET_BYTES = 2 * 1048576, EPG_BODY_MAX = 65536; // (an /epg/get answer; a POST's body: the channels' keys)
2659
+ var EPG_PER = 150, EPG_TOTAL = 60000, EPG_TITLE = 120, EPG_DESC = 200, EPG_GUIDES = 4, EPG_FRESH_MS = 6 * 3600000, EPG_RETRY_MS = 10 * 60000;
2660
+ var _epg = Object.create(null), _epgQueue = [], _epgBusy = false;
2661
+ var EPG_ACCENTS = { 'á': 'a', 'à': 'a', 'â': 'a', 'ä': 'a', 'ã': 'a', 'å': 'a', 'é': 'e', 'è': 'e', 'ê': 'e', 'ë': 'e', 'í': 'i', 'ì': 'i', 'î': 'i', 'ï': 'i',
2662
+ 'ó': 'o', 'ò': 'o', 'ô': 'o', 'ö': 'o', 'õ': 'o', 'ú': 'u', 'ù': 'u', 'û': 'u', 'ü': 'u', 'ñ': 'n', 'ç': 'c' };
2663
+ // A channel's key (the same as js/epg.js's EPG.key, see there): a tvg-id ("i:", iptv-org's feed
2664
+ // after an @ left out) or a name ("n:", no capitals, accents, spaces, "TV" or HD tags), a country
2665
+ // at the end kept apart after a dot. A guide's channel answers with it and without (epgKeys).
2666
+ var EPG_COUNTRY = { ar: 'ar', arg: 'ar', mx: 'mx', mex: 'mx', us: 'us', usa: 'us', uk: 'uk', gb: 'uk', es: 'es', br: 'br', cl: 'cl', co: 'co', pe: 'pe',
2667
+ uy: 'uy', py: 'py', ve: 've', ec: 'ec', bo: 'bo', ca: 'ca', fr: 'fr', de: 'de', it: 'it', pt: 'pt' };
2668
+ function epgParts(kind, s) {
2669
+ var t = String(s || '').toLowerCase().replace(/[áàâäãåéèêëíìîïóòôöõúùûüñç]/g, function (c) { return EPG_ACCENTS[c]; }).trim(), cc = '', m;
2670
+ if (kind === 'i') { t = t.replace(/@.*$/, ''); }
2671
+ if ((m = /\.([a-z]{2})$/.exec(t))) { cc = m[1]; t = t.slice(0, -3); }
2672
+ else if (kind === 'n' && (m = /[\s_-]+([a-z]{2,3})$/.exec(t)) && EPG_COUNTRY[m[1]] && t.length > m[0].length) { cc = EPG_COUNTRY[m[1]]; t = t.slice(0, -m[0].length); }
2673
+ if (kind === 'n') { t = t.replace(/\[[^\]]*\]|\([^)]*\)/g, ' ').replace(/\b(hd|fhd|uhd|sd|hq|4k|8k|hevc|h265|1080p?|720p?|tv)\b/g, ' '); }
2674
+ t = t.replace(/[^a-z0-9]+/g, '');
2675
+ if (kind === 'n' && t.length > 4) { t = t.replace(/tv$/, ''); }
2676
+ return { base: t.slice(0, 60), cc: cc };
2677
+ }
2678
+ function epgKey(kind, s) { var p = epgParts(kind, s); return p.base ? kind + ':' + p.base + (p.cc ? '.' + p.cc : '') : ''; }
2679
+ function epgKeys(kind, s) { var p = epgParts(kind, s); return !p.base ? [] : p.cc ? [kind + ':' + p.base + '.' + p.cc, kind + ':' + p.base] : [kind + ':' + p.base]; }
2680
+ function epgHash(s) {
2681
+ var h = 0x811c9dc5;
2682
+ for (var i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0; }
2683
+ return h;
2684
+ }
2685
+ function epgHas(bits, key) {
2686
+ if (!bits || !bits.length || !key) { return false; }
2687
+ var i = epgHash(key) % (bits.length * 8);
2688
+ return !!(bits[i >> 3] & (1 << (i & 7)));
2689
+ }
2690
+ function epgBits(k) { // base64url -> bytes
2691
+ var b64 = String(k || '').replace(/-/g, '+').replace(/_/g, '/');
2692
+ try { return HAS_BUFFER_FROM ? Buffer.from(b64, 'base64') : new Buffer(b64, 'base64'); } catch (e) { return null; }
2693
+ }
2694
+ function epgTime(s) { // "20261021203000 -0300" -> seconds
2695
+ var m = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})?\s*([+-]\d{4})?/.exec(String(s || '').trim());
2696
+ if (!m) { return 0; }
2697
+ var t = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +(m[6] || 0)) / 1000;
2698
+ if (m[7]) { t -= (m[7].charAt(0) === '-' ? -1 : 1) * (+m[7].slice(1, 3) * 3600 + +m[7].slice(3, 5) * 60); }
2699
+ return Math.floor(t);
2700
+ }
2701
+ function epgText(s) { // an element's text, or an attribute's: entities read, white space folded
2702
+ return noTags(String(s || '').replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')).replace(/&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos);/gi, function (all, e) {
2703
+ var l = e.toLowerCase();
2704
+ if (l.charAt(0) !== '#') { return { amp: '&', lt: '<', gt: '>', quot: '"', apos: '\'' }[l]; }
2705
+ var n = l.charAt(1) === 'x' ? parseInt(l.slice(2), 16) : parseInt(l.slice(1), 10);
2706
+ if (!(n > 0 && n < 0x110000)) { return ''; }
2707
+ if (n < 0x10000) { return String.fromCharCode(n); }
2708
+ n -= 0x10000;
2709
+ return String.fromCharCode(0xd800 + (n >> 10), 0xdc00 + (n & 0x3ff));
2710
+ }).replace(/\s+/g, ' ').trim();
2711
+ }
2712
+ function epgAttr(tag, name) {
2713
+ var m = new RegExp('\\s' + name + '\\s*=\\s*"([^"]*)"').exec(tag) || new RegExp('\\s' + name + '\\s*=\\s*\'([^\']*)\'').exec(tag);
2714
+ return m ? epgText(m[1]) : '';
2715
+ }
2716
+ function epgInner(el, name, all) { // the text of a child element (all: of each one)
2717
+ var re = new RegExp('<' + name + '(?:\\s[^>]*)?>([\\s\\S]*?)</' + name + '>', 'g'), out = [], m;
2718
+ while ((m = re.exec(el))) { out.push(epgText(m[1])); if (!all) { break; } }
2719
+ return all ? out : (out[0] || '');
2720
+ }
2721
+ // What a program is, from its <category>s: { m: 'movie' | 'series', o: its original title, y:
2722
+ // its year } (what's known), or null. A film's often reads "The Book ThiefDrama / 2013 / 7.6":
2723
+ // the original title with the genre stuck on, the year, a rating ("Acción / 2015": no title).
2724
+ var EPG_GENRES = ['Ciencia ficción', 'C.Ficción', 'Acción', 'Animación', 'Aventura', 'Biográfico', 'Bélica', 'Comedia', 'Crimen', 'Deportivo',
2725
+ 'Documental', 'Drama', 'Familiar', 'Fantasía', 'Ficción', 'Histórico', 'Infantil', 'Misterio', 'Musical', 'Policial', 'Romance',
2726
+ 'Romántica', 'Suspenso', 'Terror', 'Thriller', 'Western'];
2727
+ function epgAbout(cats) {
2728
+ var out = null;
2729
+ (cats || []).forEach(function (c) {
2730
+ var parts = String(c).split(' / '), y = parts.length > 1 && /^(19|20)\d\d$/.test(parts[1].trim()) ? +parts[1].trim() : 0;
2731
+ if (y) {
2732
+ var t = parts[0].trim();
2733
+ for (var i = 0; i < EPG_GENRES.length; i++) {
2734
+ var g = EPG_GENRES[i];
2735
+ if (t.length > g.length && t.slice(-g.length).toLowerCase() === g.toLowerCase()) { t = t.slice(0, -g.length).trim(); break; }
2736
+ if (t.toLowerCase() === g.toLowerCase()) { t = ''; break; }
2737
+ }
2738
+ out = { m: 'movie', y: y };
2739
+ if (t) { out.o = t.slice(0, EPG_TITLE); }
2740
+ } else if (!out && /^(temporada \d+ episodio \d+|series|episode)$/i.test(String(c).trim())) {
2741
+ out = { m: 'series' };
2742
+ } else if (!out && /^(movie|feature film|película|pelicula|cine)$/i.test(String(c).trim())) {
2743
+ out = { m: 'movie' };
2744
+ }
2745
+ });
2746
+ return out;
2747
+ }
2748
+ // The reader: text in, a piece at a time (feed); <channel> and <programme> elements read as
2749
+ // they complete.
2750
+ function epgParser(want, now) {
2751
+ var p = { buf: '', channels: Object.create(null), keys: Object.create(null), progs: Object.create(null), icons: Object.create(null), count: 0, partial: false, isGuide: false }; // (no prototype: a channel id like "__proto__" or "constructor" is just a name)
2752
+ var from = now - EPG_BEFORE, to = now + EPG_AFTER;
2753
+ function channel(el) {
2754
+ var id = epgAttr(el.slice(0, el.indexOf('>') + 1), 'id');
2755
+ if (!id) { return; }
2756
+ var keys = epgKeys('i', id);
2757
+ epgInner(el, 'display-name', true).forEach(function (n) { keys = keys.concat(epgKeys('n', n)); });
2758
+ if (!keys.some(function (k) { return epgHas(want, k); })) { return; }
2759
+ p.channels[id] = 1;
2760
+ keys.forEach(function (k) { if (!p.keys[k]) { p.keys[k] = id; } });
2761
+ var icon = /<icon\s[^>]*\bsrc\s*=\s*["']([^"']{1,1000})["']/i.exec(el); // (its logo: for a channel the playlist gives none)
2762
+ if (icon && /^https?:\/\/[^\s]+$/i.test(epgText(icon[1]))) { p.icons[id] = epgText(icon[1]); }
2763
+ }
2764
+ function programme(el) {
2765
+ var tag = el.slice(0, el.indexOf('>') + 1), ch = epgAttr(tag, 'channel');
2766
+ if (!ch || !p.channels[ch]) { return; }
2767
+ var start = epgTime(epgAttr(tag, 'start')), stop = epgTime(epgAttr(tag, 'stop')) || start + 1800;
2768
+ if (!start || stop <= from || start >= to || stop <= start) { return; }
2769
+ if (p.count >= EPG_TOTAL) { p.partial = true; return; }
2770
+ var row = [start, stop, epgInner(el, 'title').slice(0, EPG_TITLE) || '?', epgInner(el, 'desc').slice(0, EPG_DESC)];
2771
+ var pic = /<icon\s[^>]*\bsrc\s*=\s*["']([^"']{1,1000})["']/i.exec(el); // (its picture)
2772
+ if (pic && /^https?:\/\/[^\s]+$/i.test(epgText(pic[1]))) { row.push(epgText(pic[1])); }
2773
+ var about = epgAbout(epgInner(el, 'category', true));
2774
+ if (about) { if (row.length < 5) { row.push(''); } row.push(about); }
2775
+ var l = p.progs[ch] = p.progs[ch] || [];
2776
+ l.push(row);
2777
+ p.count++;
2778
+ if (l.length > 2 * EPG_PER) { // (held to EPG_PER as it's read, the earliest: a feed may list one channel's programs out of order)
2779
+ l.sort(function (a, b) { return a[0] - b[0]; });
2780
+ p.count -= l.length - EPG_PER;
2781
+ l.length = EPG_PER;
2782
+ }
2783
+ }
2784
+ p.feed = function (text) {
2785
+ if (!text) { return; }
2786
+ var b = p.buf + text, at = 0;
2787
+ if (!p.isGuide && /<tv[\s>]/.test(b)) { p.isGuide = true; }
2788
+ for (;;) {
2789
+ var c = b.indexOf('<channel', at), g = b.indexOf('<programme', at);
2790
+ var i = c === -1 ? g : g === -1 ? c : Math.min(c, g);
2791
+ if (i === -1) { at = Math.max(at, b.length - 16); break; } // (an element's start may be split: kept)
2792
+ var isCh = i === c, gt = b.indexOf('>', i);
2793
+ if (gt === -1) { at = i; break; }
2794
+ if (b.charAt(gt - 1) === '/') { at = gt + 1; continue; } // (<channel id="…"/>: nothing in it)
2795
+ var close = isCh ? '</channel>' : '</programme>', j = b.indexOf(close, gt);
2796
+ if (j === -1) { at = i; break; }
2797
+ if (isCh) { channel(b.slice(i, j)); } else { programme(b.slice(i, j)); }
2798
+ at = j + close.length;
2799
+ }
2800
+ p.buf = b.slice(at);
2801
+ if (p.buf.length > 4 * 1048576) { p.buf = p.buf.slice(-16); } // (an element that never ends: not a guide's)
2802
+ };
2803
+ p.finish = function () {
2804
+ var total = 0;
2805
+ Object.keys(p.progs).forEach(function (ch) {
2806
+ var l = p.progs[ch].sort(function (a, b) { return a[0] - b[0]; });
2807
+ if (l.length > EPG_PER) { l = p.progs[ch] = l.slice(0, EPG_PER); }
2808
+ total += l.length;
2809
+ });
2810
+ p.count = total;
2811
+ };
2812
+ return p;
2813
+ }
2814
+ // The guide's download, unpacked if it's gzipped, its text handed to onText as it comes; past
2815
+ // EPG_MAX / EPG_XML_MAX it stops there (cb(null, true): what was read is kept).
2816
+ function epgFetch(url, onText, cb) {
2817
+ cb = once(cb);
2818
+ var hops = 0, r = null, done = false, got = 0, unpacked = 0, stopGz = null;
2819
+ var timer = setTimeout(function () { end(null, true); }, EPG_MS);
2820
+ function end(err, partial) { if (done) { return; } done = true; clearTimeout(timer); try { if (r) { r.abort(); } } catch (e) {} if (stopGz) { stopGz(); } cb(err, !!partial); }
2821
+ function get(u) {
2822
+ var pu;
2823
+ try { pu = urllib.parse(u); } catch (e) { end(upError('a bad link', 400)); return; }
2824
+ if ((pu.protocol !== 'http:' && pu.protocol !== 'https:') || !dlHostOk(pu.hostname)) { end(upError('a link it can\'t follow', 400)); return; }
2825
+ var opts = { hostname: pu.hostname, port: pu.port, path: pu.path, headers: { 'User-Agent': TV_UA, 'Accept-Encoding': 'gzip' } };
2826
+ if (pu.protocol === 'https:' && caList().length) { opts.ca = caList(); }
2827
+ try {
2828
+ r = (pu.protocol === 'https:' ? https : http).get(opts, function (resp) {
2829
+ if (done) { resp.resume(); return; }
2830
+ var sc = resp.statusCode;
2831
+ if (sc >= 300 && sc < 400 && resp.headers.location && hops < 5) { resp.resume(); hops++; get(urllib.resolve(u, resp.headers.location)); return; }
2832
+ if (sc !== 200) { resp.resume(); end(upError('HTTP ' + sc, 502)); return; }
2833
+ var dec = new StringDecoder('utf8'), gz = null, head = null; // (head: the first bytes, until there are two to tell gzip by)
2834
+ function text(buf) {
2835
+ if (done) { return; }
2836
+ unpacked += buf.length;
2837
+ try { onText(dec.write(buf)); } catch (e) { end(e); return; }
2838
+ if (unpacked > EPG_XML_MAX) { end(null, true); }
2839
+ }
2840
+ var told = /gzip/i.test(String(resp.headers['content-encoding'] || '')), decided = false;
2841
+ function decide(b) { // gzip (its header said so, or 1f 8b) or text
2842
+ decided = true;
2843
+ if (told || (b.length >= 2 && b[0] === 0x1f && b[1] === 0x8b)) {
2844
+ gz = zlib.createGunzip();
2845
+ gz.on('data', text);
2846
+ gz.on('error', function () { end(upError('the guide couldn\'t be unpacked', 502)); });
2847
+ gz.on('end', function () { if (!done) { try { onText(dec.end()); } catch (e) {} end(null); } });
2848
+ stopGz = function () { try { gz.removeAllListeners('data'); if (gz.close) { gz.close(); } } catch (e) {} };
2849
+ }
2850
+ }
2851
+ function feed(b) {
2852
+ if (gz) { if (gz.write(b) === false && resp.pause) { resp.pause(); gz.once('drain', function () { if (!done && resp.resume) { resp.resume(); } }); } }
2853
+ else { text(b); }
2854
+ }
2855
+ resp.on('data', function (chunk) {
2856
+ if (done) { return; }
2857
+ got += chunk.length;
2858
+ if (!decided) {
2859
+ head = head ? Buffer.concat([head, chunk]) : chunk;
2860
+ if (head.length < 2 && !told) { return; }
2861
+ decide(head);
2862
+ chunk = head; head = null;
2863
+ }
2864
+ feed(chunk);
2865
+ if (got > EPG_MAX) { end(null, true); }
2866
+ });
2867
+ resp.on('end', function () {
2868
+ if (done) { return; }
2869
+ if (!decided && head) { decide(head); feed(head); head = null; }
2870
+ if (gz) { gz.end(); } else { try { onText(dec.end()); } catch (e) {} end(null); }
2871
+ });
2872
+ resp.on('error', function (e) { end(e); });
2873
+ });
2874
+ r.on('error', function (e) { end(upError('couldn\'t reach it (' + (e.code || e.message) + ')', 502)); });
2875
+ guardSelf(r, function () { end(upError('a link it can\'t follow', 400)); });
2876
+ } catch (e) { end(e); }
2877
+ }
2878
+ get(url);
2879
+ }
2880
+ function epgFile(url) { var d = tokenDir(); return d ? d + '/epg-' + epgHash(url).toString(36) + '.json' : null; }
2881
+ // A saved guide is read only up to EPG_FILE_MAX (a bigger one is deleted); one saved more than
2882
+ // EPG_KEEP_MS ago, of any guide, is deleted (looked at once, as the first guide is asked for).
2883
+ var EPG_FILE_MAX = 32 * 1048576, EPG_KEEP_MS = 2 * 86400000, _epgSwept = false;
2884
+ function epgSweep() {
2885
+ if (_epgSwept) { return; }
2886
+ _epgSwept = true;
2887
+ var d = tokenDir(), fs = require('fs');
2888
+ if (!d) { return; }
2889
+ fs.readdir(d, function (err, names) {
2890
+ (err ? [] : names).filter(function (n) { return /^epg-[0-9a-z]+\.json(\.part)?$/.test(n); }).forEach(function (n) {
2891
+ fs.stat(d + '/' + n, function (e, st) { if (!e && st && Date.now() - st.mtime.getTime() > EPG_KEEP_MS) { fs.unlink(d + '/' + n, function () {}); } });
2892
+ });
2893
+ });
2894
+ }
2895
+ function epgSaved(url) {
2896
+ var f = epgFile(url), fs = require('fs');
2897
+ if (!f) { return null; }
2898
+ try {
2899
+ var st = fs.statSync(f);
2900
+ if (st.size > EPG_FILE_MAX) { fs.unlink(f, function () {}); return null; }
2901
+ var g = JSON.parse(String(fs.readFileSync(f, 'utf8')));
2902
+ if (!g || g.url !== url || typeof g.k !== 'string' || !g.keys || !g.progs) { return null; }
2903
+ g.state = 'ready';
2904
+ return g;
2905
+ } catch (e) { return null; }
2906
+ }
2907
+ function epgView(g) { return { state: g.state, at: g.at || 0, channels: g.channels || 0, programmes: g.programmes || 0, partial: !!g.partial, error: g.error || '' }; }
2908
+ function epgStart(url, k, force) {
2909
+ epgSweep();
2910
+ var g = _epg[url], now = Date.now();
2911
+ if (!g || g.k !== k) { // (not read here yet, or for other channels: the saved copy, if it's for these)
2912
+ var saved = epgSaved(url);
2913
+ if (saved && saved.k === k) { g = _epg[url] = saved; }
2914
+ }
2915
+ if (g && g.k === k && !force) {
2916
+ if (g.state === 'loading') { return g; }
2917
+ if (g.state === 'ready' && now - g.at < EPG_FRESH_MS) { return g; }
2918
+ if (g.failedAt && now - g.failedAt < EPG_RETRY_MS) { return g; } // (failed lately: not again yet)
2919
+ }
2920
+ var next = { url: url, k: k, state: 'loading', at: g ? g.at : 0, keys: g && g.keys, progs: g && g.progs, icons: g && g.icons, channels: g && g.channels, programmes: g && g.programmes };
2921
+ _epg[url] = next;
2922
+ var urls = Object.keys(_epg);
2923
+ if (urls.length > EPG_GUIDES) { // (the oldest others dropped, their saved files too: the one just asked for stays)
2924
+ urls.filter(function (u) { return u !== url; }).sort(function (a, b) { return (_epg[a].at || 0) - (_epg[b].at || 0); }).slice(0, urls.length - EPG_GUIDES).forEach(function (u) {
2925
+ delete _epg[u];
2926
+ var f = epgFile(u);
2927
+ if (f) { require('fs').unlink(f, function () {}); }
2928
+ });
2929
+ }
2930
+ _epgQueue.push(next);
2931
+ epgPump();
2932
+ return next;
2933
+ }
2934
+ function epgPump() {
2935
+ if (_epgBusy || !_epgQueue.length) { return; }
2936
+ var g = _epgQueue.shift();
2937
+ if (_epg[g.url] !== g) { epgPump(); return; } // (replaced meanwhile)
2938
+ _epgBusy = true;
2939
+ var p = epgParser(epgBits(g.k), Math.floor(Date.now() / 1000));
2940
+ epgFetch(g.url, p.feed, function (err, partial) {
2941
+ _epgBusy = false;
2942
+ try {
2943
+ p.finish();
2944
+ var kept = Object.keys(p.channels).length;
2945
+ if (err || (!p.isGuide && !kept)) {
2946
+ g.state = g.keys ? 'ready' : 'failed'; // (the last one read still answers)
2947
+ g.error = err ? (err.message || 'failed') : 'not a TV guide (XMLTV)';
2948
+ g.failedAt = Date.now();
2949
+ if (!g.keys) { g.at = g.failedAt; }
2950
+ log('TV guide not read: ' + g.error);
2951
+ } else if (_epg[g.url] === g) { // (still wanted: not cleared, nor dropped, meanwhile)
2952
+ g.keys = p.keys; g.progs = p.progs; g.icons = p.icons; g.channels = kept; g.programmes = p.count;
2953
+ g.partial = partial || p.partial; g.error = ''; g.state = 'ready'; g.at = Date.now();
2954
+ var f = epgFile(g.url);
2955
+ if (f) {
2956
+ var keep = { url: g.url, k: g.k, at: g.at, keys: g.keys, progs: g.progs, icons: g.icons, channels: g.channels, programmes: g.programmes, partial: g.partial };
2957
+ require('fs').writeFile(f + '.part', JSON.stringify(keep), { mode: 384 }, function (e) { // (written whole, then swapped in)
2958
+ if (e) { log('TV guide not saved (' + (e.code || e.message) + ')'); return; }
2959
+ require('fs').rename(f + '.part', f, function (e2) { if (e2) { log('TV guide not saved (' + (e2.code || e2.message) + ')'); } });
2960
+ });
2961
+ }
2962
+ }
2963
+ } catch (e) { g.state = 'failed'; g.error = 'failed'; g.at = Date.now(); }
2964
+ epgPump();
2965
+ });
2966
+ }
2967
+ // The guide ticket: for an app that can't read the service's token (its header instead), asked
2968
+ // for once (/epg/ticket, a GET with the header: what that app reaches everything with; a web page,
2969
+ // which sends an Origin, can't), then given with its guide POSTs. Made anew each time the service starts.
2970
+ var _epgTicket = crypto.randomBytes(24).toString('hex');
2971
+ function hasTicket(query) {
2972
+ var m = /(?:^|&)ticket=([0-9a-f]{48})(?:&|$)/.exec(query || '');
2973
+ return !!(m && sameToken(m[1], _epgTicket));
2974
+ }
2975
+ // Settings › Storage: the saved guides (/epg/files: how many, their size), and Clear saved
2976
+ // copies (/epg/clear, a POST: every saved guide deleted, and those kept here let go).
2977
+ function epgSavedFiles() {
2978
+ var d = tokenDir(), fs = require('fs'), out = { count: 0, bytes: 0 };
2979
+ if (!d) { return out; }
2980
+ try {
2981
+ fs.readdirSync(d).filter(function (n) { return /^epg-[0-9a-z]+\.json(\.part)?$/.test(n); }).forEach(function (n) {
2982
+ try { var st = fs.statSync(d + '/' + n); out.count++; out.bytes += st.size; out.names = (out.names || []).concat(n); } catch (e) {}
2983
+ });
2984
+ } catch (e) {}
2985
+ return out;
2986
+ }
2987
+ function epgClear() {
2988
+ var d = tokenDir(), files = epgSavedFiles();
2989
+ (files.names || []).forEach(function (n) { try { require('fs').unlinkSync(d + '/' + n); } catch (e) {} });
2990
+ _epg = Object.create(null); _epgQueue = []; // (one being read: not saved, see epgPump)
2991
+ return files.count;
2992
+ }
2993
+ function epgControl(req, res, path, query) {
2994
+ if (!entryAllowed(req, query) && !(req.method === 'POST' && hasTicket(query))) { send(res, 403, 'text/plain', 'not allowed (token)'); return; } // (the guide ticket: its POSTs, see hasTicket)
2995
+ if (path === '/epg/ticket') {
2996
+ if (req.method !== 'GET' || (req.headers && req.headers.origin !== undefined)) { send(res, 403, 'text/plain', 'not allowed'); return; }
2997
+ send(res, 200, 'application/json', JSON.stringify({ ticket: _epgTicket }));
2998
+ return;
2999
+ }
3000
+ if (path === '/epg/files') { var sf = epgSavedFiles(); send(res, 200, 'application/json', JSON.stringify({ count: sf.count, bytes: sf.bytes })); return; }
3001
+ if (path === '/epg/clear') {
3002
+ if (req.method !== 'POST') { send(res, 405, 'text/plain', 'POST only'); return; }
3003
+ send(res, 200, 'application/json', JSON.stringify({ cleared: epgClear() }));
3004
+ return;
3005
+ }
3006
+ var q = {};
3007
+ String(query || '').split('&').forEach(function (kv) { var i = kv.indexOf('='); if (i > 0) { try { q[kv.slice(0, i)] = decodeURIComponent(kv.slice(i + 1)); } catch (e) {} } });
3008
+ var u = String(q.u || '').trim();
3009
+ if (!/^https?:\/\/[^\s]+$/i.test(u) || u.length > 2000) { send(res, 400, 'text/plain', 'bad guide link'); return; }
3010
+ if (path === '/epg/load') {
3011
+ if (!/^[A-Za-z0-9_-]{4,24000}$/.test(q.k || '')) { send(res, 400, 'text/plain', 'bad channels'); return; }
3012
+ send(res, 200, 'application/json', JSON.stringify(epgView(epgStart(u, q.k, q.force === '1'))));
3013
+ return;
3014
+ }
3015
+ if (path === '/epg/get') {
3016
+ // Each key asked: its programs; [] for a channel in the guide with none then; nothing for one
3017
+ // not in it; _cut: those not looked at (past EPG_GET_BYTES); _unread: no guide here to ask.
3018
+ var g = _epg[u], now = Math.floor(Date.now() / 1000), out = Object.create(null);
3019
+ var from = +q.from || now - EPG_BEFORE, to = +q.to || now + EPG_AFTER, max = Math.min(EPG_PER, Math.max(1, +q.max || EPG_PER));
3020
+ if (!(g && g.keys)) { out._unread = true; }
3021
+ else {
3022
+ var icons = {}, bytes = 0; // (an answer kept to EPG_GET_BYTES: past it, said to be partial)
3023
+ String(q.ids || '').split('|').slice(0, 300).forEach(function (key) {
3024
+ var own = Object.prototype.hasOwnProperty; // (a saved guide's tables have a prototype: own entries only, of the right kind)
3025
+ var id = key && own.call(g.keys, key) && typeof g.keys[key] === 'string' ? g.keys[key] : null;
3026
+ var l = id && own.call(g.progs, id) && Array.isArray(g.progs[id]) ? g.progs[id] : null;
3027
+ if (id && g.icons && own.call(g.icons, id) && typeof g.icons[id] === 'string') { icons[key] = g.icons[id]; }
3028
+ if (!id) { return; }
3029
+ if (out._partial) { (out._cut = out._cut || []).push(key); return; }
3030
+ var sel = [];
3031
+ for (var i = 0; l && i < l.length && sel.length < max; i++) {
3032
+ if (!(l[i][1] > from && l[i][0] < to)) { continue; }
3033
+ bytes += JSON.stringify(l[i]).length + 1;
3034
+ if (bytes > EPG_GET_BYTES) { out._partial = true; break; }
3035
+ sel.push(l[i]);
3036
+ }
3037
+ if (out._partial) { (out._cut = out._cut || []).push(key); return; } // (this one too: looked at in part)
3038
+ out[key] = sel;
3039
+ });
3040
+ if (Object.keys(icons).length) { out._icons = icons; }
3041
+ }
3042
+ send(res, 200, 'application/json', JSON.stringify(out));
3043
+ return;
3044
+ }
3045
+ send(res, 404, 'text/plain', 'not found');
3046
+ }
3047
+
2025
3048
  // ---- Entering text from the phone ----
2026
3049
  // While the TV shows its QR code, a one-time page on the home network (ENTRY_PORT, on the
2027
3050
  // TV's home-network address only): one field, one entry, behind a random code in its address.
@@ -2036,7 +3059,7 @@ var ENTRY_OPEN_MAX = 2, ENTRY_OPENS = 12, ENTRY_CONNECTIONS = 8, ENTRY_VISIT_MS
2036
3059
  var ENTRY_TITLES = { link: 'Play a link on StremioK', addon: 'Add an addon to StremioK', playlist: 'Add an IPTV playlist to StremioK', config: 'Import a configuration file into StremioK',
2037
3060
  search: 'Search on StremioK', youtube: 'Add a YouTube catalog to StremioK', aiom: 'Your AIOManager address for StremioK', aiomkey: 'Your AIOManager API key for StremioK' };
2038
3061
  var ENTRY_TEXT = { search: 'Words to search for', youtube: 'A channel, a playlist link, or words to search YouTube for', aiomkey: 'Your account API key' }; // (plain text, not a link)
2039
- var _entries = {}, _entryServer = null, _entrySockets = [], _entryOpened = [];
3062
+ var _entries = Object.create(null), _entryServer = null, _entrySockets = [], _entryOpened = [];
2040
3063
  function lanAddress() {
2041
3064
  try {
2042
3065
  var nets = require('os').networkInterfaces(), names = Object.keys(nets), i, j;