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