torrent-online 1.5.3 → 1.5.5

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/wtui.js +76 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "torrent-online",
3
- "version": "1.5.3",
3
+ "version": "1.5.5",
4
4
  "type": "module",
5
5
  "description": "Stream .torrent / magnet to VLC or browser — torrent streaming for macOS/Linux without a torrent client. Стрим торрентов в VLC или браузер.",
6
6
  "keywords": [
package/wtui.js CHANGED
@@ -5,7 +5,9 @@ import { promises as fsp } from 'fs';
5
5
  import path from 'path';
6
6
  import os from 'os';
7
7
  import crypto from 'crypto';
8
- import { spawn, spawnSync } from 'child_process';
8
+ import { spawn, spawnSync, execFile } from 'child_process';
9
+ import { promisify } from 'util';
10
+ const execFileP = promisify(execFile);
9
11
  import { fileURLToPath } from 'url';
10
12
  import { select, checkbox, input, confirm, Separator } from '@inquirer/prompts';
11
13
  import WebTorrent from 'webtorrent';
@@ -159,6 +161,14 @@ async function listenOnFreePort(server, host, fixedPort) {
159
161
  }
160
162
 
161
163
  // ---- VLC ----
164
+ function detectFFBin(name) {
165
+ const cands = ['/opt/homebrew/bin/' + name, '/usr/local/bin/' + name, '/usr/bin/' + name];
166
+ for (const c of cands) if (fs.existsSync(c)) return c;
167
+ const r = spawnSync('which', [name], { encoding: 'utf8', timeout: 3000 });
168
+ const p = (r.stdout || '').trim();
169
+ return p && fs.existsSync(p) ? p : null;
170
+ }
171
+
162
172
  function detectVLCPath() {
163
173
  const macVLC = '/Applications/VLC.app/Contents/MacOS/VLC';
164
174
  if (fs.existsSync(macVLC)) return macVLC;
@@ -470,6 +480,7 @@ async function addTorrent(client, source, cacheDir) {
470
480
  META_TIMEOUT_MS
471
481
  );
472
482
  client.add(source, { path: cacheDir }, t => {
483
+ t.files.forEach(f => f.deselect()); // не качаем всё подряд до выбора пользователя
473
484
  if (t.ready) { clearTimeout(to); return resolve(t); }
474
485
  const spin = setInterval(() => {
475
486
  process.stdout.write(`\r⏳ Метаданные: пиры ${(t.peers?.length ?? 0)}, получено ${fmtBytes(t.received)} `);
@@ -715,13 +726,75 @@ async function webReq(req, res, client, st, opts, cacheDir, base) {
715
726
  if (!f) { res.statusCode = 404; res.end('not found'); return; }
716
727
  return serveFile(req, res, f);
717
728
  }
729
+ const mT = /^\/api\/tracks\/(\d+)\/(\d+)$/.exec(p);
730
+ if (mT) {
731
+ const e = st.torrents.get(Number(mT[1]));
732
+ const f = e?.t.files[Number(mT[2])];
733
+ if (!f) return json(404, { error: 'нет файла' });
734
+ const ffprobe = detectFFBin('ffprobe');
735
+ if (!ffprobe) return json(200, { ffmpeg: false, tracks: [] });
736
+ const url = 'http://127.0.0.1:' + st.port + base + '/s/' + mT[1] + '/' + mT[2];
737
+ try {
738
+ const { stdout } = await execFileP(ffprobe, ['-hide_banner', '-loglevel', 'error', '-print_format', 'json', '-show_streams', url], { maxBuffer: 8 * 1048576, timeout: 25000 });
739
+ const j = JSON.parse(stdout);
740
+ let ai = 0, si = 0;
741
+ const tracks = [], subs = [];
742
+ for (const s of (j.streams || [])) {
743
+ const lang = (s.tags && s.tags.language) || '';
744
+ const title = (s.tags && s.tags.title) || '';
745
+ if (s.codec_type === 'audio') tracks.push({ a: ai++, lang, title, codec: s.codec_name });
746
+ else if (s.codec_type === 'subtitle') subs.push({ s: si++, lang, title, codec: s.codec_name });
747
+ }
748
+ return json(200, { ffmpeg: true, tracks, subs });
749
+ } catch (e2) { return json(200, { ffmpeg: true, tracks: [], subs: [], error: String((e2 && e2.message) || e2).slice(0, 160) }); }
750
+ }
751
+ const mR = /^\/remux\/(\d+)\/(\d+)$/.exec(p);
752
+ if (mR) {
753
+ const e = st.torrents.get(Number(mR[1]));
754
+ const f = e?.t.files[Number(mR[2])];
755
+ if (!f) { res.statusCode = 404; res.end('not found'); return; }
756
+ const ffmpeg = detectFFBin('ffmpeg');
757
+ if (!ffmpeg) { res.statusCode = 500; res.end('ffmpeg не найден'); return; }
758
+ const a = u.searchParams.get('a') || '0';
759
+ const sb = u.searchParams.get('s');
760
+ const url = 'http://127.0.0.1:' + st.port + base + '/s/' + mR[1] + '/' + mR[2];
761
+ const args = ['-hide_banner', '-loglevel', 'error', '-i', url, '-map', '0:v:0', '-map', '0:a:' + a, '-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k', '-max_muxing_queue_size', '9999'];
762
+ if (sb !== null && sb !== '') args.push('-map', '0:s:' + sb, '-c:s', 'mov_text');
763
+ args.push('-f', 'mp4', '-movflags', 'frag_keyframe+empty_moov+default_base_moof', '-');
764
+ res.setHeader('Content-Type', 'video/mp4');
765
+ const proc = spawn(ffmpeg, args, { stdio: ['ignore', 'pipe', 'ignore'] });
766
+ proc.stdout.pipe(res);
767
+ proc.on('error', () => { try { res.end(); } catch {} });
768
+ req.on('close', () => { try { proc.kill('SIGKILL'); } catch {} });
769
+ res.on('close', () => { try { proc.kill('SIGKILL'); } catch {} });
770
+ return;
771
+ }
718
772
  const mV = /^\/view\/(\d+)\/(\d+)$/.exec(p);
719
773
  if (mV) {
720
774
  const e = st.torrents.get(Number(mV[1]));
721
775
  const f = e?.t.files[Number(mV[2])];
722
776
  if (!f) { res.statusCode = 404; res.end('not found'); return; }
723
777
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
724
- res.end('<!doctype html><meta charset=utf-8><title>' + esc(f.name) + '</title><style>body{margin:0;background:#000}</style><video controls autoplay src="' + base + '/s/' + mV[1] + '/' + mV[2] + '"></video>');
778
+ res.end('<!doctype html><meta charset=utf-8><title>' + esc(f.name) + '</title>' +
779
+ '<style>body{margin:0;background:#000;color:#ddd;font:14px system-ui;display:flex;flex-direction:column;height:100vh;box-sizing:border-box}' +
780
+ '.bar{display:flex;gap:10px;align-items:center;padding:6px 10px;flex-wrap:wrap}' +
781
+ 'select{background:#1b1b1f;color:#ddd;border:1px solid #333;border-radius:6px;padding:4px;max-width:340px}' +
782
+ 'video{flex:1;width:100%;min-height:0;background:#000}</style>' +
783
+ '<div class=bar><b>' + esc(f.name) + '</b><select id=a style=display:none></select><select id=s style=display:none></select><span id=n></span></div>' +
784
+ '<video id=v controls autoplay src="' + base + '/s/' + mV[1] + '/' + mV[2] + '"></video>' +
785
+ '<script>var B=' + JSON.stringify(base) + ',ID=' + mV[1] + ',I=' + mV[2] + ';' +
786
+ 'var v=document.getElementById("v"),aS=document.getElementById("a"),sS=document.getElementById("s"),n=document.getElementById("n");' +
787
+ 'fetch(B+"/api/tracks/"+ID+"/"+I).then(function(r){return r.json()}).then(function(d){' +
788
+ 'if(d.error)n.textContent="⚠ "+d.error;' +
789
+ 'var A=d.tracks||[],S=d.subs||[];' +
790
+ 'if(!d.ffmpeg||A.length<2){return;}' +
791
+ 'A.forEach(function(t){var o=document.createElement("option");o.value=t.a;o.textContent="♪ "+(t.lang&&t.lang!=="und"?t.lang:"дорожка "+(t.a+1))+(t.title?" — "+t.title:"")+" ["+t.codec+"]";aS.appendChild(o)});' +
792
+ 'aS.value=A[0].a;aS.style.display="";' +
793
+ 'if(S.length){var o0=document.createElement("option");o0.value="";o0.textContent="без субтитров";sS.appendChild(o0);' +
794
+ 'S.forEach(function(t){var o=document.createElement("option");o.value=t.s;o.textContent="💬 "+(t.lang&&t.lang!=="und"?t.lang:"субтитры "+(t.s+1))+(t.title?" — "+t.title:"");sS.appendChild(o)});sS.style.display=""}' +
795
+ 'var go=function(){var t=v.currentTime;v.src=B+"/remux/"+ID+"/"+I+"?a="+aS.value+(sS.value!==""?"&s="+sS.value:"");v.load();v.addEventListener("loadedmetadata",function once(){v.currentTime=t;v.removeEventListener("loadedmetadata",once)});v.play()};' +
796
+ 'aS.onchange=go;sS.onchange=go;go();' +
797
+ '}).catch(function(){})</script>');
725
798
  return;
726
799
  }
727
800
  res.statusCode = 404; res.end('not found');
@@ -740,6 +813,7 @@ async function startWeb(opts, srcArg) {
740
813
  if (id == null) return;
741
814
  const dup = [...st.torrents.values()].find(e => e.t.infoHash === t.infoHash);
742
815
  if (dup) { try { t.destroy(); } catch {} return; }
816
+ t.files.forEach(f => f.deselect()); // webtorrent по умолчанию выбирает все файлы
743
817
  const sel = new Set();
744
818
  const vids = t.files.map((f, i) => ({ f, i })).filter(x => VIDEO_EXT.has(path.extname(x.f.name).toLowerCase()));
745
819
  if (st.autoSelect) {