torrent-online 1.4.10 → 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.
- package/README.md +44 -2
- package/package.json +1 -1
- package/wtui.js +241 -3
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
|
-
|
|
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
|
-
|
|
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.
|
|
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()));
|
|
@@ -476,7 +483,7 @@ async function addTorrent(client, source, cacheDir) {
|
|
|
476
483
|
client.add(source, { path: cacheDir }, t => {
|
|
477
484
|
if (t.ready) { clearTimeout(to); return resolve(t); }
|
|
478
485
|
const spin = setInterval(() => {
|
|
479
|
-
process.stdout.write(`\r⏳ Метаданные: пиры ${t.peers
|
|
486
|
+
process.stdout.write(`\r⏳ Метаданные: пиры ${(t.peers?.length ?? 0)}, получено ${fmtBytes(t.received)} `);
|
|
480
487
|
}, 1000);
|
|
481
488
|
t.once('ready', () => { clearInterval(spin); process.stdout.write('\x1b[K'); clearTimeout(to); resolve(t); });
|
|
482
489
|
});
|
|
@@ -487,7 +494,7 @@ async function addTorrent(client, source, cacheDir) {
|
|
|
487
494
|
function startProgress(torrent) {
|
|
488
495
|
const t = setInterval(() => {
|
|
489
496
|
process.stdout.write(
|
|
490
|
-
`\r⏬ ${(torrent.progress * 100).toFixed(1)}% · ${fmtBytes(torrent.downloadSpeed)}/s · пиры: ${torrent.
|
|
497
|
+
`\r⏬ ${(torrent.progress * 100).toFixed(1)}% · ${fmtBytes(torrent.downloadSpeed)}/s · пиры: ${(torrent._peers?.size ?? 0)} `
|
|
491
498
|
);
|
|
492
499
|
}, 1000);
|
|
493
500
|
return () => { clearInterval(t); process.stdout.write('\x1b[K\n'); };
|
|
@@ -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 {'&':'&','<':'<','>':'>','"':'"',"'":'''}[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;
|