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/CHANGELOG.md +82 -0
- package/README.md +9 -9
- package/config.xml +1 -1
- package/css/style.css +27 -0
- package/index.html +34 -31
- package/js/app.js +19 -7
- package/js/epg.js +320 -0
- package/js/iptv.js +198 -25
- package/js/keys.js +2 -0
- package/js/player-controls.js +50 -8
- package/js/player-core.js +112 -12
- package/js/player-store.js +21 -1
- package/js/player-subs.js +38 -8
- package/js/stremio.js +70 -6
- package/js/trakt.js +1 -0
- package/js/util.js +24 -13
- package/js/view-core.js +128 -55
- package/js/view-guide.js +332 -0
- package/js/view-home.js +148 -33
- package/js/view-settings.js +103 -35
- package/js/views.js +3 -2
- package/js/ytplay.js +53 -5
- package/package.json +2 -1
- package/service/index.js +863 -46
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
|
-
|
|
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]))
|
|
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(
|
|
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
|
|
1512
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
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) {
|
|
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
|
-
|
|
1687
|
-
if (req.
|
|
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
|
|
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
|
|
1891
|
-
function
|
|
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
|
|
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
|
-
|
|
2000
|
+
list.push({ type: b[k], pid: ((b[k + 1] & 0x1f) << 8) | b[k + 2] });
|
|
1904
2001
|
}
|
|
1905
|
-
return {
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>'), '');
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
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;
|