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/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
- if (/chunked/i.test(hd['transfer-encoding'] || '')) {
222
- var out = [], pos = 0;
223
- while (pos < bodyBuf.length) {
224
- var nl = bodyBuf.indexOf('\r\n', pos);
225
- if (nl === -1) { break; }
226
- var n = parseInt(bodyBuf.slice(pos, nl).toString('binary'), 16);
227
- if (!(n > 0)) { break; }
228
- out.push(bodyBuf.slice(nl + 2, nl + 2 + n));
229
- pos = nl + 2 + n + 2;
230
- }
231
- bodyBuf = Buffer.concat(out);
232
- } else if (hd['content-length']) { bodyBuf = bodyBuf.slice(0, +hd['content-length']); }
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
- function dlHostOk(h) {
1300
- h = String(h || '').toLowerCase().replace(/^\[|\]$/g, '');
1301
- return !!h && h !== 'localhost' && !/\.localhost$/.test(h) && !/^127\./.test(h) && !/^0\./.test(h) && h !== '::1' && h !== '0:0:0:0:0:0:0:1' && !/^169\.254\./.test(h);
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
- function dfOf(dir) { // { free, total } in bytes, or null
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
- var line = String(require('child_process').execSync('df -k "' + dir.replace(/"/g, '') + '"', { timeout: 5000 })).trim().split('\n').slice(-1)[0].split(/\s+/);
1306
- var total = +line[1] * 1024, free = +line[3] * 1024;
1307
- return isFinite(total) && isFinite(free) ? { total: total, free: free } : null;
1308
- } catch (e) { return null; }
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
- var df = dfOf(m);
1327
- send(res, 200, 'application/json', JSON.stringify({ free: df ? df.free : null, total: df ? df.total : null, fs: fsTypeOf(m) }));
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
- _dls.push(e);
1344
- dlPump();
1345
- send(res, 200, 'application/json', JSON.stringify(dlView(e)));
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), df = dfOf(e.dir), fsType = fsTypeOf(mount);
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
- if (df && need > 0 && df.free < need + DL_SPARE) { resp.resume(); closed = true; dlEnd(e, 'failed', 'not enough space on the drive'); return; }
1427
- try { ws = fs.createWriteStream(part, { flags: resumed ? 'a' : 'w' }); }
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.stat(final, function (fe) {
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
- var ffs = require('fs');
1523
- ffs.stat(fq.path, function (se, st) {
1524
- if (se || !st.isFile()) { send(res, 404, 'text/plain', 'no such file'); return; }
1525
- var size = st.size, m = /bytes=(\d*)-(\d*)/.exec(req.headers.range || ''), a0 = 0, b0 = size - 1, part = false;
1526
- if (m) { part = true; if (m[1] !== '') { a0 = +m[1]; if (m[2] !== '') { b0 = Math.min(+m[2], size - 1); } } else if (m[2] !== '') { a0 = Math.max(0, size - (+m[2])); } }
1527
- if (a0 > b0 || a0 >= size) { res.writeHead(416, { 'Content-Range': 'bytes */' + size }); res.end(); return; }
1528
- var h = { 'Content-Type': /\.mp4$|\.m4v$/i.test(fq.path) ? 'video/mp4' : 'video/x-matroska', 'Accept-Ranges': 'bytes', 'Content-Length': b0 - a0 + 1, 'Cache-Control': 'no-store' };
1529
- if (part) { h['Content-Range'] = 'bytes ' + a0 + '-' + b0 + '/' + size; }
1530
- res.writeHead(part ? 206 : 200, h);
1531
- var rs = ffs.createReadStream(fq.path, { start: a0, end: b0 });
1532
- rs.on('error', function () { try { res.end(); } catch (e) {} });
1533
- res.on('close', function () { try { rs.destroy(); } catch (e) {} });
1534
- rs.pipe(res);
1535
- });
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
- function hydraReq(method, url, key, body, cb, _hops) {
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 pu;
1973
- try { pu = urllib.parse(url); } catch (e) { cb(upError('bad address', 400)); return noop; }
1974
- if ((pu.protocol !== 'http:' && pu.protocol !== 'https:') || !dlHostOk(pu.hostname)) { cb(upError('an address it can\'t reach', 400)); return noop; }
1975
- var h = { 'X-API-Key': key, 'Accept': 'application/json', 'User-Agent': 'StremioK' }, data = body ? toBuffer(JSON.stringify(body)) : null;
1976
- if (data) { h['Content-Type'] = 'application/json'; h['Content-Length'] = data.length; }
1977
- var opts = { hostname: pu.hostname, port: pu.port, path: pu.path, method: method, headers: h };
1978
- if (pu.protocol === 'https:' && caList().length) { opts.ca = caList(); }
1979
- var r, done = false, chunks = [], size = 0;
1980
- var timer = setTimeout(function () { end(upError('AIOManager didn\'t answer', 504)); }, AIOM_MS);
1981
- function end(err, sc) { if (done) { return; } done = true; clearTimeout(timer); try { r.abort(); } catch (e) {} cb(err, sc, err ? null : Buffer.concat(chunks, size).toString('utf8')); }
1982
- try {
1983
- r = (pu.protocol === 'https:' ? https : http).request(opts, function (resp) {
1984
- var sc = resp.statusCode;
1985
- if (sc >= 300 && sc < 400 && resp.headers.location && method === 'GET' && (_hops || 0) < 4) {
1986
- done = true; clearTimeout(timer); resp.resume();
1987
- hydraReq(method, urllib.resolve(url, resp.headers.location), key, body, cb, (_hops || 0) + 1);
1988
- return;
1989
- }
1990
- resp.on('data', function (c) { if (done) { return; } chunks.push(c); size += c.length; if (size > AIOM_MAX) { end(upError('too large an answer', 502)); } });
1991
- resp.on('end', function () { if (sc !== 200) { end(upError('HTTP ' + sc, sc === 401 || sc === 403 ? 401 : 502)); return; } end(null, sc); });
1992
- resp.on('error', function (e) { end(e); });
1993
- });
1994
- r.on('error', function (e) { end(upError('couldn\'t reach it (' + (e.code || e.message) + ')', 502)); });
1995
- if (data) { r.write(data); }
1996
- r.end();
1997
- } catch (e) { end(e); }
1998
- return function () { if (!done) { done = true; clearTimeout(timer); try { r.abort(); } catch (e2) {} } };
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; }
@@ -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
- if (!ok || typeof webapis === 'undefined' || !webapis || !webapis.preview || typeof webapis.preview.setPreviewData !== 'function') { done(); return; }
43
- try { webapis.preview.setPreviewData(json, done, done); } catch (e5) { done(); }
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
  }