torrent-online 1.4.11 → 1.5.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.
Files changed (3) hide show
  1. package/README.md +44 -2
  2. package/package.json +1 -1
  3. package/wtui.js +239 -1
package/README.md CHANGED
@@ -42,6 +42,7 @@ node wtui.js "https://example.com/file.torrent"
42
42
  | `--resume` | продолжить с прошлой позиции (VLC `--start-time`) |
43
43
  | `--lan` | слушать 0.0.0.0 + токен в URL — смотреть с телефона/ТВ в сети |
44
44
  | `--no-vlc` | только браузер-стриминг, без VLC |
45
+ | `--web` | графический интерфейс в браузере: поиск, карточки, прогресс, выбор файлов |
45
46
  | `--port=<N>` | фиксированный порт (иначе авто-поиск 8123..10122) |
46
47
  | `--help` | справка |
47
48
 
@@ -57,8 +58,28 @@ node wtui.js "https://example.com/file.torrent"
57
58
  - Фильтр по имени (подстрока или `/regex/`) перед выбором файлов
58
59
  - Проверка VLC на старте с внятной ошибкой
59
60
 
61
+ ## Веб-GUI (`--web`)
62
+ ```bash
63
+ node wtui.js --web
64
+ ```
65
+ Тот же процесс, тот же порт — только в браузере открывается красивый интерфейс: поиск (Rutor/TPB/1337x, RU→EN словарь), карточки торрентов с прогрессом, выбор файлов галочками, кнопки «▶ VLC» и «▶ Браузер». С телефона в Wi-Fi — через `--lan` (токен в URL).
66
+
67
+ ## Нативное приложение (Tauri, macOS)
68
+ Окошко с иконкой, без терминала: над тем же веб-GUI.
69
+ ```bash
70
+ # один раз: Rust (https://rustup.rs) + Xcode CLT
71
+ cd desktop && ./build-mac.sh
72
+ # → desktop/src-tauri/target/release/bundle/macos/TorrentOnline.app
73
+ ```
74
+
60
75
  ## .app (macOS)
61
- `.app` открывает **Terminal** и запускает скрипт.
76
+ Скачай готовый `TorrentOnline.app.zip` из [релизов](../../releases), распакуй и перетащи в `/Applications`.
77
+ Приложение не подписано, поэтому macOS повесит карантин — сними его один раз:
78
+ ```bash
79
+ xattr -dr com.apple.quarantine /Applications/TorrentOnline.app
80
+ open /Applications/TorrentOnline.app
81
+ ```
82
+ Свои сборка (`.app` открывает **Terminal** и запускает скрипт):
62
83
  ```bash
63
84
  ./build/app.sh
64
85
  open dist/TorrentOnline.app
@@ -131,6 +152,7 @@ node wtui.js "https://example.com/file.torrent"
131
152
  | `--resume` | continue from the last position (VLC `--start-time`) |
132
153
  | `--lan` | listen on 0.0.0.0 + token in URL — watch from phone/TV on your network |
133
154
  | `--no-vlc` | browser streaming only, no VLC |
155
+ | `--web` | graphical UI in the browser: search, cards, progress, file picker |
134
156
  | `--port=<N>` | fixed port (otherwise auto-scan 8123..10122) |
135
157
  | `--help` | help |
136
158
 
@@ -146,8 +168,28 @@ Default cache: `~/Movies/WebTorrent`. **Note: the cache folder is deleted on exi
146
168
  - Name filter (substring or `/regex/`) before picking files
147
169
  - VLC presence checked at startup with a clear error
148
170
 
171
+ ## Web GUI (`--web`)
172
+ ```bash
173
+ node wtui.js --web
174
+ ```
175
+ Same process, same port — the browser opens a polished UI: search (Rutor/TPB/1337x, RU→EN dictionary), torrent cards with progress, file checkboxes, “▶ VLC” / “▶ Browser” buttons. From a phone on your Wi-Fi — use `--lan` (token in URL).
176
+
177
+ ## Native app (Tauri, macOS)
178
+ A window with an icon, no terminal — wraps the same web GUI.
179
+ ```bash
180
+ # once: Rust (https://rustup.rs) + Xcode CLT
181
+ cd desktop && ./build-mac.sh
182
+ # → desktop/src-tauri/target/release/bundle/macos/TorrentOnline.app
183
+ ```
184
+
149
185
  ## .app (macOS)
150
- The `.app` opens **Terminal** and runs the script.
186
+ Download the prebuilt `TorrentOnline.app.zip` from [releases](../../releases), unzip and drag it to `/Applications`.
187
+ The app is unsigned, so macOS adds a quarantine flag — clear it once:
188
+ ```bash
189
+ xattr -dr com.apple.quarantine /Applications/TorrentOnline.app
190
+ open /Applications/TorrentOnline.app
191
+ ```
192
+ Build it yourself (the `.app` opens **Terminal** and runs the script):
151
193
  ```bash
152
194
  ./build/app.sh
153
195
  open dist/TorrentOnline.app
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "torrent-online",
3
- "version": "1.4.11",
3
+ "version": "1.5.0",
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
@@ -28,14 +28,17 @@ const HELP = `torrent-online — стрим торрента в VLC или бр
28
28
  --resume продолжить с прошлой позиции (VLC --start-time)
29
29
  --lan слушать 0.0.0.0 + токен в URL (смотреть с телефона)
30
30
  --no-vlc только браузер-стриминг, без VLC
31
+ --web графический интерфейс в браузере (поиск/выбор/прогресс)
31
32
  --port=<N> фиксированный порт (иначе авто 8123..10122)
32
33
  --help, -h эта справка`;
33
34
 
34
35
  function parseCli() {
35
- const opts = { keepCache: false, resume: false, lan: false, noVlc: false, caching: 3000, port: 0 };
36
+ const opts = { keepCache: false, resume: false, lan: false, noVlc: false, web: false, noOpen: false, caching: 3000, port: 0 };
36
37
  const positional = [];
37
38
  for (const a of process.argv.slice(2)) {
38
39
  if (a === '--keep-cache') opts.keepCache = true;
40
+ else if (a === '--web') opts.web = true;
41
+ else if (a === '--no-open') opts.noOpen = true;
39
42
  else if (a === '--resume') opts.resume = true;
40
43
  else if (a === '--lan') opts.lan = true;
41
44
  else if (a === '--no-vlc') opts.noVlc = true;
@@ -237,6 +240,10 @@ function handleReq(req, res, torrent, base) {
237
240
  const file = torrent.files[Number(mFile[1])];
238
241
  if (!file) { res.statusCode = 404; res.end('not found'); return; }
239
242
 
243
+ serveFile(req, res, file);
244
+ }
245
+
246
+ function serveFile(req, res, file) {
240
247
  const total = file.length;
241
248
  res.setHeader('Accept-Ranges', 'bytes');
242
249
  res.setHeader('Content-Type', contentTypeByExt(path.extname(file.name).toLowerCase()));
@@ -530,10 +537,241 @@ function watchFolder() {
530
537
  }).catch(() => {});
531
538
  }
532
539
 
540
+ // ---- WEB GUI (--web) ----
541
+ const WEB_HTML = `<!doctype html><html><head><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><title>TorrentOnline</title><style>
542
+ :root{--bg:#0f1115;--card:#171a21;--line:#232833;--txt:#e8ecf1;--mut:#8a93a3;--acc:#7fd4ff}
543
+ *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--txt);font:15px/1.45 -apple-system,'Segoe UI',Roboto,sans-serif}
544
+ header{display:flex;gap:10px;align-items:center;padding:14px 20px;border-bottom:1px solid var(--line);position:sticky;top:0;background:rgba(15,17,21,.92);backdrop-filter:blur(6px)}
545
+ header b{font-size:18px}
546
+ .wrap{max-width:900px;margin:0 auto;padding:16px}
547
+ .search{display:flex;gap:8px}
548
+ input#q{flex:1;min-width:0;background:#1d222c;border:1px solid #2a3140;color:var(--txt);padding:10px 12px;border-radius:8px;font-size:15px}
549
+ input#q:focus{outline:none;border-color:var(--acc)}
550
+ button{background:#223046;color:var(--acc);border:1px solid #2f4160;padding:9px 14px;border-radius:8px;cursor:pointer;font-size:14px}
551
+ button:hover{background:#2a3c58}
552
+ .note{color:var(--mut);margin:8px 0;font-size:13px}
553
+ .row{display:flex;gap:10px;align-items:baseline;background:var(--card);border:1px solid var(--line);border-radius:8px;padding:10px 12px;margin:6px 0;cursor:pointer}
554
+ .row:hover{border-color:#33415e}
555
+ .tag{background:#223046;color:var(--acc);border-radius:6px;padding:1px 7px;font-size:12px;flex:none}
556
+ .meta{color:var(--mut);font-size:12px;flex:none}
557
+ .rname{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
558
+ .card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:14px;margin:10px 0}
559
+ .tname{font-weight:600;margin-bottom:4px;word-break:break-word}
560
+ .tmeta{color:var(--mut);font-size:13px}
561
+ .bar{height:6px;background:#232a36;border-radius:3px;margin:8px 0;overflow:hidden}
562
+ .bar div{height:100%;background:linear-gradient(90deg,#3b82f6,#7fd4ff);border-radius:3px}
563
+ .fin{display:block;padding:4px 2px;cursor:pointer;color:var(--mut);font-size:14px}
564
+ .fin input{margin-right:8px;accent-color:#3b82f6}
565
+ .acts{margin-top:10px;display:flex;gap:8px;flex-wrap:wrap}
566
+ .acts .del{margin-left:auto;color:#ff8a8a;border-color:#4a2a33;background:#2a1c22}
567
+ h3{color:var(--mut);font-size:14px;margin:18px 0 4px;font-weight:500}
568
+ video{width:100vw;height:100vh;background:#000;display:block;margin:0}
569
+ </style></head><body>
570
+ <header>🦀 <b>TorrentOnline</b></header>
571
+ <div class=wrap>
572
+ <div class=search><input id=q placeholder="Поиск: пацаны 4 сезон · breaking bad · magnet:…" autocomplete=off><button id=go>Искать</button><button id=mag>+ magnet</button></div>
573
+ <div id=notes></div>
574
+ <div id=results></div>
575
+ <h3>Торренты</h3>
576
+ <div id=torrents></div>
577
+ </div>
578
+ <script>
579
+ var BASE="__BASE__";
580
+ function esc(s){return String(s).replace(/[&<>"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];});}
581
+ function api(p,body){var o=body?{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)}:undefined;return fetch(BASE+p,o).then(function(r){return r.json();});}
582
+ function doSearch(){var q=document.getElementById('q').value.trim();if(!q)return;var box=document.getElementById('results');box.innerHTML='<div class=note>Ищу…</div>';
583
+ api('/api/search?q='+encodeURIComponent(q)).then(function(d){
584
+ var h='';(d.notes||[]).forEach(function(n){h+='<div class=note>⚠ '+esc(n)+'</div>';});
585
+ if(!d.items.length){box.innerHTML=h+'<div class=note>Ничего не найдено</div>';return;}
586
+ h+=d.items.map(function(it){return '<div class=row data-mag="'+esc(it.mag)+'"><span class=tag>'+esc(it.src)+'</span><span class=rname>'+esc(it.name)+'</span>'+(it.size?'<span class=meta>'+esc(it.size)+'</span>':'')+(it.seeds?'<span class=meta>🌱 '+it.seeds+'</span>':'')+'</div>';}).join('');
587
+ box.innerHTML=h;
588
+ [].forEach.call(box.querySelectorAll('.row'),function(r){r.onclick=function(){api('/api/add',{source:r.getAttribute('data-mag')}).then(poll);};});
589
+ }).catch(function(){box.innerHTML='<div class=note>Ошибка поиска</div>';});}
590
+ document.getElementById('go').onclick=doSearch;
591
+ document.getElementById('q').addEventListener('keydown',function(e){if(e.key==='Enter')doSearch();});
592
+ document.getElementById('mag').onclick=function(){var m=prompt('magnet:');if(m&&m.indexOf('magnet:')===0){api('/api/add',{source:m}).then(poll);}};
593
+ function render(list){var box=document.getElementById('torrents');
594
+ if(!list.length){box.innerHTML='<div class=note>Пусто. Найди торрент и кликни по строке.</div>';return;}
595
+ box.innerHTML=list.map(function(t){
596
+ var files=t.files.map(function(f){return '<label class=fin><input type=checkbox data-id='+t.id+' data-i='+f.i+(f.selected?' checked':'')+'> '+esc(f.name)+' <span class=meta>'+esc(f.size)+'</span></label>';}).join('');
597
+ var first=t.files.filter(function(f){return f.selected;})[0];
598
+ var wi=first?first.i:(t.files[0]?t.files[0].i:0);
599
+ var bar=t.ready?t.progress:0;
600
+ return '<div class=card><div class=tname>'+esc(t.name)+'</div><div class=tmeta>'+(t.ready?'⏬ '+t.progress+'% · '+esc(t.speed)+' · пиры '+t.peers:'⏳ ждём метаданные…')+'</div><div class=bar><div style=width:'+bar+'%></div></div><div class=files>'+files+'</div><div class=acts><button data-act=vlc data-id='+t.id+'>▶ VLC</button><button data-act=web data-id='+t.id+' data-i='+wi+'>▶ Браузер</button><button class=del data-act=del data-id='+t.id+'>✕ удалить</button></div></div>';
601
+ }).join('');
602
+ [].forEach.call(box.querySelectorAll('.fin input'),function(cb){cb.onchange=function(){var id=+cb.getAttribute('data-id');var idx=[].filter.call(box.querySelectorAll('.fin input[data-id="'+id+'"]'),function(x){return x.checked;}).map(function(x){return +x.getAttribute('data-i');});api('/api/select',{id:id,indices:idx});};});
603
+ [].forEach.call(box.querySelectorAll('button[data-act]'),function(b){b.onclick=function(){var id=+b.getAttribute('data-id');var act=b.getAttribute('data-act');
604
+ if(act==='vlc'){b.textContent='…';api('/api/vlc',{id:id}).then(function(d){b.textContent=d.error?('⚠ '+d.error):'▶ VLC';setTimeout(function(){b.textContent='▶ VLC';},2500);});}
605
+ if(act==='web'){window.open(BASE+'/view/'+id+'/'+b.getAttribute('data-i'));}
606
+ if(act==='del'){api('/api/remove',{id:id}).then(poll);}
607
+ };});
608
+ }
609
+ function poll(){api('/api/status').then(render).catch(function(){});}
610
+ poll();setInterval(poll,1500);
611
+ </script></body></html>`;
612
+
613
+ function readBody(req) {
614
+ return new Promise((resolve, reject) => {
615
+ let d = '';
616
+ req.on('data', c => { d += c; if (d.length > 1e5) req.destroy(); });
617
+ req.on('end', () => { try { resolve(d ? JSON.parse(d) : {}); } catch (e) { reject(e); } });
618
+ req.on('error', reject);
619
+ });
620
+ }
621
+
622
+ function makeWebServer(client, st, opts, cacheDir, base) {
623
+ return http.createServer((req, res) => {
624
+ Promise.resolve(webReq(req, res, client, st, opts, cacheDir, base)).catch(e => {
625
+ if (!res.headersSent) { res.statusCode = 500; res.end('error'); }
626
+ console.error('web:', e?.message || e);
627
+ });
628
+ });
629
+ }
630
+
631
+ async function webReq(req, res, client, st, opts, cacheDir, base) {
632
+ const u = new URL(req.url, 'http://127.0.0.1');
633
+ let p = u.pathname;
634
+ if (base) {
635
+ if (p !== base && !p.startsWith(base + '/')) { res.statusCode = 404; res.end('not found'); return; }
636
+ p = p.slice(base.length) || '/';
637
+ }
638
+ const json = (code, obj) => { res.statusCode = code; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify(obj)); };
639
+
640
+ if (p === '/' || p === '') {
641
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
642
+ res.end(WEB_HTML.replace('__BASE__', base));
643
+ return;
644
+ }
645
+ if (p === '/api/search') {
646
+ const q = u.searchParams.get('q') || '';
647
+ const { items, notes } = await searchIndexers(q);
648
+ return json(200, { items, notes });
649
+ }
650
+ if (p === '/api/add' && req.method === 'POST') {
651
+ const b = await readBody(req);
652
+ const source = String(b.source || '');
653
+ if (!source) return json(400, { error: 'нужен source' });
654
+ const id = st.nextId++;
655
+ st.pending.push(id);
656
+ client.add(source, { path: cacheDir });
657
+ return json(200, { id });
658
+ }
659
+ if (p === '/api/status') {
660
+ const list = [...st.torrents.values()].map(e => ({
661
+ id: e.id,
662
+ name: e.t.name || '…',
663
+ ready: !!e.t.ready,
664
+ progress: Math.round((e.t.progress || 0) * 100),
665
+ speed: fmtBytes(e.t.downloadSpeed) + '/s',
666
+ peers: e.t._peers?.size ?? 0,
667
+ files: e.t.files
668
+ .map((f, i) => ({ i, name: f.path, size: fmtBytes(f.length), selected: e.sel.has(i), video: VIDEO_EXT.has(path.extname(f.name).toLowerCase()) }))
669
+ .filter(f => f.video),
670
+ }));
671
+ return json(200, list);
672
+ }
673
+ if (p === '/api/select' && req.method === 'POST') {
674
+ const b = await readBody(req);
675
+ const e = st.torrents.get(Number(b.id));
676
+ if (!e) return json(404, { error: 'нет торрента' });
677
+ e.t.files.forEach(f => f.deselect());
678
+ e.sel = new Set((b.indices || []).map(Number));
679
+ e.sel.forEach(i => e.t.files[i]?.select());
680
+ return json(200, { ok: true });
681
+ }
682
+ if (p === '/api/vlc' && req.method === 'POST') {
683
+ const b = await readBody(req);
684
+ const e = st.torrents.get(Number(b.id));
685
+ if (!e) return json(404, { error: 'нет торрента' });
686
+ const idx = [...e.sel];
687
+ if (!idx.length) return json(400, { error: 'выбери файлы' });
688
+ const vlcBin = detectVLCPath();
689
+ if (!vlcBin) return json(500, { error: 'VLC не найден' });
690
+ const subs = await collectSubtitles(e.t, idx);
691
+ const urls = idx.map(i => 'http://127.0.0.1:' + st.port + base + '/s/' + e.id + '/' + i);
692
+ const args = buildVLCArgs({ urls, caching: opts.caching, subs, startAt: 0 });
693
+ const v = spawn(vlcBin, args, { stdio: 'ignore' });
694
+ v.on('error', () => {});
695
+ st.vlc.push(v);
696
+ return json(200, { ok: true, subs: subs.length });
697
+ }
698
+ if (p === '/api/remove' && req.method === 'POST') {
699
+ const b = await readBody(req);
700
+ const id = Number(b.id);
701
+ const e = st.torrents.get(id);
702
+ if (e) { st.torrents.delete(id); try { await new Promise(r => e.t.destroy(r)); } catch {} }
703
+ return json(200, { ok: true });
704
+ }
705
+ const mS = /^\/s\/(\d+)\/(\d+)$/.exec(p);
706
+ if (mS) {
707
+ const e = st.torrents.get(Number(mS[1]));
708
+ const f = e?.t.files[Number(mS[2])];
709
+ if (!f) { res.statusCode = 404; res.end('not found'); return; }
710
+ return serveFile(req, res, f);
711
+ }
712
+ const mV = /^\/view\/(\d+)\/(\d+)$/.exec(p);
713
+ if (mV) {
714
+ const e = st.torrents.get(Number(mV[1]));
715
+ const f = e?.t.files[Number(mV[2])];
716
+ if (!f) { res.statusCode = 404; res.end('not found'); return; }
717
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
718
+ 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>');
719
+ return;
720
+ }
721
+ res.statusCode = 404; res.end('not found');
722
+ }
723
+
724
+ async function startWeb(opts, srcArg) {
725
+ const cacheDir = DEFAULT_CACHE;
726
+ await ensureDir(cacheDir);
727
+ const client = new WebTorrent({ destroyStoreOnDestroy: true });
728
+ const token = opts.lan ? crypto.randomBytes(8).toString('hex') : '';
729
+ const base = token ? '/t/' + token : '';
730
+ const st = { torrents: new Map(), nextId: 1, vlc: [], port: 0, pending: [], autoSelect: !!srcArg };
731
+ client.on('torrent', t => {
732
+ const id = st.pending.shift();
733
+ if (id == null) return;
734
+ const sel = new Set();
735
+ if (st.autoSelect) {
736
+ st.autoSelect = false;
737
+ t.files.forEach((f, i) => { if (VIDEO_EXT.has(path.extname(f.name).toLowerCase())) sel.add(i); });
738
+ }
739
+ sel.forEach(i => t.files[i]?.select());
740
+ st.torrents.set(id, { id, t, sel });
741
+ });
742
+ const server = makeWebServer(client, st, opts, cacheDir, base);
743
+ const host = opts.lan ? '0.0.0.0' : '127.0.0.1';
744
+ st.port = await listenOnFreePort(server, host, opts.port);
745
+ const url = 'http://127.0.0.1:' + st.port + base + '/';
746
+ console.log('🌐 GUI: ' + url);
747
+ if (opts.lan) console.log('📱 В сети: http://' + localIP() + ':' + st.port + base + '/ (токен в URL)');
748
+ if (!opts.noOpen) {
749
+ const opener = process.platform === 'darwin' ? 'open' : 'xdg-open';
750
+ try { spawn(opener, [url], { stdio: 'ignore' }); } catch {}
751
+ }
752
+ if (srcArg) { st.pending.push(st.nextId++); client.add(srcArg, { path: cacheDir }); }
753
+ let cleaning = false;
754
+ async function shutdown(code = 0) {
755
+ if (cleaning) return; cleaning = true;
756
+ st.vlc.forEach(v => { try { v.kill(); } catch {} });
757
+ try { server.close(); } catch {}
758
+ try { await new Promise(r => client.destroy(r)); } catch {}
759
+ if (!opts.keepCache) await cleanCache(cacheDir);
760
+ process.exit(code);
761
+ }
762
+ process.on('SIGINT', () => shutdown(0));
763
+ process.on('SIGTERM', () => shutdown(0));
764
+ const NET_NOISE = /premature|aborted|EPIPE|ECONNRESET|ETIMEDOUT|socket|stream/i;
765
+ process.on('unhandledRejection', e => { const m = (e && (e.message || e)) + ''; if (!NET_NOISE.test(m)) console.error('unhandledRejection:', m); });
766
+ process.on('uncaughtException', e => { const m = (e && (e.message || e)) + ''; if (NET_NOISE.test(m)) return; console.error('uncaughtException:', m); console.error((e && e.stack || '').split('\\n').slice(0, 8).join('\\n')); });
767
+ console.log('Ctrl+C — выход.');
768
+ }
769
+
533
770
  // ---- Main ----
534
771
  async function main() {
535
772
  const { opts, srcArg } = parseCli();
536
773
  watchFolder();
774
+ if (opts.web) return startWeb(opts, srcArg);
537
775
 
538
776
  const source = srcArg || await promptSource();
539
777
  let cacheDir;