stremiok 2.1.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;
@@ -332,6 +341,27 @@ function cacheSet(page, master, links) {
332
341
  _ytCache[page] = { master: master, links: links || [], exp: now + 240000 }; // 4 min
333
342
  }
334
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
+ }
335
365
  function resolveYtMaster(ytPage, fresh, cb) {
336
366
  cb = once(cb);
337
367
  var hit = _ytCache[ytPage];
@@ -398,7 +428,7 @@ function limitedResolve(page, fresh, cb) {
398
428
  _inflight[key] = [cb];
399
429
  function run() {
400
430
  _active++; _recent.push(Date.now());
401
- resolveYtMaster(page, fresh, function (err, master, links) {
431
+ resolveYtLive(page, fresh, function (err, master, links) {
402
432
  _active--;
403
433
  var waiting = _inflight[key] || [];
404
434
  delete _inflight[key];
@@ -593,10 +623,24 @@ function vttTime(sec) {
593
623
  // YouTube's caption file (its "srv1" form: <text start dur>…</text>) as WebVTT. Automatic
594
624
  // captions roll (each line stays up while the next comes): each ends when the next starts,
595
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
+ }
596
640
  function srv1ToVtt(xml, auto) {
597
641
  var cues = [], re = /<text start="([\d.]+)"(?: dur="([\d.]+)")?[^>]*>([\s\S]*?)<\/text>/g, m;
598
642
  while ((m = re.exec(xml)) && cues.length < 20000) {
599
- 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)
600
644
  if (!text) { continue; }
601
645
  var st = +m[1], en = st + (+m[2] || 2);
602
646
  cues.push({ st: st, en: en, text: text });
@@ -606,7 +650,7 @@ function srv1ToVtt(xml, auto) {
606
650
  var next = cues[i + 1], en = c.en;
607
651
  if (auto && next && next.st < en) { en = next.st; }
608
652
  if (en <= c.st) { en = c.st + 0.5; }
609
- out.push(vttTime(c.st) + ' --> ' + vttTime(en), c.text.replace(/-->/g, '->'), '');
653
+ out.push(vttTime(c.st) + ' --> ' + vttTime(en), c.text.replace(/--!?>/g, '->'), '');
610
654
  });
611
655
  return out.join('\n');
612
656
  }
@@ -1508,10 +1552,18 @@ function dlFinishedBefore(e, cb) {
1508
1552
  });
1509
1553
  });
1510
1554
  }
1511
- // A resumed answer must go on exactly where the partial file ends: "bytes <have>-…/…".
1512
- function resumeOk(have, contentRange) {
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) {
1513
1558
  var m = /^bytes (\d+)-(\d+)\/(\d+|\*)$/.exec(String(contentRange || '').trim());
1514
- return !!m && +m[1] === have && +m[2] >= have;
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;
1515
1567
  }
1516
1568
  // A streamed playlist (HLS, DASH) isn't a file to download: by its type, or its first bytes.
1517
1569
  function isPlaylistAnswer(type, first) {
@@ -1551,10 +1603,15 @@ function dlRun(e) {
1551
1603
  if (bad) { dlEnd(e, 'failed', 'the folder: ' + bad.message); return; }
1552
1604
  fs.stat(part, function (se, st) {
1553
1605
  var have = se ? 0 : st.size;
1554
- 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
+ });
1555
1611
  });
1556
1612
  });
1557
1613
  });
1614
+ function dropPart(then) { fs.unlink(part + '.v', function () { fs.unlink(part, function () { then(); }); }); }
1558
1615
  function get(u, hops, have) {
1559
1616
  if (e.state !== 'downloading') { return; }
1560
1617
  var pu = urllib.parse(u), mod = pu.protocol === 'https:' ? https : http;
@@ -1577,14 +1634,21 @@ function dlRun(e) {
1577
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; }
1578
1635
  if (sc >= 500) { resp.resume(); closed = true; again('the server: HTTP ' + sc); return; }
1579
1636
  if (sc !== 200 && sc !== 206) { resp.resume(); closed = true; dlEnd(e, 'failed', 'HTTP ' + sc); return; }
1580
- if (sc === 206 && have > 0 && !resumeOk(have, resp.headers['content-range'])) { // (not where the partial file ends: started over)
1637
+ if (sc === 206 && !resumeOk(have, resp.headers['content-range'])) { // (not where the file so far ends, or a range that can't be)
1581
1638
  resp.resume(); closed = true;
1582
- fs.unlink(part, function () { if (e.state === 'downloading') { get(e.url, 0, 0); } });
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'); }
1583
1641
  return;
1584
1642
  }
1643
+ var range = sc === 206 ? rangeOf(resp.headers['content-range']) : null, expect = range ? range.last - range.first + 1 : 0, came = 0;
1585
1644
  var resumed = sc === 206 && have > 0;
1586
1645
  if (!resumed) { have = 0; }
1587
- if (!resumed) { var et = resp.headers.etag; e.validator = et && !/^W\//.test(et) ? et : (resp.headers['last-modified'] || ''); } // (asked again on a resume: If-Range)
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
+ }
1588
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; }
1589
1653
  var total = 0, cr = /\/(\d+)\s*$/.exec(resp.headers['content-range'] || '');
1590
1654
  if (cr) { total = +cr[1]; } else if (resp.headers['content-length']) { total = have + (+resp.headers['content-length']); }
@@ -1604,11 +1668,17 @@ function dlRun(e) {
1604
1668
  try { ws = fs.createWriteStream(part, { fd: fd }); }
1605
1669
  catch (er) { resp.resume(); closed = true; dlEnd(e, 'failed', 'can\'t write to the drive: ' + (er.code || er.message)); return; }
1606
1670
  e.got = have;
1607
- var firstSeen = resumed;
1608
- resp.on('data', function (c) { // (a playlist's first bytes, where its type didn't say: refused)
1609
- if (firstSeen) { return; }
1610
- firstSeen = true;
1611
- if (isPlaylistAnswer('', c.slice(0, 600).toString('utf8'))) { stop(); fs.unlink(part, function () {}); dlEnd(e, 'failed', 'a streamed playlist (HLS or DASH), not a file: it can\'t be downloaded'); }
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(); }
1612
1682
  });
1613
1683
  var tick = { at: Date.now(), got: have };
1614
1684
  ws.on('error', function (er) { if (!closed) { stop(); dlEnd(e, 'failed', 'can\'t write to the drive: ' + (er.code || er.message)); } });
@@ -1619,7 +1689,7 @@ function dlRun(e) {
1619
1689
  });
1620
1690
  // (the server closing before the end: a dropped connection, picked up where it stopped)
1621
1691
  var ended = false;
1622
- resp.on('end', function () { ended = true; });
1692
+ resp.on('end', function () { ended = true; if (!seen) { lookAtHead(); } });
1623
1693
  function dropped() {
1624
1694
  // (all of it came: on the 2017 TV's Node a small answer without a size — a
1625
1695
  // subtitle, sent chunked — can report its connection closed before its end
@@ -1636,14 +1706,16 @@ function dlRun(e) {
1636
1706
  ws.on('finish', function () {
1637
1707
  if (closed || e.state !== 'downloading') { return; }
1638
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; }
1639
1710
  if (e.size && e.got < e.size) { again('the connection dropped'); return; } // (picked up where it stopped)
1640
- if (e.size && e.got > e.size) { fs.unlink(part, function () {}); dlEnd(e, 'failed', 'more came than the file\'s size: started again it may work'); return; }
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; }
1641
1712
  var final = e.dir + '/' + e.name;
1642
1713
  fs.lstat(final, function (fe) { // (anything there, a link too: not replaced)
1643
1714
  if (!fe) { final = e.dir + '/' + e.name.replace(/(\.[^.]+)?$/, ' (' + Date.now().toString(36) + ')$1'); } // (one already there: not overwritten)
1644
1715
  fs.rename(part, final, function (re) {
1645
1716
  if (re) { dlEnd(e, 'failed', 'couldn\'t finish the file: ' + (re.code || re.message)); return; }
1646
1717
  e.name = final.split('/').pop(); e.tries = 0;
1718
+ fs.unlink(part + '.v', function () {});
1647
1719
  dlEnd(e, 'done');
1648
1720
  });
1649
1721
  });
@@ -1674,17 +1746,22 @@ function handle(req, res) {
1674
1746
  res.__noCors = isFs;
1675
1747
  if (!isFs) {
1676
1748
  res.setHeader('Access-Control-Allow-Origin', '*');
1677
- res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
1749
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
1678
1750
  res.setHeader('Access-Control-Allow-Headers', 'X-StremioK, X-AIOM-Key, *'); // (named: older engines don't take *)
1679
1751
  }
1680
1752
  res.setHeader('Cache-Control', 'no-store');
1681
1753
  res.setHeader('X-Content-Type-Options', 'nosniff');
1682
1754
  if (path === '/health' && req.method === 'GET') { send(res, 200, 'text/plain', 'ok'); return; }
1683
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; }
1684
1760
  if (who) { send(res, 403, 'text/plain', 'not allowed (' + who + ')'); return; }
1685
1761
  if (req.method === 'OPTIONS') { if (isFs) { send(res, 403, 'text/plain', 'not allowed'); return; } res.writeHead(200); res.end(); return; }
1686
- if (req.method !== 'GET') { send(res, 405, 'text/plain', 'GET only'); return; } // (before any work)
1687
- 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)
1688
1765
  // (StremioK gave up on it — a screen left, the player closed: what it still waits for is
1689
1766
  // dropped; 'close' before the response finished means that on every Node)
1690
1767
  var leaving = [];
@@ -1697,6 +1774,15 @@ function handle(req, res) {
1697
1774
 
1698
1775
  if (/^\/entry\//.test(path)) { entryControl(req, res, path, parts[1]); return; }
1699
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)
1700
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)
1701
1787
  if (path === '/fsfile') { // a USB file over HTTP, with Range: the player reads a big one from here (see fsfile in the header)
1702
1788
  var whyF = fsAllowed(req, parts[1]);
@@ -1828,15 +1914,25 @@ function handle(req, res) {
1828
1914
  // its segments, only the links its own playlists named (by number), each fetched with the
1829
1915
  // channel's User-Agent and Referer. A segment comes back with its sound repacked (liveFix:
1830
1916
  // whole ADTS frames per packet, each packet with its first frame's own time), everything else
1831
- // 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).
1832
1919
  var HLS_MASTER_MAX = 524288; // (an HLS quality list: /live/master)
1833
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;
1834
1921
  var ADTS_RATES = [96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350];
1835
- var _live = {};
1922
+ var _live = Object.create(null); // (no prototype: whatever key it's asked for, nothing shared is reached)
1836
1923
  // A link's body: cb(err, Buffer, the link it ended at). part: stop at max and keep what came
1837
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;
1838
1929
  function liveGet(u, who, max, cb, part) {
1839
- 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); });
1840
1936
  var hops = 0, r = null, gone = false;
1841
1937
  function get(url) {
1842
1938
  var pu;
@@ -1865,8 +1961,9 @@ function liveGet(u, who, max, cb, part) {
1865
1961
  if (sc !== 200) { resp.resume(); end(upError('HTTP ' + sc, sc >= 400 && sc < 600 ? sc : 502)); return; }
1866
1962
  resp.on('data', function (c) {
1867
1963
  if (done) { return; }
1868
- chunks.push(c); size += c.length;
1964
+ chunks.push(c); size += c.length; held += c.length; _liveHeld += c.length;
1869
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)); }
1870
1967
  });
1871
1968
  resp.on('end', function () { end(null); });
1872
1969
  resp.on('error', function (e) { end(e); });
@@ -1876,7 +1973,7 @@ function liveGet(u, who, max, cb, part) {
1876
1973
  } catch (e) { end(e); }
1877
1974
  }
1878
1975
  get(u);
1879
- return function () { gone = true; try { if (r) { r.abort(); } } catch (e) {} };
1976
+ return function () { gone = true; try { if (r) { r.abort(); } } catch (e) {} free(); };
1880
1977
  }
1881
1978
 
1882
1979
  // MPEG-TS: a packet's PID, where its payload starts (-1: none).
@@ -1887,8 +1984,8 @@ function tsPayload(b, i) {
1887
1984
  if (afc & 2) { o += 1 + b[i + 4]; }
1888
1985
  return o < i + 188 ? o : -1;
1889
1986
  }
1890
- // The program's first AAC (ADTS) stream and the PID its clock is on: { audio, pcr } or null.
1891
- 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) {
1892
1989
  var pmt = -1;
1893
1990
  for (var i = 0; i + 188 <= b.length; i += 188) {
1894
1991
  if (b[i] !== 0x47 || !(b[i + 1] & 0x40)) { continue; }
@@ -1898,15 +1995,22 @@ function tsAudio(b) {
1898
1995
  if (pid === 0 && b[o] === 0) {
1899
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; } }
1900
1997
  } else if (pid === pmt && b[o] === 2) {
1901
- var pcr = ((b[o + 8] & 0x1f) << 8) | b[o + 9], audio = null;
1998
+ var list = [];
1902
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])) {
1903
- 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] });
1904
2001
  }
1905
- return { audio: audio, pcr: pcr };
2002
+ return { pcr: ((b[o + 8] & 0x1f) << 8) | b[o + 9], list: list };
1906
2003
  }
1907
2004
  }
1908
2005
  return null;
1909
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
+ }
1910
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)); }
1911
2015
  function writePts(b, o, v) {
1912
2016
  v = Math.round(v) % 8589934592; // (33 bits: wraps)
@@ -2029,17 +2133,307 @@ function liveFix(s, b, seq) {
2029
2133
  if (group.length) { out.push(livePes(a, group)); }
2030
2134
  return Buffer.concat(out);
2031
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
+
2032
2426
  // A playlist with its links through the relay: a master's variants and renditions (m), a media
2033
2427
  // playlist's segments (s, numbered from its media sequence); encrypted or fMP4 segments, keys:
2034
2428
  // fetched directly (their absolute links).
2035
- function liveLink(s, url, kind, seq) {
2036
- var key = kind + ' ' + url;
2429
+ function liveLink(s, url, kind, seq, ch) {
2430
+ var key = kind + (ch !== undefined ? ch : '') + ' ' + url;
2037
2431
  if (s.at[key] !== undefined) { return s.at[key]; }
2038
2432
  var n = s.next++;
2039
- s.links[n] = { url: url, kind: kind, seq: seq };
2433
+ s.links[n] = { url: url, kind: kind, seq: seq, ch: ch, key: key };
2040
2434
  s.at[key] = n;
2041
2435
  var old = s.links[n - LIVE_LINKS];
2042
- 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]; }
2043
2437
  return n;
2044
2438
  }
2045
2439
  function liveBase(s) { return 'http://localhost:' + PORT + '/live/' + s.id + '/'; }
@@ -2061,13 +2455,15 @@ function liveRewrite(s, text, base) {
2061
2455
  return pre + 's?n=' + liveLink(s, abs, 's', seq++);
2062
2456
  }).join('\n');
2063
2457
  }
2064
- function liveSession(u, who) {
2458
+ // opts: { fix: the sound repacked, cc: the caption channels added (ccAsked) }.
2459
+ function liveSession(u, who, opts) {
2065
2460
  var ids = Object.keys(_live);
2066
2461
  if (ids.length >= LIVE_MAX) { // (the one asked for longest ago goes)
2067
2462
  ids.sort(function (x, y) { return _live[x].last - _live[y].last; });
2068
2463
  delete _live[ids[0]];
2069
2464
  }
2070
- 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) };
2071
2467
  _live[s.id] = s;
2072
2468
  s.top = liveLink(s, u, 'm');
2073
2469
  return s;
@@ -2077,7 +2473,8 @@ function livePurge() {
2077
2473
  Object.keys(_live).forEach(function (id) { if (now - _live[id].last > LIVE_IDLE_MS) { delete _live[id]; } });
2078
2474
  }
2079
2475
  // The look: the playlist (a master's first variant), then the start of the segment a player
2080
- // 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).
2081
2478
  function liveProbe(u, who, cb, leaving) {
2082
2479
  var depth = 0;
2083
2480
  function list(url) {
@@ -2093,9 +2490,17 @@ function liveProbe(u, who, cb, leaving) {
2093
2490
  }
2094
2491
  if (/#EXT-X-KEY:METHOD=(?!NONE)|#EXT-X-MAP/.test(text)) { cb(null, { fix: false, sure: true, why: 'encrypted or fMP4 segments' }); return; }
2095
2492
  if (!uris.length) { cb(null, { fix: false, sure: false, why: 'no segments yet' }); return; }
2096
- 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) {
2097
2495
  if (e2) { cb(e2); return; }
2098
- 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));
2099
2504
  }, true));
2100
2505
  }));
2101
2506
  }
@@ -2122,26 +2527,37 @@ function liveControl(req, res, path, query, leaving) {
2122
2527
  if (!entryAllowed(req, query)) { send(res, 403, 'text/plain', 'not allowed (token)'); return; }
2123
2528
  var u = q.u || '', who = { ua: liveHeader(q.ua), ref: liveHeader(q.ref) };
2124
2529
  if (!/^https?:\/\/[^\s]+$/i.test(u) || u.length > 2000) { send(res, 400, 'text/plain', 'bad link'); return; }
2530
+ var cc = ccAsked(q.cc);
2125
2531
  if (q.force === '1') {
2126
- var sf = liveSession(u, who);
2532
+ var sf = liveSession(u, who, { fix: q.fix !== '0', cc: cc });
2127
2533
  send(res, 200, 'application/json', JSON.stringify({ fix: true, sure: true, why: 'asked for', url: liveBase(sf) + 'm?n=' + sf.top }));
2128
2534
  return;
2129
2535
  }
2130
2536
  liveProbe(u, who, function (err, v) {
2131
2537
  if (err) { send(res, 200, 'application/json', JSON.stringify({ fix: false, sure: false, why: 'couldn\'t check: ' + (err.message || 'unknown') })); return; }
2132
- 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; }
2133
2539
  send(res, 200, 'application/json', JSON.stringify(v));
2134
2540
  }, leaving);
2135
2541
  return;
2136
2542
  }
2137
- 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;
2138
2546
  if (!link || link.kind !== m[2]) { send(res, 404, 'text/plain', 'not found'); return; }
2139
2547
  s.last = Date.now();
2140
- 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) {
2141
2554
  if (err) { send(res, err.status || 502, 'text/plain', 'failed: ' + (err.message || 'unknown')); return; }
2142
- 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; }
2143
2558
  var out = body;
2144
- 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(); } }
2145
2561
  send(res, 200, 'video/mp2t', out);
2146
2562
  }));
2147
2563
  }
@@ -2228,6 +2644,407 @@ function aiomControl(req, res, query, leaving) {
2228
2644
  }));
2229
2645
  }
2230
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
+
2231
3048
  // ---- Entering text from the phone ----
2232
3049
  // While the TV shows its QR code, a one-time page on the home network (ENTRY_PORT, on the
2233
3050
  // TV's home-network address only): one field, one entry, behind a random code in its address.
@@ -2242,7 +3059,7 @@ var ENTRY_OPEN_MAX = 2, ENTRY_OPENS = 12, ENTRY_CONNECTIONS = 8, ENTRY_VISIT_MS
2242
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',
2243
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' };
2244
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)
2245
- var _entries = {}, _entryServer = null, _entrySockets = [], _entryOpened = [];
3062
+ var _entries = Object.create(null), _entryServer = null, _entrySockets = [], _entryOpened = [];
2246
3063
  function lanAddress() {
2247
3064
  try {
2248
3065
  var nets = require('os').networkInterfaces(), names = Object.keys(nets), i, j;