stremiok 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/config.xml +1 -1
- package/css/style.css +10 -2
- package/index.html +31 -31
- package/js/downloads.js +17 -5
- package/js/hubpreview.js +15 -5
- package/js/iptv.js +4 -4
- package/js/player-controls.js +5 -0
- package/js/player-core.js +92 -19
- package/js/player-lang.js +1 -1
- package/js/player-quality.js +15 -7
- package/js/player-store.js +7 -3
- package/js/player-subs.js +7 -3
- package/js/player.js +1 -1
- package/js/stremio-account.js +3 -0
- package/js/stremio.js +87 -22
- package/js/trakt.js +60 -9
- package/js/util.js +17 -2
- package/js/view-core.js +11 -0
- package/js/view-detail.js +5 -4
- package/js/view-home.js +27 -10
- package/js/view-settings.js +67 -45
- package/js/youtube.js +1 -3
- package/js/ytplay.js +10 -1
- package/package.json +1 -1
- package/service/index.js +280 -74
- package/service/preview.js +14 -4
package/service/index.js
CHANGED
|
@@ -117,6 +117,31 @@ function videoHost(h) { return /(^|\.)googlevideo\.com$/i.test(h) || youtubeHost
|
|
|
117
117
|
// channel's /live URL redirects to /watch), each to a host `allow(host)` accepts; gives up
|
|
118
118
|
// after TIMEOUT_MS of silence or MAX_BODY bytes; an answer other than 200 is an error.
|
|
119
119
|
// Returns abort() (the hop under way, and no further one). Only through httpReq's gate.
|
|
120
|
+
// The body of an answer read straight off the connection: only when it's complete. Chunked:
|
|
121
|
+
// every chunk's size line, its bytes and their CRLF, up to the closing zero-size chunk; else its
|
|
122
|
+
// declared Content-Length, all of it. { body } or { error }.
|
|
123
|
+
function rawBody(hd, buf) {
|
|
124
|
+
if (/chunked/i.test(hd['transfer-encoding'] || '')) {
|
|
125
|
+
var out = [], pos = 0;
|
|
126
|
+
for (;;) {
|
|
127
|
+
var nl = buf.indexOf('\r\n', pos);
|
|
128
|
+
if (nl === -1) { return { error: 'incomplete answer' }; }
|
|
129
|
+
var line = buf.slice(pos, nl).toString('binary').split(';')[0].trim();
|
|
130
|
+
if (!/^[0-9a-f]{1,8}$/i.test(line)) { return { error: 'malformed answer' }; }
|
|
131
|
+
var n = parseInt(line, 16);
|
|
132
|
+
if (n === 0) { return { body: Buffer.concat(out) }; } // (the end: trailers, if any, not needed)
|
|
133
|
+
if (nl + 2 + n + 2 > buf.length || buf[nl + 2 + n] !== 13 || buf[nl + 3 + n] !== 10) { return { error: 'incomplete answer' }; }
|
|
134
|
+
out.push(buf.slice(nl + 2, nl + 2 + n));
|
|
135
|
+
pos = nl + 2 + n + 2;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (hd['content-length'] !== undefined) {
|
|
139
|
+
var len = +hd['content-length'];
|
|
140
|
+
if (!(len >= 0) || buf.length < len) { return { error: 'incomplete answer' }; }
|
|
141
|
+
return { body: buf.slice(0, len) };
|
|
142
|
+
}
|
|
143
|
+
return { body: buf }; // (no length: what came before the connection closed)
|
|
144
|
+
}
|
|
120
145
|
function rawReq(method, urlStr, headers, body, cb, allow, _n, _at) {
|
|
121
146
|
cb = once(cb);
|
|
122
147
|
var at = _at || {};
|
|
@@ -218,19 +243,10 @@ function bigHeaderGet(urlStr, headers, cb, allow, at, _n) {
|
|
|
218
243
|
bigHeaderGet(urllib.resolve(urlStr, hd.location), headers, cb, allow, at, (_n || 0) + 1);
|
|
219
244
|
return;
|
|
220
245
|
}
|
|
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']); }
|
|
246
|
+
var whole = rawBody(hd, bodyBuf);
|
|
233
247
|
if (sc !== 200) { end(new Error('HTTP ' + sc)); return; }
|
|
248
|
+
if (whole.error) { end(new Error(whole.error)); return; }
|
|
249
|
+
bodyBuf = whole.body;
|
|
234
250
|
if (bodyBuf.length > MAX_BODY) { end(new Error('response too large')); return; }
|
|
235
251
|
end(null, sc, bodyBuf.toString('utf8'));
|
|
236
252
|
});
|
|
@@ -1296,16 +1312,90 @@ function dlName(n) { // a file name: no folders, nothing a drive refuses, not
|
|
|
1296
1312
|
var s = String(n || '').replace(/[\/\\:*?"<>|\x00-\x1f]+/g, ' ').replace(/\s+/g, ' ').trim().replace(/^\.+/, '');
|
|
1297
1313
|
return s.slice(0, 200);
|
|
1298
1314
|
}
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1315
|
+
// The TV itself (loopback, unspecified, link-local), however an address is written: dotted,
|
|
1316
|
+
// decimal, hex or octal IPv4 ("2130706433", "0x7f.1", "0177.0.0.1"), IPv6 and IPv4-mapped IPv6
|
|
1317
|
+
// ("::ffff:127.0.0.1", "::ffff:7f00:1"), "localhost". Home-network addresses (a NAS) are fine.
|
|
1318
|
+
function ipv4Int(h) { // inet_aton's forms: 1 to 4 parts, each decimal, 0x hex or 0 octal; else -1
|
|
1319
|
+
var parts = h.split('.');
|
|
1320
|
+
if (!parts.length || parts.length > 4) { return -1; }
|
|
1321
|
+
var nums = [];
|
|
1322
|
+
for (var i = 0; i < parts.length; i++) {
|
|
1323
|
+
var t = parts[i], v;
|
|
1324
|
+
if (/^0x[0-9a-f]+$/i.test(t)) { v = parseInt(t.slice(2), 16); }
|
|
1325
|
+
else if (/^0[0-7]*$/.test(t)) { v = parseInt(t, 8); }
|
|
1326
|
+
else if (/^[1-9][0-9]*$/.test(t)) { v = parseInt(t, 10); }
|
|
1327
|
+
else { return -1; }
|
|
1328
|
+
if (!isFinite(v)) { return -1; }
|
|
1329
|
+
nums.push(v);
|
|
1330
|
+
}
|
|
1331
|
+
var last = nums.pop(), max = [4294967296, 16777216, 65536, 256][nums.length], n = 0;
|
|
1332
|
+
if (last >= max || nums.some(function (x) { return x > 255; })) { return -1; }
|
|
1333
|
+
nums.forEach(function (x, k) { n += x * Math.pow(256, 3 - k); });
|
|
1334
|
+
return n + last;
|
|
1335
|
+
}
|
|
1336
|
+
function ipv6Words(h) { // 8 numbers, or null
|
|
1337
|
+
var tail = /(\d+\.\d+\.\d+\.\d+)$/.exec(h), extra = null;
|
|
1338
|
+
if (tail) { // (a dotted IPv4 at the end: its two words)
|
|
1339
|
+
var v = ipv4Int(tail[1]);
|
|
1340
|
+
if (v < 0) { return null; }
|
|
1341
|
+
extra = [Math.floor(v / 65536), v % 65536];
|
|
1342
|
+
h = h.slice(0, -tail[1].length) + '0:0';
|
|
1343
|
+
}
|
|
1344
|
+
var halves = h.split('::');
|
|
1345
|
+
if (halves.length > 2) { return null; }
|
|
1346
|
+
var a = halves[0] ? halves[0].split(':') : [], b = halves.length === 2 && halves[1] ? halves[1].split(':') : [], all;
|
|
1347
|
+
if (halves.length === 1) { all = a; }
|
|
1348
|
+
else {
|
|
1349
|
+
var fill = 8 - a.length - b.length, zeros = [];
|
|
1350
|
+
if (fill < 1) { return null; }
|
|
1351
|
+
while (zeros.length < fill) { zeros.push('0'); }
|
|
1352
|
+
all = a.concat(zeros, b);
|
|
1353
|
+
}
|
|
1354
|
+
if (all.length !== 8 || !all.every(function (w) { return /^[0-9a-f]{1,4}$/i.test(w); })) { return null; }
|
|
1355
|
+
all = all.map(function (w) { return parseInt(w, 16); });
|
|
1356
|
+
if (extra) { all[6] = extra[0]; all[7] = extra[1]; }
|
|
1357
|
+
return all;
|
|
1358
|
+
}
|
|
1359
|
+
function isSelfAddr(host) {
|
|
1360
|
+
var h = String(host || '').toLowerCase().replace(/^\[|\]$/g, '').replace(/%.*$/, '').replace(/\.$/, '');
|
|
1361
|
+
if (!h || h === 'localhost' || /\.localhost$/.test(h)) { return true; }
|
|
1362
|
+
function v4self(n) { var a = Math.floor(n / 16777216); return a === 127 || a === 0 || Math.floor(n / 65536) === 0xA9FE; }
|
|
1363
|
+
if (h.indexOf(':') === -1) { var n = ipv4Int(h); return n >= 0 && v4self(n); }
|
|
1364
|
+
var w = ipv6Words(h);
|
|
1365
|
+
if (!w) { return false; }
|
|
1366
|
+
var zero5 = w[0] === 0 && w[1] === 0 && w[2] === 0 && w[3] === 0 && w[4] === 0;
|
|
1367
|
+
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)
|
|
1368
|
+
if (zero5 && w[5] === 0 && w[6] === 0 && w[7] <= 1) { return true; } // (::1, ::)
|
|
1369
|
+
return (w[0] & 0xffc0) === 0xfe80; // (link-local)
|
|
1370
|
+
}
|
|
1371
|
+
function dlHostOk(h) { return !!String(h || '') && !isSelfAddr(h); }
|
|
1372
|
+
// Checked again where a name actually leads: the address it resolves to, before connecting, and
|
|
1373
|
+
// the one connected to (a name that resolves to the TV itself, now or after a change, is refused).
|
|
1374
|
+
function guardSelf(req, fail) {
|
|
1375
|
+
req.on('socket', function (sock) {
|
|
1376
|
+
sock.on('lookup', function (err, addr) { if (!err && isSelfAddr(addr)) { fail(); } });
|
|
1377
|
+
sock.on('connect', function () { if (isSelfAddr(sock.remoteAddress)) { fail(); } });
|
|
1378
|
+
});
|
|
1302
1379
|
}
|
|
1303
|
-
|
|
1380
|
+
// A drive's free space: df run directly (execFile: the path an argument, never a shell), without
|
|
1381
|
+
// holding up the service. cb({ free, total } in bytes, or null).
|
|
1382
|
+
function dfOf(dir, cb) {
|
|
1304
1383
|
try {
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1384
|
+
require('child_process').execFile('df', ['-k', dir], { timeout: 5000 }, function (err, out) {
|
|
1385
|
+
if (err) { cb(null); return; }
|
|
1386
|
+
var line = String(out || '').trim().split('\n').slice(-1)[0].split(/\s+/);
|
|
1387
|
+
var total = +line[1] * 1024, free = +line[3] * 1024;
|
|
1388
|
+
cb(isFinite(total) && isFinite(free) ? { total: total, free: free } : null);
|
|
1389
|
+
});
|
|
1390
|
+
} catch (e) { cb(null); }
|
|
1391
|
+
}
|
|
1392
|
+
// A USB drive that's actually mounted (in /proc/mounts, its mount point's escapes undone).
|
|
1393
|
+
function isMounted(mount) {
|
|
1394
|
+
try {
|
|
1395
|
+
return String(require('fs').readFileSync('/proc/mounts', 'utf8')).split('\n').some(function (l) {
|
|
1396
|
+
return (l.split(' ')[1] || '').replace(/\\([0-7]{3})/g, function (a, o) { return String.fromCharCode(parseInt(o, 8)); }) === mount;
|
|
1397
|
+
});
|
|
1398
|
+
} catch (e) { return false; }
|
|
1309
1399
|
}
|
|
1310
1400
|
function fsTypeOf(mount) {
|
|
1311
1401
|
try {
|
|
@@ -1313,6 +1403,30 @@ function fsTypeOf(mount) {
|
|
|
1313
1403
|
return hit ? hit.split(' ')[2] : '';
|
|
1314
1404
|
} catch (e) { return ''; }
|
|
1315
1405
|
}
|
|
1406
|
+
// /fsfile: a file on a USB drive, with ranges: only where it really is (fsRealOk: a link that
|
|
1407
|
+
// leads off the drive is refused), opened once, and served only if that's a regular file.
|
|
1408
|
+
function fsFileServe(req, res, path) {
|
|
1409
|
+
var fs = require('fs');
|
|
1410
|
+
fsRealOk(path, function (bad, real) {
|
|
1411
|
+
if (bad) { send(res, bad.status === 403 ? 403 : 404, 'text/plain', bad.status === 403 ? 'not on the USB drive' : 'no such file'); return; }
|
|
1412
|
+
fs.open(real, 'r', function (oe, fd) {
|
|
1413
|
+
if (oe) { send(res, 404, 'text/plain', 'no such file'); return; }
|
|
1414
|
+
fs.fstat(fd, function (se, st) {
|
|
1415
|
+
if (se || !st.isFile()) { fs.close(fd, function () {}); send(res, 404, 'text/plain', 'no such file'); return; }
|
|
1416
|
+
var size = st.size, m = /bytes=(\d*)-(\d*)/.exec(req.headers.range || ''), a0 = 0, b0 = size - 1, part = false;
|
|
1417
|
+
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])); } }
|
|
1418
|
+
if (a0 > b0 || a0 >= size) { fs.close(fd, function () {}); res.writeHead(416, { 'Content-Range': 'bytes */' + size }); res.end(); return; }
|
|
1419
|
+
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' };
|
|
1420
|
+
if (part) { h['Content-Range'] = 'bytes ' + a0 + '-' + b0 + '/' + size; }
|
|
1421
|
+
res.writeHead(part ? 206 : 200, h);
|
|
1422
|
+
var rs = fs.createReadStream(real, { fd: fd, start: a0, end: b0 });
|
|
1423
|
+
rs.on('error', function () { try { res.end(); } catch (e) {} });
|
|
1424
|
+
res.on('close', function () { try { rs.destroy(); } catch (e) {} });
|
|
1425
|
+
rs.pipe(res);
|
|
1426
|
+
});
|
|
1427
|
+
});
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1316
1430
|
function dlView(e) {
|
|
1317
1431
|
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
1432
|
}
|
|
@@ -1322,15 +1436,14 @@ function dlControl(path, query, res) {
|
|
|
1322
1436
|
if (path === '/fsdl/list') { send(res, 200, 'application/json', JSON.stringify({ items: _dls.map(dlView) })); return; }
|
|
1323
1437
|
if (path === '/fsdl/space') {
|
|
1324
1438
|
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) }));
|
|
1439
|
+
if (!m || !fsDirOk(m) || !isMounted(m)) { send(res, 400, 'text/plain', 'not a USB drive'); return; }
|
|
1440
|
+
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
1441
|
return;
|
|
1329
1442
|
}
|
|
1330
1443
|
if (path === '/fsdl/add') {
|
|
1331
1444
|
var dir = q.dir || '', name = dlName(q.name), u = q.url || '', pu = null;
|
|
1332
1445
|
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; }
|
|
1446
|
+
if (!fsDirOk(dir) || dir.split('/').length > 6 || !isMounted(mountOf(dir))) { send(res, 400, 'text/plain', 'not a folder on a USB drive'); return; }
|
|
1334
1447
|
if (!name) { send(res, 400, 'text/plain', 'no file name'); return; }
|
|
1335
1448
|
if (!pu || (pu.protocol !== 'https:' && pu.protocol !== 'http:') || !dlHostOk(pu.hostname)) { send(res, 400, 'text/plain', 'not a link it can download'); return; }
|
|
1336
1449
|
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 +1453,14 @@ function dlControl(path, query, res) {
|
|
|
1340
1453
|
headers: { 'User-Agent': String(q.ua || 'Mozilla/5.0').slice(0, 300) } };
|
|
1341
1454
|
if (/^https?:\/\//.test(q.ref || '')) { e.headers.Referer = String(q.ref).slice(0, 2000); }
|
|
1342
1455
|
_dls = _dls.filter(function (x) { return !(x.dir === dir && x.name === name); }); // (a done / failed one of the same file: replaced)
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1456
|
+
// Asked again by the app (this service restarted meanwhile): one that had already finished
|
|
1457
|
+
// (its file there, a regular file at its size, no partial file left) is done, not downloaded twice.
|
|
1458
|
+
dlFinishedBefore(q.again === '1' ? e : null, function (done) {
|
|
1459
|
+
if (done) { e.state = 'done'; e.got = e.size = done.size; }
|
|
1460
|
+
_dls.push(e);
|
|
1461
|
+
if (!done) { dlPump(); }
|
|
1462
|
+
send(res, 200, 'application/json', JSON.stringify(dlView(e)));
|
|
1463
|
+
});
|
|
1346
1464
|
return;
|
|
1347
1465
|
}
|
|
1348
1466
|
var x = _dls.filter(function (y) { return y.id === q.id; })[0];
|
|
@@ -1377,6 +1495,48 @@ function dlEnd(e, state, err) {
|
|
|
1377
1495
|
if (_dlActive === e) { _dlActive = null; }
|
|
1378
1496
|
setTimeout(dlPump, 0);
|
|
1379
1497
|
}
|
|
1498
|
+
// cb({ size }) when e's file is already on the drive, finished: a regular file, at its size (when
|
|
1499
|
+
// known), with no partial file beside it; else cb(null).
|
|
1500
|
+
function dlFinishedBefore(e, cb) {
|
|
1501
|
+
if (!e) { cb(null); return; }
|
|
1502
|
+
var fs = require('fs'), file = e.dir + '/' + e.name;
|
|
1503
|
+
fs.lstat(file + '.part', function (pe) {
|
|
1504
|
+
if (!pe) { cb(null); return; } // (a partial file: picked up where it stopped instead)
|
|
1505
|
+
fs.lstat(file, function (fe, st) {
|
|
1506
|
+
if (fe || !st.isFile() || (e.size && st.size !== e.size)) { cb(null); return; }
|
|
1507
|
+
cb({ size: st.size });
|
|
1508
|
+
});
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1511
|
+
// A resumed answer must go on exactly where the partial file ends: "bytes <have>-…/…".
|
|
1512
|
+
function resumeOk(have, contentRange) {
|
|
1513
|
+
var m = /^bytes (\d+)-(\d+)\/(\d+|\*)$/.exec(String(contentRange || '').trim());
|
|
1514
|
+
return !!m && +m[1] === have && +m[2] >= have;
|
|
1515
|
+
}
|
|
1516
|
+
// A streamed playlist (HLS, DASH) isn't a file to download: by its type, or its first bytes.
|
|
1517
|
+
function isPlaylistAnswer(type, first) {
|
|
1518
|
+
if (/mpegurl|dash\+xml/i.test(String(type || ''))) { return true; }
|
|
1519
|
+
var t = String(first || '').replace(/^\uFEFF/, '').replace(/^\s+/, '');
|
|
1520
|
+
return t.indexOf('#EXTM3U') === 0 || (/^<\?xml/.test(t) && /<MPD[\s>]/.test(t.slice(0, 512))) || t.indexOf('<MPD') === 0;
|
|
1521
|
+
}
|
|
1522
|
+
// The partial file opened without following a link (O_NOFOLLOW where the TV's Node has it),
|
|
1523
|
+
// and only if it's a regular file. cb(err, fd).
|
|
1524
|
+
function openPart(fs, part, append, cb) {
|
|
1525
|
+
var C = {};
|
|
1526
|
+
try { C = require('constants'); } catch (e) {}
|
|
1527
|
+
var flags = append ? 'a' : 'w';
|
|
1528
|
+
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); }
|
|
1529
|
+
fs.lstat(part, function (le, lst) {
|
|
1530
|
+
if (!le && !lst.isFile()) { cb(new Error('the partial file isn\'t a regular file')); return; }
|
|
1531
|
+
fs.open(part, flags, 420, function (oe, fd) {
|
|
1532
|
+
if (oe) { cb(oe); return; }
|
|
1533
|
+
fs.fstat(fd, function (fe, st) {
|
|
1534
|
+
if (fe || !st.isFile()) { fs.close(fd, function () {}); cb(fe || new Error('the partial file isn\'t a regular file')); return; }
|
|
1535
|
+
cb(null, fd);
|
|
1536
|
+
});
|
|
1537
|
+
});
|
|
1538
|
+
});
|
|
1539
|
+
}
|
|
1380
1540
|
function dlRun(e) {
|
|
1381
1541
|
var fs = require('fs'), part = e.dir + '/' + e.name + '.part';
|
|
1382
1542
|
e.state = 'downloading'; e.err = '';
|
|
@@ -1401,13 +1561,14 @@ function dlRun(e) {
|
|
|
1401
1561
|
if ((pu.protocol !== 'https:' && pu.protocol !== 'http:') || !dlHostOk(pu.hostname)) { dlEnd(e, 'failed', 'a link it can\'t follow'); return; }
|
|
1402
1562
|
var h = {};
|
|
1403
1563
|
Object.keys(e.headers).forEach(function (k) { h[k] = e.headers[k]; });
|
|
1404
|
-
if (have > 0) { h.Range = 'bytes=' + have + '-'; }
|
|
1564
|
+
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
1565
|
var opts = { hostname: pu.hostname, port: pu.port, path: pu.path, headers: h };
|
|
1406
1566
|
if (pu.protocol === 'https:' && caList().length) { opts.ca = caList(); }
|
|
1407
1567
|
var r, ws = null, closed = false;
|
|
1408
1568
|
function stop() { closed = true; try { r.abort(); } catch (er) {} if (ws) { try { ws.end(); } catch (er2) {} } }
|
|
1409
1569
|
e.stop = stop;
|
|
1410
1570
|
try { r = mod.get(opts, onResp); } catch (er) { again('couldn\'t connect: ' + (er.code || er.message)); return; }
|
|
1571
|
+
guardSelf(r, function () { if (!closed) { stop(); dlEnd(e, 'failed', 'a link to the TV itself'); } });
|
|
1411
1572
|
r.on('error', function (er) { if (!closed) { closed = true; if (ws) { try { ws.end(); } catch (x) {} } again('connection: ' + (er.code || er.message)); } });
|
|
1412
1573
|
r.setTimeout(DL_SILENCE_MS, function () { if (!closed) { stop(); again('no data for ' + (DL_SILENCE_MS / 1000) + ' s'); } });
|
|
1413
1574
|
function onResp(resp) {
|
|
@@ -1416,17 +1577,39 @@ function dlRun(e) {
|
|
|
1416
1577
|
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
1578
|
if (sc >= 500) { resp.resume(); closed = true; again('the server: HTTP ' + sc); return; }
|
|
1418
1579
|
if (sc !== 200 && sc !== 206) { resp.resume(); closed = true; dlEnd(e, 'failed', 'HTTP ' + sc); return; }
|
|
1580
|
+
if (sc === 206 && have > 0 && !resumeOk(have, resp.headers['content-range'])) { // (not where the partial file ends: started over)
|
|
1581
|
+
resp.resume(); closed = true;
|
|
1582
|
+
fs.unlink(part, function () { if (e.state === 'downloading') { get(e.url, 0, 0); } });
|
|
1583
|
+
return;
|
|
1584
|
+
}
|
|
1419
1585
|
var resumed = sc === 206 && have > 0;
|
|
1420
1586
|
if (!resumed) { have = 0; }
|
|
1587
|
+
if (!resumed) { var et = resp.headers.etag; e.validator = et && !/^W\//.test(et) ? et : (resp.headers['last-modified'] || ''); } // (asked again on a resume: If-Range)
|
|
1588
|
+
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
1589
|
var total = 0, cr = /\/(\d+)\s*$/.exec(resp.headers['content-range'] || '');
|
|
1422
1590
|
if (cr) { total = +cr[1]; } else if (resp.headers['content-length']) { total = have + (+resp.headers['content-length']); }
|
|
1423
1591
|
if (total) { e.size = total; }
|
|
1424
|
-
var need = (e.size || 0) - have, mount = mountOf(e.dir),
|
|
1592
|
+
var need = (e.size || 0) - have, mount = mountOf(e.dir), fsType = fsTypeOf(mount);
|
|
1425
1593
|
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
|
-
|
|
1594
|
+
dfOf(e.dir, function (df) { // (the answer waits, unread, meanwhile)
|
|
1595
|
+
if (closed || e.state !== 'downloading') { resp.resume(); return; }
|
|
1596
|
+
if (df && need > 0 && df.free < need + DL_SPARE) { resp.resume(); closed = true; dlEnd(e, 'failed', 'not enough space on the drive'); return; }
|
|
1597
|
+
openPart(fs, part, resumed, function (oe, fd) {
|
|
1598
|
+
if (closed || e.state !== 'downloading') { if (fd) { fs.close(fd, function () {}); } resp.resume(); return; }
|
|
1599
|
+
if (oe) { resp.resume(); closed = true; dlEnd(e, 'failed', 'can\'t write to the drive: ' + (oe.code || oe.message)); return; }
|
|
1600
|
+
write(fd);
|
|
1601
|
+
});
|
|
1602
|
+
});
|
|
1603
|
+
function write(fd) {
|
|
1604
|
+
try { ws = fs.createWriteStream(part, { fd: fd }); }
|
|
1428
1605
|
catch (er) { resp.resume(); closed = true; dlEnd(e, 'failed', 'can\'t write to the drive: ' + (er.code || er.message)); return; }
|
|
1429
1606
|
e.got = have;
|
|
1607
|
+
var firstSeen = resumed;
|
|
1608
|
+
resp.on('data', function (c) { // (a playlist's first bytes, where its type didn't say: refused)
|
|
1609
|
+
if (firstSeen) { return; }
|
|
1610
|
+
firstSeen = true;
|
|
1611
|
+
if (isPlaylistAnswer('', c.slice(0, 600).toString('utf8'))) { stop(); fs.unlink(part, function () {}); dlEnd(e, 'failed', 'a streamed playlist (HLS or DASH), not a file: it can\'t be downloaded'); }
|
|
1612
|
+
});
|
|
1430
1613
|
var tick = { at: Date.now(), got: have };
|
|
1431
1614
|
ws.on('error', function (er) { if (!closed) { stop(); dlEnd(e, 'failed', 'can\'t write to the drive: ' + (er.code || er.message)); } });
|
|
1432
1615
|
resp.on('data', function (c) {
|
|
@@ -1454,8 +1637,9 @@ function dlRun(e) {
|
|
|
1454
1637
|
if (closed || e.state !== 'downloading') { return; }
|
|
1455
1638
|
closed = true;
|
|
1456
1639
|
if (e.size && e.got < e.size) { again('the connection dropped'); return; } // (picked up where it stopped)
|
|
1640
|
+
if (e.size && e.got > e.size) { fs.unlink(part, function () {}); dlEnd(e, 'failed', 'more came than the file\'s size: started again it may work'); return; }
|
|
1457
1641
|
var final = e.dir + '/' + e.name;
|
|
1458
|
-
fs.
|
|
1642
|
+
fs.lstat(final, function (fe) { // (anything there, a link too: not replaced)
|
|
1459
1643
|
if (!fe) { final = e.dir + '/' + e.name.replace(/(\.[^.]+)?$/, ' (' + Date.now().toString(36) + ')$1'); } // (one already there: not overwritten)
|
|
1460
1644
|
fs.rename(part, final, function (re) {
|
|
1461
1645
|
if (re) { dlEnd(e, 'failed', 'couldn\'t finish the file: ' + (re.code || re.message)); return; }
|
|
@@ -1464,6 +1648,7 @@ function dlRun(e) {
|
|
|
1464
1648
|
});
|
|
1465
1649
|
});
|
|
1466
1650
|
});
|
|
1651
|
+
}
|
|
1467
1652
|
}
|
|
1468
1653
|
}
|
|
1469
1654
|
}
|
|
@@ -1519,20 +1704,7 @@ function handle(req, res) {
|
|
|
1519
1704
|
var fq = {};
|
|
1520
1705
|
(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
1706
|
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
|
-
});
|
|
1707
|
+
fsFileServe(req, res, fq.path);
|
|
1536
1708
|
return;
|
|
1537
1709
|
}
|
|
1538
1710
|
if (/^\/fsdl\//.test(path)) { // (downloads to a USB drive: see dlControl)
|
|
@@ -1657,6 +1829,7 @@ function handle(req, res) {
|
|
|
1657
1829
|
// channel's User-Agent and Referer. A segment comes back with its sound repacked (liveFix:
|
|
1658
1830
|
// whole ADTS frames per packet, each packet with its first frame's own time), everything else
|
|
1659
1831
|
// as it was. &force=1: the relay without the look (StremioK already knows the channel).
|
|
1832
|
+
var HLS_MASTER_MAX = 524288; // (an HLS quality list: /live/master)
|
|
1660
1833
|
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
1834
|
var ADTS_RATES = [96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350];
|
|
1662
1835
|
var _live = {};
|
|
@@ -1699,6 +1872,7 @@ function liveGet(u, who, max, cb, part) {
|
|
|
1699
1872
|
resp.on('error', function (e) { end(e); });
|
|
1700
1873
|
});
|
|
1701
1874
|
r.on('error', function (e) { end(e); });
|
|
1875
|
+
guardSelf(r, function () { end(upError('a link to the TV itself', 400)); });
|
|
1702
1876
|
} catch (e) { end(e); }
|
|
1703
1877
|
}
|
|
1704
1878
|
get(u);
|
|
@@ -1932,6 +2106,18 @@ function liveControl(req, res, path, query, leaving) {
|
|
|
1932
2106
|
livePurge();
|
|
1933
2107
|
var q = {};
|
|
1934
2108
|
(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) {} } });
|
|
2109
|
+
if (path === '/live/master') { // an HLS stream's quality list, asked with its own User-Agent / Referer (which a web page can't send)
|
|
2110
|
+
if (!entryAllowed(req, query)) { send(res, 403, 'text/plain', 'not allowed (token)'); return; }
|
|
2111
|
+
var mu = q.u || '';
|
|
2112
|
+
if (!/^https?:\/\/[^\s]+$/i.test(mu) || mu.length > 2000) { send(res, 400, 'text/plain', 'bad link'); return; }
|
|
2113
|
+
leaving.push(liveGet(mu, { ua: liveHeader(q.ua), ref: liveHeader(q.ref) }, HLS_MASTER_MAX, function (err, buf) {
|
|
2114
|
+
if (err) { send(res, err.status || 502, 'text/plain', 'failed: ' + (err.message || 'unknown')); return; }
|
|
2115
|
+
var text = buf.toString('utf8').replace(/^\uFEFF/, '');
|
|
2116
|
+
if (text.replace(/^\s+/, '').indexOf('#EXTM3U') !== 0) { send(res, 502, 'text/plain', 'not an HLS playlist'); return; }
|
|
2117
|
+
send(res, 200, 'application/vnd.apple.mpegurl', text);
|
|
2118
|
+
}));
|
|
2119
|
+
return;
|
|
2120
|
+
}
|
|
1935
2121
|
if (path === '/live/open') {
|
|
1936
2122
|
if (!entryAllowed(req, query)) { send(res, 403, 'text/plain', 'not allowed (token)'); return; }
|
|
1937
2123
|
var u = q.u || '', who = { ua: liveHeader(q.ua), ref: liveHeader(q.ref) };
|
|
@@ -1967,35 +2153,55 @@ function liveControl(req, res, path, query, leaving) {
|
|
|
1967
2153
|
// AIOManager's Connections, with when it last read). Here rather than in the app: an instance
|
|
1968
2154
|
// only answers the web origins it's set to (CORS_ORIGINS). Nothing is written to AIOManager.
|
|
1969
2155
|
var AIOM_MS = 20000, AIOM_MAX = 2 * 1048576;
|
|
1970
|
-
|
|
2156
|
+
// A redirect it follows with the key: the same server only (its name and port; plain HTTP may
|
|
2157
|
+
// move to HTTPS on the default ports, never the other way). The key goes nowhere else.
|
|
2158
|
+
function sameServerRedirect(from, to) {
|
|
2159
|
+
if (!to || (to.protocol !== 'http:' && to.protocol !== 'https:') || String(to.hostname || '').toLowerCase() !== String(from.hostname || '').toLowerCase()) { return false; }
|
|
2160
|
+
function port(u) { return +(u.port || (u.protocol === 'https:' ? 443 : 80)); }
|
|
2161
|
+
if (from.protocol === to.protocol) { return port(from) === port(to); }
|
|
2162
|
+
return from.protocol === 'http:' && to.protocol === 'https:' && !from.port && !to.port;
|
|
2163
|
+
}
|
|
2164
|
+
// One request (with its redirects) to an AIOManager instance: one deadline for the whole chain,
|
|
2165
|
+
// and the abort it returns stops whichever request is under way.
|
|
2166
|
+
function hydraReq(method, url, key, body, cb) {
|
|
1971
2167
|
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
|
-
|
|
2168
|
+
var ctl = { r: null, done: false }, first = null;
|
|
2169
|
+
try { first = urllib.parse(url); } catch (e) { first = null; }
|
|
2170
|
+
var timer = setTimeout(function () { finish(upError('AIOManager didn\'t answer', 504)); }, AIOM_MS);
|
|
2171
|
+
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); }
|
|
2172
|
+
function go(u, hops) {
|
|
2173
|
+
var pu;
|
|
2174
|
+
try { pu = urllib.parse(u); } catch (e) { finish(upError('bad address', 400)); return; }
|
|
2175
|
+
if ((pu.protocol !== 'http:' && pu.protocol !== 'https:') || !dlHostOk(pu.hostname)) { finish(upError('an address it can\'t reach', 400)); return; }
|
|
2176
|
+
var h = { 'X-API-Key': key, 'Accept': 'application/json', 'User-Agent': 'StremioK' }, data = body ? toBuffer(JSON.stringify(body)) : null;
|
|
2177
|
+
if (data) { h['Content-Type'] = 'application/json'; h['Content-Length'] = data.length; }
|
|
2178
|
+
var opts = { hostname: pu.hostname, port: pu.port, path: pu.path, method: method, headers: h };
|
|
2179
|
+
if (pu.protocol === 'https:' && caList().length) { opts.ca = caList(); }
|
|
2180
|
+
var chunks = [], size = 0;
|
|
2181
|
+
try {
|
|
2182
|
+
var r = ctl.r = (pu.protocol === 'https:' ? https : http).request(opts, function (resp) {
|
|
2183
|
+
if (ctl.done || ctl.r !== r) { resp.resume(); return; }
|
|
2184
|
+
var sc = resp.statusCode;
|
|
2185
|
+
if (sc >= 300 && sc < 400 && resp.headers.location && method === 'GET' && hops < 4) {
|
|
2186
|
+
resp.resume();
|
|
2187
|
+
var next = urllib.resolve(u, resp.headers.location), np = null;
|
|
2188
|
+
try { np = urllib.parse(next); } catch (e) { np = null; }
|
|
2189
|
+
if (!sameServerRedirect(first, np)) { finish(upError('AIOManager redirected to another server: not followed (your API key stays with it)', 502)); return; }
|
|
2190
|
+
go(next, hops + 1);
|
|
2191
|
+
return;
|
|
2192
|
+
}
|
|
2193
|
+
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)); } });
|
|
2194
|
+
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')); });
|
|
2195
|
+
resp.on('error', function (e) { finish(e); });
|
|
2196
|
+
});
|
|
2197
|
+
r.on('error', function (e) { if (ctl.r === r) { finish(upError('couldn\'t reach it (' + (e.code || e.message) + ')', 502)); } });
|
|
2198
|
+
guardSelf(r, function () { finish(upError('an address it can\'t reach', 400)); });
|
|
2199
|
+
if (data) { r.write(data); }
|
|
2200
|
+
r.end();
|
|
2201
|
+
} catch (e) { finish(e); }
|
|
2202
|
+
}
|
|
2203
|
+
go(url, 0);
|
|
2204
|
+
return function () { if (!ctl.done) { ctl.done = true; clearTimeout(timer); try { if (ctl.r) { ctl.r.abort(); } } catch (e) {} } };
|
|
1999
2205
|
}
|
|
2000
2206
|
function aiomControl(req, res, query, leaving) {
|
|
2001
2207
|
if (!entryAllowed(req, query)) { send(res, 403, 'text/plain', 'not allowed (token)'); return; }
|
package/service/preview.js
CHANGED
|
@@ -24,7 +24,7 @@ module.exports.onExit = function () {};
|
|
|
24
24
|
function run() {
|
|
25
25
|
if (_ran) { return; }
|
|
26
26
|
_ran = true;
|
|
27
|
-
var json = null, fs = null;
|
|
27
|
+
var json = null, fs = null, fromApp = false;
|
|
28
28
|
try { fs = require('fs'); } catch (e) {}
|
|
29
29
|
try {
|
|
30
30
|
var req = tizen.application.getCurrentApplication().getRequestedAppControl();
|
|
@@ -33,12 +33,22 @@ function run() {
|
|
|
33
33
|
if (d.key === 'caller') { caller = d.value && d.value[0]; }
|
|
34
34
|
if (d.key === 'tiles') { tiles = d.value && d.value[0]; }
|
|
35
35
|
});
|
|
36
|
-
if (caller === 'ForegroundApp' && typeof tiles === 'string' && tiles.length <= TILES_MAX) { json = tiles; }
|
|
36
|
+
if (caller === 'ForegroundApp' && typeof tiles === 'string' && tiles.length <= TILES_MAX) { json = tiles; fromApp = true; }
|
|
37
37
|
} catch (e1) {}
|
|
38
38
|
if (json) { try { fs.writeFileSync(dataDir() + '/' + TILES_FILE, json); } catch (e2) {} }
|
|
39
39
|
else { try { json = String(fs.readFileSync(dataDir() + '/' + TILES_FILE, 'utf8')); } catch (e3) { json = null; } } // (the TV asking: the last ones sent)
|
|
40
40
|
var ok = false;
|
|
41
41
|
try { var o = JSON.parse(json); ok = !!o && Array.isArray(o.sections) && json.length <= TILES_MAX; } catch (e4) {}
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
// StremioK (not the TV) asked: told whether the TV took them (it counts them as sent only then)
|
|
43
|
+
function answer(took) {
|
|
44
|
+
if (fromApp) {
|
|
45
|
+
try {
|
|
46
|
+
var rq = tizen.application.getCurrentApplication().getRequestedAppControl();
|
|
47
|
+
if (took) { rq.replyResult([new tizen.ApplicationControlData('result', ['ok'])]); } else { rq.replyFailure(); }
|
|
48
|
+
} catch (e6) {}
|
|
49
|
+
}
|
|
50
|
+
done();
|
|
51
|
+
}
|
|
52
|
+
if (!ok || typeof webapis === 'undefined' || !webapis || !webapis.preview || typeof webapis.preview.setPreviewData !== 'function') { answer(false); return; }
|
|
53
|
+
try { webapis.preview.setPreviewData(json, function () { answer(true); }, function () { answer(false); }); } catch (e5) { answer(false); }
|
|
44
54
|
}
|