torrent-online 1.5.4 → 1.5.6

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 +82 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "torrent-online",
3
- "version": "1.5.4",
3
+ "version": "1.5.6",
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;
@@ -631,6 +641,7 @@ function makeWebServer(client, st, opts, cacheDir, base) {
631
641
 
632
642
  async function webReq(req, res, client, st, opts, cacheDir, base) {
633
643
  const u = new URL(req.url, 'http://127.0.0.1');
644
+ const ensureSel = (e, i) => { const f = e?.t.files[i]; if (f && !e.sel.has(i)) { e.sel.add(i); try { f.select(); } catch {} } };
634
645
  let p = u.pathname;
635
646
  if (base) {
636
647
  if (p !== base && !p.startsWith(base + '/')) { res.statusCode = 404; res.end('not found'); return; }
@@ -714,15 +725,83 @@ async function webReq(req, res, client, st, opts, cacheDir, base) {
714
725
  const e = st.torrents.get(Number(mS[1]));
715
726
  const f = e?.t.files[Number(mS[2])];
716
727
  if (!f) { res.statusCode = 404; res.end('not found'); return; }
728
+ ensureSel(e, Number(mS[2]));
717
729
  return serveFile(req, res, f);
718
730
  }
731
+ const mT = /^\/api\/tracks\/(\d+)\/(\d+)$/.exec(p);
732
+ if (mT) {
733
+ const e = st.torrents.get(Number(mT[1]));
734
+ const f = e?.t.files[Number(mT[2])];
735
+ if (!f) return json(404, { error: 'нет файла' });
736
+ ensureSel(e, Number(mT[2]));
737
+ const ffprobe = detectFFBin('ffprobe');
738
+ if (!ffprobe) return json(200, { ffmpeg: false, tracks: [] });
739
+ const url = 'http://127.0.0.1:' + st.port + base + '/s/' + mT[1] + '/' + mT[2];
740
+ try {
741
+ const { stdout } = await execFileP(ffprobe, ['-hide_banner', '-loglevel', 'error', '-print_format', 'json', '-show_streams', url], { maxBuffer: 8 * 1048576, timeout: 25000 });
742
+ const j = JSON.parse(stdout);
743
+ let ai = 0, si = 0;
744
+ const tracks = [], subs = [];
745
+ for (const s of (j.streams || [])) {
746
+ const lang = (s.tags && s.tags.language) || '';
747
+ const title = (s.tags && s.tags.title) || '';
748
+ if (s.codec_type === 'audio') tracks.push({ a: ai++, lang, title, codec: s.codec_name });
749
+ else if (s.codec_type === 'subtitle') subs.push({ s: si++, lang, title, codec: s.codec_name });
750
+ }
751
+ return json(200, { ffmpeg: true, tracks, subs });
752
+ } catch (e2) { return json(200, { ffmpeg: true, tracks: [], subs: [], retry: true }); }
753
+ }
754
+ const mR = /^\/remux\/(\d+)\/(\d+)$/.exec(p);
755
+ if (mR) {
756
+ const e = st.torrents.get(Number(mR[1]));
757
+ const f = e?.t.files[Number(mR[2])];
758
+ if (!f) { res.statusCode = 404; res.end('not found'); return; }
759
+ ensureSel(e, Number(mR[2]));
760
+ const ffmpeg = detectFFBin('ffmpeg');
761
+ if (!ffmpeg) { res.statusCode = 500; res.end('ffmpeg не найден'); return; }
762
+ const a = u.searchParams.get('a') || '0';
763
+ const sb = u.searchParams.get('s');
764
+ const url = 'http://127.0.0.1:' + st.port + base + '/s/' + mR[1] + '/' + mR[2];
765
+ 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'];
766
+ if (sb !== null && sb !== '') args.push('-map', '0:s:' + sb, '-c:s', 'mov_text');
767
+ args.push('-f', 'mp4', '-movflags', 'frag_keyframe+empty_moov+default_base_moof', '-');
768
+ res.setHeader('Content-Type', 'video/mp4');
769
+ const proc = spawn(ffmpeg, args, { stdio: ['ignore', 'pipe', 'ignore'] });
770
+ proc.stdout.pipe(res);
771
+ proc.on('error', () => { try { res.end(); } catch {} });
772
+ req.on('close', () => { try { proc.kill('SIGKILL'); } catch {} });
773
+ res.on('close', () => { try { proc.kill('SIGKILL'); } catch {} });
774
+ return;
775
+ }
719
776
  const mV = /^\/view\/(\d+)\/(\d+)$/.exec(p);
720
777
  if (mV) {
721
778
  const e = st.torrents.get(Number(mV[1]));
722
779
  const f = e?.t.files[Number(mV[2])];
723
780
  if (!f) { res.statusCode = 404; res.end('not found'); return; }
781
+ ensureSel(e, Number(mV[2]));
724
782
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
725
- 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>');
783
+ res.end('<!doctype html><meta charset=utf-8><title>' + esc(f.name) + '</title>' +
784
+ '<style>body{margin:0;background:#000;color:#ddd;font:14px system-ui;display:flex;flex-direction:column;height:100vh;box-sizing:border-box}' +
785
+ '.bar{display:flex;gap:10px;align-items:center;padding:6px 10px;flex-wrap:wrap}' +
786
+ 'select{background:#1b1b1f;color:#ddd;border:1px solid #333;border-radius:6px;padding:4px;max-width:340px}' +
787
+ 'video{flex:1;width:100%;min-height:0;background:#000}</style>' +
788
+ '<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>' +
789
+ '<video id=v controls autoplay src="' + base + '/s/' + mV[1] + '/' + mV[2] + '"></video>' +
790
+ '<script>var B=' + JSON.stringify(base) + ',ID=' + mV[1] + ',I=' + mV[2] + ';' +
791
+ 'var v=document.getElementById("v"),aS=document.getElementById("a"),sS=document.getElementById("s"),n=document.getElementById("n");' +
792
+ 'var tries=0;' +
793
+ 'var load=function(){fetch(B+"/api/tracks/"+ID+"/"+I).then(function(r){return r.json()}).then(function(d){' +
794
+ 'if(d.retry&&tries++<15){n.textContent="⏳ готовлю файл к стриму…";setTimeout(load,4000);return;}' +
795
+ 'if(d.error)n.textContent="⚠ "+d.error;' +
796
+ 'var A=d.tracks||[],S=d.subs||[];' +
797
+ 'if(!d.ffmpeg||A.length<2){return;}' +
798
+ '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)});' +
799
+ 'aS.value=A[0].a;aS.style.display="";' +
800
+ 'if(S.length){var o0=document.createElement("option");o0.value="";o0.textContent="без субтитров";sS.appendChild(o0);' +
801
+ '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=""}' +
802
+ '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()};' +
803
+ 'aS.onchange=go;sS.onchange=go;go();' +
804
+ '}).catch(function(){if(tries++<15)setTimeout(load,4000)})};load();</script>');
726
805
  return;
727
806
  }
728
807
  res.statusCode = 404; res.end('not found');
@@ -742,6 +821,7 @@ async function startWeb(opts, srcArg) {
742
821
  const dup = [...st.torrents.values()].find(e => e.t.infoHash === t.infoHash);
743
822
  if (dup) { try { t.destroy(); } catch {} return; }
744
823
  t.files.forEach(f => f.deselect()); // webtorrent по умолчанию выбирает все файлы
824
+ try { t.fileSelector = (tt, index, file) => (file._selections && file._selections.length ? 2 : 0); } catch {}
745
825
  const sel = new Set();
746
826
  const vids = t.files.map((f, i) => ({ f, i })).filter(x => VIDEO_EXT.has(path.extname(x.f.name).toLowerCase()));
747
827
  if (st.autoSelect) {