torrent-online 1.7.5 → 1.7.7

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 (4) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +8 -6
  3. package/package.json +1 -1
  4. package/wtui.js +371 -141
package/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  Формат — [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/), версии — [Semantic Versioning](https://semver.org/lang/ru/).
4
4
 
5
+ ## [1.7.7] — 2026-10-04
6
+ **RU:** Поиск снова живой. Страница больше не падает с `Invalid regular expression: /+/g`.
7
+ **EN:** Search works again. The page no longer dies on `Invalid regular expression: /+/g`.
8
+ - 🐛 С 1.7.3 имя из magnet писалось через `/+/g` внутри шаблона, слэш съедался, и браузер отказывался выполнять весь скрипт страницы. Кнопка «Искать» из-за этого молчала и в Chrome, и в Safari
9
+
10
+ ## [1.7.6] — 2026-10-04
11
+ **RU:** Браузер играет как обычный ролик: стабильное время, перемотка, смена дорожки. Окно с поиском открывается само. Кэш стирает кнопка.
12
+ **EN:** The browser plays a normal VOD title: stable duration, seek, and audio-track switch. The search window opens on launch. Cache is cleared by a button.
13
+ - 🐛 Плейлист больше не «прямой эфир» (`EVENT` без конца). Теперь `VOD` + `ENDLIST`, длительность берётся из файла и не прыгает
14
+ - 🐛 Смена дорожки и перемотка не сбрасывают воспроизведение в ноль
15
+ - 🐛 В браузере всегда H.264 + AAC, поэтому Chrome и Safari играют один и тот же поток. VLC по-прежнему открывает исходный файл
16
+ - 🐛 `hls.js` подключается через `createRequire` (раньше `require` в ESM не находился, и Chrome оставался без плеера)
17
+ - ➕ Запуск без аргументов открывает окно: поиск, magnet, файл `.torrent`, у каждого файла кнопки VLC и Браузер
18
+ - ➕ Кнопка «Очистить кэш». В окне скачанное больше не удаляется при выходе
19
+
5
20
  ## [1.4.3] — 2026-10-03
6
21
  **RU:** .app распространяется как артефакт релиза.
7
22
  **EN:** The .app ships as a release artifact.
package/README.md CHANGED
@@ -42,11 +42,12 @@ 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
+ | `--web` | окно в браузере. Это же происходит при запуске без торрента |
46
+ | `--cli` | старое меню в терминале |
46
47
  | `--port=<N>` | фиксированный порт (иначе авто-поиск 8123..10122) |
47
48
  | `--help` | справка |
48
49
 
49
- Кэш по умолчанию: `~/Movies/WebTorrent`. **Внимание: папка кэша удаляется после выхода** (кроме `--keep-cache`) — промпт об этом предупреждает.
50
+ Кэш по умолчанию: `~/Movies/WebTorrent`. В окне он остаётся после выхода, стирает его кнопка «Очистить кэш». В режиме `--cli` папка по-прежнему удаляется при выходе, если нет `--keep-cache`.
50
51
 
51
52
  ## Возможности (v1.4)
52
53
  - 🔎 Поиск торрентов прямо в меню: **Rutor.info (по-русски)**, TPB.party (по-английски), опционально 1337x API
@@ -62,7 +63,7 @@ node wtui.js "https://example.com/file.torrent"
62
63
  ```bash
63
64
  node wtui.js --web
64
65
  ```
65
- Тот же процесс, тот же порт — только в браузере открывается красивый интерфейс: поиск (Rutor/TPB/1337x, RU→EN словарь), карточки торрентов с прогрессом, выбор файлов галочками, кнопки «▶ VLC» и «▶ Браузер». С телефона в Wi-Fi — через `--lan` (токен в URL).
66
+ Тот же процесс, тот же порт. В браузере: поиск, поле magnet, кнопка файла `.torrent`, карточки с прогрессом, у каждого файла кнопки VLC и Браузер. VLC играет исходник. Браузер играет тот же поток со звуком: обычная шкала, перемотка и смена дорожки. С телефона в Wi-Fi — через `--lan` (токен в URL).
66
67
 
67
68
  ## Нативное приложение (Tauri, macOS)
68
69
  Окошко с иконкой, без терминала: над тем же веб-GUI.
@@ -152,11 +153,12 @@ node wtui.js "https://example.com/file.torrent"
152
153
  | `--resume` | continue from the last position (VLC `--start-time`) |
153
154
  | `--lan` | listen on 0.0.0.0 + token in URL — watch from phone/TV on your network |
154
155
  | `--no-vlc` | browser streaming only, no VLC |
155
- | `--web` | graphical UI in the browser: search, cards, progress, file picker |
156
+ | `--web` | browser window. This is also what a launch with no torrent does |
157
+ | `--cli` | the old terminal menu |
156
158
  | `--port=<N>` | fixed port (otherwise auto-scan 8123..10122) |
157
159
  | `--help` | help |
158
160
 
159
- Default cache: `~/Movies/WebTorrent`. **Note: the cache folder is deleted on exit** (unless `--keep-cache`) — the prompt warns about it.
161
+ Default cache: `~/Movies/WebTorrent`. The window keeps it after exit; the «Очистить кэш» button deletes it. In `--cli` mode the folder is still removed on exit unless `--keep-cache` is set.
160
162
 
161
163
  ## Features (v1.4)
162
164
  - 🔎 Built-in torrent search: **Rutor.info (Russian)**, TPB.party (English), optional 1337x API
@@ -172,7 +174,7 @@ Default cache: `~/Movies/WebTorrent`. **Note: the cache folder is deleted on exi
172
174
  ```bash
173
175
  node wtui.js --web
174
176
  ```
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).
177
+ Same process, same port. The browser has search, a magnet field, a `.torrent` file button, progress cards, and VLC / Browser buttons on each file. VLC plays the original. The browser plays one stream with sound: a normal timeline, seek, and an audio-track switch. From a phone on your Wi-Fi — use `--lan` (token in URL).
176
178
 
177
179
  ## Native app (Tauri, macOS)
178
180
  A window with an icon, no terminal — wraps the same web GUI.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "torrent-online",
3
- "version": "1.7.5",
3
+ "version": "1.7.7",
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
@@ -9,9 +9,12 @@ import { spawn, spawnSync, execFile } from 'child_process';
9
9
  import { promisify } from 'util';
10
10
  const execFileP = promisify(execFile);
11
11
  import { fileURLToPath } from 'url';
12
+ import { createRequire } from 'node:module';
12
13
  import { select, checkbox, input, confirm, Separator } from '@inquirer/prompts';
13
14
  import WebTorrent from 'webtorrent';
14
15
 
16
+ const require = createRequire(import.meta.url);
17
+
15
18
  /*
16
19
  torrent-online — WebTorrent → VLC / браузер (ESM)
17
20
  TLA-совместимо с WebTorrent 2.x на Node 18+.
@@ -21,25 +24,29 @@ import WebTorrent from 'webtorrent';
21
24
  const HELP = `torrent-online — стрим торрента в VLC или браузер
22
25
 
23
26
  Использование:
24
- torrent-online интерактивное меню
25
- torrent-online <file.torrent| magnet:...> без меню (играют все видеофайлы)
27
+ torrent-online окно: поиск, magnet, файл, VLC или браузер
28
+ torrent-online <file.torrent|magnet:...> сразу в VLC, без окна
29
+ torrent-online --cli старое меню в терминале
26
30
 
27
31
  Флаги:
28
- --keep-cache не удалять кэш после выхода
32
+ --web то же окно, что и запуск без аргументов
33
+ --cli меню в терминале
34
+ --keep-cache в режиме --cli не удалять кэш после выхода
29
35
  --network-caching=<ms> кэш VLC (по умолчанию 3000)
30
36
  --resume продолжить с прошлой позиции (VLC --start-time)
31
37
  --lan слушать 0.0.0.0 + токен в URL (смотреть с телефона)
32
38
  --no-vlc только браузер-стриминг, без VLC
33
- --web графический интерфейс в браузере (поиск/выбор/прогресс)
34
39
  --port=<N> фиксированный порт (иначе авто 8123..10122)
40
+ --no-open не открывать браузер самой
35
41
  --help, -h эта справка`;
36
42
 
37
43
  function parseCli() {
38
- const opts = { keepCache: false, resume: false, lan: false, noVlc: false, web: false, noOpen: false, caching: 3000, port: 0 };
44
+ const opts = { keepCache: false, resume: false, lan: false, noVlc: false, web: false, cli: false, noOpen: false, caching: 3000, port: 0 };
39
45
  const positional = [];
40
46
  for (const a of process.argv.slice(2)) {
41
47
  if (a === '--keep-cache') opts.keepCache = true;
42
48
  else if (a === '--web') opts.web = true;
49
+ else if (a === '--cli') opts.cli = true;
43
50
  else if (a === '--no-open') opts.noOpen = true;
44
51
  else if (a === '--resume') opts.resume = true;
45
52
  else if (a === '--lan') opts.lan = true;
@@ -550,6 +557,7 @@ const WEB_HTML = `<!doctype html><html><head><meta charset=utf-8><meta name=view
550
557
  *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--txt);font:15px/1.45 -apple-system,'Segoe UI',Roboto,sans-serif}
551
558
  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)}
552
559
  header b{font-size:18px}
560
+ #clr{margin-left:auto;color:#ffb4b4;border-color:#5a3038;background:#2a1c22}
553
561
  .wrap{max-width:900px;margin:0 auto;padding:16px}
554
562
  .search{display:flex;gap:8px}
555
563
  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}
@@ -581,9 +589,11 @@ body.drop{outline:3px dashed #3b82f6;outline-offset:-10px;background:rgba(59,130
581
589
  h3{color:var(--mut);font-size:14px;margin:18px 0 4px;font-weight:500}
582
590
  video{width:100vw;height:100vh;background:#000;display:block;margin:0}
583
591
  </style></head><body>
584
- <header>🦀 <b>TorrentOnline</b></header>
592
+ <header>🦀 <b>TorrentOnline</b><button id=clr type=button>Очистить кэш</button></header>
585
593
  <div class=wrap>
586
- <div class=search><input id=q placeholder="Поиск: пацаны 4 сезон · breaking bad · magnet:…" autocomplete=off><button id=go>Искать</button><button id=mag>+ magnet</button><button id=tpath>+ .torrent файл</button><input type=file id=tt accept=".torrent,application/x-bittorrent" style=display:none></div>
594
+ <div class=search><input id=q placeholder="Поиск: пацаны 4 сезон · breaking bad" autocomplete=off><button id=go type=button>Искать</button></div>
595
+ <div class=search style="margin-top:8px"><input id=magin placeholder="Вставь magnet:?xt=urn:btih:…" autocomplete=off><button id=mag type=button>Добавить magnet</button><button id=tpath type=button>Файл .torrent</button><input type=file id=tt accept=".torrent,application/x-bittorrent" style=display:none></div>
596
+ <div class=note>Поток идёт сразу, целиком ждать не нужно. У каждого файла кнопки VLC и Браузер. Кэш при выходе не удаляется — только этой кнопкой.</div>
587
597
  <div id=notes></div>
588
598
  <div id=results></div>
589
599
  <h3>Торренты</h3>
@@ -595,13 +605,13 @@ function esc(s){return String(s).replace(/[&<>"']/g,function(c){return {'&':'&am
595
605
  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();});}
596
606
  function addTorrent(mag){
597
607
  var box=document.getElementById('torrents');
598
- var dn=(/dn=([^&]*)/.exec(mag)||[,''])[1];try{dn=decodeURIComponent(dn.replace(/\+/g,' '))}catch(e){}
608
+ var dn=(/dn=([^&]*)/.exec(mag)||[,''])[1];try{dn=decodeURIComponent(dn.replace(/\\+/g,' '))}catch(e){}
599
609
  var ph=document.createElement('div');ph.className='card';
600
610
  ph.innerHTML='<div class=tname>⏳ '+esc(dn||'торрент')+'</div><div class=tmeta>добавляю в клиент…</div>';
601
611
  box.prepend(ph);
602
612
  api('/api/add',{source:mag}).then(function(d){if(d&&d.error){ph.innerHTML='<div class=tname>⚠ '+esc(d.error)+'</div>';}poll();toTorrents();}).catch(function(e){ph.innerHTML='<div class=tname>⚠ Ошибка /api/add: '+esc(e&&e.message||e)+'</div>';});
603
613
  }
604
- function doSearch(){var q=document.getElementById('q').value.trim();if(!q)return;var box=document.getElementById('results');box.innerHTML='<div class=note>Ищу…</div>';
614
+ function doSearch(){var q=document.getElementById('q').value.trim();if(!q)return;if(q.indexOf('magnet:')===0){addTorrent(q);document.getElementById('q').value='';return;}var box=document.getElementById('results');box.innerHTML='<div class=note>Ищу…</div>';
605
615
  api('/api/search?q='+encodeURIComponent(q)).then(function(d){
606
616
  var h='';(d.notes||[]).forEach(function(n){h+='<div class=note>⚠ '+esc(n)+'</div>';});
607
617
  if(!d.items.length){box.innerHTML=h+'<div class=note>Ничего не найдено</div>';return;}
@@ -611,7 +621,9 @@ box.innerHTML=h;
611
621
  }).catch(function(){box.innerHTML='<div class=note>Ошибка поиска</div>';});}
612
622
  document.getElementById('go').onclick=doSearch;
613
623
  document.getElementById('q').addEventListener('keydown',function(e){if(e.key==='Enter')doSearch();});
614
- document.getElementById('mag').onclick=function(){var m=prompt('magnet:');if(m&&m.indexOf('magnet:')===0){addTorrent(m);}};
624
+ document.getElementById('mag').onclick=function(){var m=document.getElementById('magin').value.trim();var nb=document.getElementById('notes');if(m.indexOf('magnet:')!==0){nb.innerHTML='<div class=note>Нужна ссылка, которая начинается с magnet:</div>';return;}nb.innerHTML='';addTorrent(m);document.getElementById('magin').value='';};
625
+ document.getElementById('magin').addEventListener('keydown',function(e){if(e.key==='Enter')document.getElementById('mag').click();});
626
+ document.getElementById('clr').onclick=function(){if(!confirm('Удалить скачанные файлы из кэша? Список торрентов тоже очистится.'))return;api('/api/clear-cache',{ok:1}).then(function(){document.getElementById('notes').innerHTML='<div class=note>Кэш очищен</div>';poll();}).catch(function(){document.getElementById('notes').innerHTML='<div class=note>Не удалось очистить кэш</div>';});};
615
627
  document.getElementById('tpath').onclick=function(){document.getElementById('tt').click();};
616
628
  document.getElementById('tt').onchange=function(){if(this.files&&this.files[0])uploadFile(this.files[0]);this.value='';};
617
629
  function uploadFile(f){var nb=document.getElementById('notes');nb.innerHTML='<div class=note>⏳ Загружаю '+esc(f.name)+'…</div>';
@@ -620,14 +632,14 @@ document.addEventListener('dragover',function(e){e.preventDefault();document.bod
620
632
  document.addEventListener('dragleave',function(e){if(e.target===document.body||e.relatedTarget===null)document.body.classList.remove('drop');});
621
633
  document.addEventListener('drop',function(e){e.preventDefault();document.body.classList.remove('drop');
622
634
  var fs=e.dataTransfer.files||[];
623
- for(var i=0;i<fs.length;i++){if(/\.torrent$/i.test(fs[i].name)){uploadFile(fs[i]);return;}}
635
+ for(var i=0;i<fs.length;i++){if(/\\.torrent$/i.test(fs[i].name)){uploadFile(fs[i]);return;}}
624
636
  var t=(e.dataTransfer.getData('text/uri-list')||'')+(e.dataTransfer.getData('text/plain')||'');
625
- var mm=t.match(/magnet:\?[^\s,]+/);if(mm){api('/api/add',{source:'magnet:'+mm[0]}).then(function(){poll();toTorrents();});}
637
+ var mm=t.match(/magnet:\\?[^\\s]+/);if(mm){addTorrent(mm[0]);}
626
638
  });
627
639
  function render(list){var box=document.getElementById('torrents');
628
640
  if(!list.length){box.innerHTML='<div class=note>Пусто. Найди торрент и кликни по строке.</div>';return;}
629
641
  box.innerHTML=list.map(function(t){
630
- 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> <button class=play data-act=web data-id='+t.id+' data-i='+f.i+'>▶</button></label>';}).join('');
642
+ var files=t.files.map(function(f){return '<div class=fin><label><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> <button type=button class=play data-act=vlc data-id='+t.id+' data-i='+f.i+'>VLC</button> <button type=button class=play data-act=web data-id='+t.id+' data-i='+f.i+'>Браузер</button></div>';}).join('');
631
643
  var first=t.files.filter(function(f){return f.selected;})[0];
632
644
  var wi=first?first.i:0;
633
645
  var nSel=t.files.filter(function(f){return f.selected;}).length;
@@ -636,8 +648,8 @@ var hint=(t.ready&&nSel===0)?'<div class=note>Отметь серии галоч
636
648
  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>'+(t.files.length>1?'<div class=picks><button data-act=all data-id='+t.id+'>выбрать все</button><button data-act=none data-id='+t.id+'>снять все</button></div>':'')+'<div class=files>'+files+'</div>'+hint+'<div class=acts><button data-act=vlc data-id='+t.id+'>▶ VLC'+(nSel?' ('+nSel+')':'')+'</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>';
637
649
  }).join('');
638
650
  [].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});};});
639
- [].forEach.call(box.querySelectorAll('button[data-act]'),function(b){b.onclick=function(){var id=+b.getAttribute('data-id');var act=b.getAttribute('data-act');
640
- 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);});}
651
+ [].forEach.call(box.querySelectorAll('button[data-act]'),function(b){b.onclick=function(ev){ev.preventDefault();ev.stopPropagation();var id=+b.getAttribute('data-id');var act=b.getAttribute('data-act');
652
+ if(act==='vlc'){var one=b.getAttribute('data-i');var label=b.textContent;b.textContent='…';var body={id:id};if(one!=null&&one!=='')body.index=+one;api('/api/vlc',body).then(function(d){b.textContent=d.error?('⚠ '+String(d.error).slice(0,80)):label;});}
641
653
  if(act==='web'){window.open(BASE+'/view/'+id+'/'+b.getAttribute('data-i'));}
642
654
  if(act==='del'){api('/api/remove',{id:id}).then(poll);}
643
655
  if(act==='all'||act==='none'){var idx=act==='all'?[].map.call(box.querySelectorAll('.fin input[data-id="'+id+'"]'),function(x){return +x.getAttribute('data-i');}):[];api('/api/select',{id:id,indices:idx}).then(poll);}
@@ -657,7 +669,202 @@ function readBody(req) {
657
669
  });
658
670
  }
659
671
 
672
+ const HLS_SEG = 4;
673
+
674
+ function hlsSegName(i) {
675
+ return 'seg-' + String(i).padStart(5, '0') + '.ts';
676
+ }
677
+
678
+ function listHlsSegs(dir) {
679
+ try {
680
+ return fs.readdirSync(dir)
681
+ .filter(n => /^seg-\d+\.ts$/.test(n))
682
+ .map(n => Number(n.slice(4, -3)))
683
+ .filter(n => Number.isFinite(n))
684
+ .sort((a, b) => a - b);
685
+ } catch { return []; }
686
+ }
687
+
688
+ function hlsSegComplete(dir, index, job) {
689
+ const fp = path.join(dir, hlsSegName(index));
690
+ let st;
691
+ try { st = fs.statSync(fp); } catch { return false; }
692
+ if (st.size < 188) return false;
693
+ try { if (fs.statSync(path.join(dir, hlsSegName(index + 1))).size > 0) return true; } catch {}
694
+ return !!(job && job.ended && !job.aborted);
695
+ }
696
+
697
+ async function probeSource(ffprobe, url) {
698
+ const { stdout } = await execFileP(ffprobe, [
699
+ '-hide_banner', '-loglevel', 'error', '-print_format', 'json',
700
+ '-show_streams', '-show_format', '-probesize', '5000000', '-analyzeduration', '5000000', url,
701
+ ], { maxBuffer: 8 * 1048576, timeout: 25000 });
702
+ const j = JSON.parse(stdout);
703
+ let ai = 0, si = 0;
704
+ const tracks = [], subs = [];
705
+ for (const s of (j.streams || [])) {
706
+ const lang = (s.tags && s.tags.language) || '';
707
+ const title = (s.tags && s.tags.title) || '';
708
+ if (s.codec_type === 'audio') tracks.push({ a: ai++, lang, title, codec: s.codec_name });
709
+ else if (s.codec_type === 'subtitle') subs.push({ s: si++, lang, title, codec: s.codec_name });
710
+ }
711
+ const vs = (j.streams || []).find(s => s.codec_type === 'video');
712
+ const duration = Number(j.format && j.format.duration) || (vs && Number(vs.duration)) || 0;
713
+ return {
714
+ duration: Number.isFinite(duration) ? duration : 0,
715
+ vcodec: vs ? vs.codec_name : '',
716
+ width: vs ? (vs.width || 0) : 0,
717
+ height: vs ? (vs.height || 0) : 0,
718
+ tracks,
719
+ subs,
720
+ };
721
+ }
722
+
723
+ async function getProbe(hls, ffprobe, key, url) {
724
+ const hit = hls.probe.get(key);
725
+ if (hit && hit.duration > 0) return hit;
726
+ let pending = hls.inflight.get(key);
727
+ if (!pending) {
728
+ pending = probeSource(ffprobe, url).then(meta => {
729
+ hls.inflight.delete(key);
730
+ if (meta && meta.duration > 0) hls.probe.set(key, meta);
731
+ return meta;
732
+ }).catch(err => {
733
+ hls.inflight.delete(key);
734
+ throw err;
735
+ });
736
+ hls.inflight.set(key, pending);
737
+ }
738
+ return pending;
739
+ }
740
+
741
+ function killHlsJob(job, aborted) {
742
+ if (!job || job.aborted || job.ended) return;
743
+ job.aborted = !!aborted;
744
+ try { job.proc.kill('SIGKILL'); } catch {}
745
+ if (!aborted) return;
746
+ const segs = listHlsSegs(job.dir);
747
+ if (!segs.length) return;
748
+ const tail = segs[segs.length - 1];
749
+ if (!fs.existsSync(path.join(job.dir, hlsSegName(tail + 1)))) {
750
+ try { fs.rmSync(path.join(job.dir, hlsSegName(tail))); } catch {}
751
+ }
752
+ }
753
+
754
+ function jobCovers(job, index) {
755
+ if (!job || job.ended || job.aborted || job.proc.killed) return false;
756
+ if (job.from > index) return false;
757
+ const segs = listHlsSegs(job.dir);
758
+ const tip = segs.length ? segs[segs.length - 1] : job.from;
759
+ return index <= Math.max(job.from, tip) + 8;
760
+ }
761
+
762
+ function ensureHlsJob(hls, key, dir, ffmpeg, url, fromIndex, audioIndex, meta) {
763
+ const cur = hls.procs.get(key);
764
+ if (jobCovers(cur, fromIndex)) { cur.touched = Date.now(); return cur; }
765
+ if (cur) killHlsJob(cur, true);
766
+ const target = fromIndex * HLS_SEG;
767
+ const pre = target > 8 ? target - 8 : 0;
768
+ const post = target - pre;
769
+ const args = ['-y', '-hide_banner', '-loglevel', 'warning', '-nostdin',
770
+ '-rw_timeout', '30000000', '-reconnect', '1', '-reconnect_streamed', '1', '-reconnect_delay_max', '5'];
771
+ if (pre > 0.05) args.push('-ss', pre.toFixed(3));
772
+ args.push('-i', url);
773
+ if (post > 0.05) args.push('-ss', post.toFixed(3));
774
+ args.push('-map', '0:v:0');
775
+ const hasAudio = meta.tracks.length > 0;
776
+ const a = hasAudio ? Math.max(0, Math.min(audioIndex, meta.tracks.length - 1)) : 0;
777
+ if (hasAudio) args.push('-map', '0:a:' + a);
778
+ // Копия исходного видео режется только по ключевым кадрам, и длина куска
779
+ // перестаёт совпадать со шкалой. В браузере всегда H.264 с кадром каждые 4 с.
780
+ // VLC по-прежнему открывает исходный файл как есть.
781
+ const big = (meta.height || 0) > 1080 || (meta.width || 0) > 1920;
782
+ const scale = big
783
+ ? 'scale=trunc(min(1920\\,iw)/2)*2:trunc(min(1080\\,ih)/2)*2'
784
+ : 'scale=trunc(iw/2)*2:trunc(ih/2)*2';
785
+ args.push(
786
+ '-c:v', 'libx264', '-preset', 'ultrafast', '-tune', 'zerolatency',
787
+ '-profile:v', 'main', '-crf', '22',
788
+ '-vf', scale + ',format=yuv420p',
789
+ '-force_key_frames', 'expr:gte(t,n_forced*' + HLS_SEG + ')'
790
+ );
791
+ if (hasAudio) args.push('-c:a', 'aac', '-b:a', '160k', '-ac', '2', '-af', 'aresample=async=1:first_pts=0');
792
+ else args.push('-an');
793
+ args.push(
794
+ '-avoid_negative_ts', 'make_zero',
795
+ '-muxdelay', '0', '-muxpreload', '0',
796
+ '-max_muxing_queue_size', '9999',
797
+ '-f', 'segment', '-segment_time', String(HLS_SEG),
798
+ '-segment_format', 'mpegts',
799
+ '-reset_timestamps', '1',
800
+ '-break_non_keyframes', '1',
801
+ '-segment_start_number', String(fromIndex),
802
+ path.join(dir, 'seg-%05d.ts')
803
+ );
804
+ const proc = spawn(ffmpeg, args, { stdio: ['ignore', 'ignore', 'pipe'] });
805
+ const job = { proc, dir, from: fromIndex, touched: Date.now(), ended: false, aborted: false, err: '' };
806
+ proc.stderr.on('data', d => {
807
+ const s = String(d);
808
+ if (job.err.length < 500) job.err += s;
809
+ if (!job.logged) { job.logged = true; console.error('hls:', s.trim().slice(0, 240)); }
810
+ });
811
+ proc.on('exit', () => { job.ended = true; });
812
+ proc.on('error', e => { job.ended = true; job.err += e.message; });
813
+ hls.procs.set(key, job);
814
+ return job;
815
+ }
816
+
817
+ function vodPlaylist(duration, audio) {
818
+ const n = Math.max(1, Math.ceil(duration / HLS_SEG - 1e-6));
819
+ let pl = '#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:' + HLS_SEG + '\n#EXT-X-PLAYLIST-TYPE:VOD\n#EXT-X-MEDIA-SEQUENCE:0\n';
820
+ const qs = '?a=' + encodeURIComponent(String(audio));
821
+ for (let i = 0; i < n; i++) {
822
+ const segDur = Math.min(HLS_SEG, Math.max(0.001, duration - i * HLS_SEG));
823
+ pl += '#EXTINF:' + segDur.toFixed(3) + ',\n' + hlsSegName(i) + qs + '\n';
824
+ }
825
+ pl += '#EXT-X-ENDLIST\n';
826
+ return pl;
827
+ }
828
+
829
+ async function waitForSeg(dir, index, job, timeoutMs) {
830
+ const deadline = Date.now() + timeoutMs;
831
+ while (Date.now() < deadline) {
832
+ if (hlsSegComplete(dir, index, job)) return path.join(dir, hlsSegName(index));
833
+ if (job && job.ended && !job.aborted && job.err && !fs.existsSync(path.join(dir, hlsSegName(index)))) return null;
834
+ await sleep(200);
835
+ }
836
+ return null;
837
+ }
838
+
839
+ function dropHlsFor(hls, prefix) {
840
+ for (const [k, h] of [...hls.procs]) {
841
+ if (!k.startsWith(prefix)) continue;
842
+ killHlsJob(h, true);
843
+ hls.procs.delete(k);
844
+ try { fs.rmSync(h.dir, { recursive: true, force: true }); } catch {}
845
+ }
846
+ for (const k of [...hls.probe.keys()]) if (k.startsWith(prefix)) hls.probe.delete(k);
847
+ for (const k of [...hls.inflight.keys()]) if (k.startsWith(prefix)) hls.inflight.delete(k);
848
+ }
849
+
660
850
  function makeWebServer(client, st, opts, cacheDir, base) {
851
+ const hls = {
852
+ procs: new Map(),
853
+ root: path.join(os.tmpdir(), 'wtui-hls-' + process.pid),
854
+ probe: new Map(),
855
+ inflight: new Map(),
856
+ };
857
+ st.hls = hls;
858
+ const sweep = setInterval(() => {
859
+ const now = Date.now();
860
+ for (const [k, h] of hls.procs) {
861
+ if (now - h.touched < 180000) continue;
862
+ killHlsJob(h, true);
863
+ hls.procs.delete(k);
864
+ try { fs.rmSync(h.dir, { recursive: true, force: true }); } catch {}
865
+ }
866
+ }, 30000);
867
+ sweep.unref();
661
868
  return http.createServer((req, res) => {
662
869
  Promise.resolve(webReq(req, res, client, st, opts, cacheDir, base)).catch(e => {
663
870
  if (!res.headersSent) { res.statusCode = 500; res.end('error'); }
@@ -669,9 +876,7 @@ function makeWebServer(client, st, opts, cacheDir, base) {
669
876
  async function webReq(req, res, client, st, opts, cacheDir, base) {
670
877
  const u = new URL(req.url, 'http://127.0.0.1');
671
878
  const ensureSel = (e, i) => { const f = e?.t.files[i]; if (f && !e.sel.has(i)) { e.sel.add(i); try { f.select(); } catch {} } };
672
- const hlsProcs = new Map();
673
- const HLS_ROOT = path.join(os.tmpdir(), 'wtui-hls-' + process.pid);
674
- setInterval(() => { const now = Date.now(); for (const [k, h] of hlsProcs) if (now - h.touched > 180000) { try { h.proc.kill('SIGKILL'); } catch {} hlsProcs.delete(k); try { fs.rmSync(h.dir, { recursive: true, force: true }); } catch {} } } , 30000).unref();
879
+ const hls = st.hls;
675
880
  let p = u.pathname;
676
881
  if (base) {
677
882
  if (p !== base && !p.startsWith(base + '/')) { res.statusCode = 404; res.end('not found'); return; }
@@ -756,7 +961,14 @@ async function webReq(req, res, client, st, opts, cacheDir, base) {
756
961
  const b = await readBody(req);
757
962
  const e = st.torrents.get(Number(b.id));
758
963
  if (!e) return json(404, { error: 'нет торрента' });
759
- const idx = [...e.sel];
964
+ let idx;
965
+ if (b.index != null && b.index !== '' && Number.isFinite(Number(b.index))) {
966
+ const i = Number(b.index);
967
+ if (!e.t.files[i]) return json(400, { error: 'нет файла' });
968
+ e.sel.add(i);
969
+ try { e.t.files[i].select(); } catch {}
970
+ idx = [i];
971
+ } else idx = [...e.sel];
760
972
  if (!idx.length) return json(400, { error: 'выбери файлы' });
761
973
  const vlcBin = detectVLCPath();
762
974
  if (!vlcBin) return json(500, { error: 'VLC не найден' });
@@ -773,7 +985,21 @@ async function webReq(req, res, client, st, opts, cacheDir, base) {
773
985
  const id = Number(b.id);
774
986
  const e = st.torrents.get(id);
775
987
  if (e) { st.torrents.delete(id); try { await new Promise(r => e.t.destroy(r)); } catch {} }
776
- for (const [k, h] of hlsProcs) if (k.startsWith(id + '-')) { try { h.proc.kill('SIGKILL'); } catch {} hlsProcs.delete(k); try { fs.rmSync(h.dir, { recursive: true, force: true }); } catch {} }
988
+ dropHlsFor(hls, id + '-');
989
+ return json(200, { ok: true });
990
+ }
991
+ if (p === '/api/clear-cache' && req.method === 'POST') {
992
+ await readBody(req).catch(() => ({}));
993
+ for (const h of hls.procs.values()) killHlsJob(h, true);
994
+ hls.procs.clear();
995
+ hls.probe.clear();
996
+ hls.inflight.clear();
997
+ try { fs.rmSync(hls.root, { recursive: true, force: true }); } catch {}
998
+ st.torrents.clear();
999
+ st.pending.length = 0;
1000
+ for (const t of [...(client.torrents || [])]) { try { await new Promise(r => t.destroy({ destroyStore: true }, r)); } catch {} }
1001
+ try { await fsp.rm(cacheDir, { recursive: true, force: true }); } catch {}
1002
+ await ensureDir(cacheDir);
777
1003
  return json(200, { ok: true });
778
1004
  }
779
1005
  const mS = /^\/s\/(\d+)\/(\d+)$/.exec(p);
@@ -794,31 +1020,10 @@ async function webReq(req, res, client, st, opts, cacheDir, base) {
794
1020
  if (!ffprobe) return json(200, { ffmpeg: false, tracks: [] });
795
1021
  const url = 'http://127.0.0.1:' + st.port + base + '/s/' + mT[1] + '/' + mT[2];
796
1022
  try {
797
- const { stdout } = await execFileP(ffprobe, ['-hide_banner', '-loglevel', 'error', '-print_format', 'json', '-show_streams', url], { maxBuffer: 8 * 1048576, timeout: 25000 });
798
- const j = JSON.parse(stdout);
799
- let ai = 0, si = 0;
800
- const tracks = [], subs = [];
801
- for (const s of (j.streams || [])) {
802
- const lang = (s.tags && s.tags.language) || '';
803
- const title = (s.tags && s.tags.title) || '';
804
- if (s.codec_type === 'audio') tracks.push({ a: ai++, lang, title, codec: s.codec_name });
805
- else if (s.codec_type === 'subtitle') subs.push({ s: si++, lang, title, codec: s.codec_name });
806
- }
807
- const vs = (j.streams || []).find(s => s.codec_type === 'video');
808
- let vc = '';
809
- if (vs) {
810
- if (vs.codec_name === 'h264') {
811
- const P = { Baseline: '42', Main: '4D', Extended: '58', High: '64', 'High 10': '6E', 'High 4:2:2': '76', 'High 4:4:4': 'F4' };
812
- const idc = P[vs.profile] || '64';
813
- const lvl = Number.isFinite(vs.level) ? ('0' + Math.round(vs.level / 10).toString(16)).slice(-2) : '28';
814
- vc = 'avc1.' + idc + '00' + lvl;
815
- } else if (vs.codec_name === 'hevc') {
816
- vc = Number.isFinite(vs.level) ? 'hvc1.1.6.L' + Math.round(vs.level / 10) + '.B0' : 'hvc1.1.6.L120.B0';
817
- } else if (vs.codec_name === 'av1') vc = 'av01.0.04M.08';
818
- else if (vs.codec_name === 'vp9') vc = 'vp09.00.10.08';
819
- }
820
- return json(200, { ffmpeg: true, tracks, subs, vc });
821
- } catch (e2) { return json(200, { ffmpeg: true, tracks: [], subs: [], retry: true }); }
1023
+ const meta = await getProbe(hls, ffprobe, mT[1] + '-' + mT[2], url);
1024
+ if (!meta || !(meta.duration > 0)) return json(200, { ffmpeg: true, tracks: meta?.tracks || [], subs: meta?.subs || [], retry: true });
1025
+ return json(200, { ffmpeg: true, tracks: meta.tracks, subs: meta.subs, duration: meta.duration, vcodec: meta.vcodec });
1026
+ } catch { return json(200, { ffmpeg: true, tracks: [], subs: [], retry: true }); }
822
1027
  }
823
1028
  const mR = /^\/remux\/(\d+)\/(\d+)$/.exec(p);
824
1029
  if (mR && req.method === 'HEAD') { res.statusCode = 200; res.setHeader('Content-Type', 'video/mp4'); res.end(); return; }
@@ -871,82 +1076,93 @@ async function webReq(req, res, client, st, opts, cacheDir, base) {
871
1076
  if (!f) { res.statusCode = 404; res.end('not found'); return; }
872
1077
  ensureSel(e, Number(mH[2]));
873
1078
  const ffmpeg = detectFFBin('ffmpeg');
874
- if (!ffmpeg) { res.statusCode = 500; res.end('ffmpeg не найден'); return; }
1079
+ const ffprobe = detectFFBin('ffprobe');
1080
+ if (!ffmpeg || !ffprobe) { res.statusCode = 500; res.end('ffmpeg не найден'); return; }
875
1081
  const name = mH[3];
876
1082
  if (name.includes('..')) { res.statusCode = 400; res.end('bad'); return; }
877
- const a = u.searchParams.get('a') || '0';
878
- const s1 = u.searchParams.get('si');
879
- const si = (s1 === null || s1 === '' || !Number.isFinite(Number(s1))) ? -1 : Math.max(0, Math.min(7, Number(s1)));
880
- const t = Math.max(0, Math.round(Number(u.searchParams.get('t')) || 0));
881
- const key = mH[1] + '-' + mH[2] + '-' + a + '-' + (si >= 0 ? 's' + si : 'n');
882
- const dir = path.join(HLS_ROOT, key);
883
- const hit0 = hlsProcs.get(key); if (hit0) hit0.touched = Date.now();
884
- const hit1 = hlsProcs.get(key + '-vtt'); if (hit1) hit1.touched = Date.now();
885
- const isSeg = /^seg-\d+\.ts$/.test(name);
886
- const need = !isSeg && (!hlsProcs.has(key) || Math.abs((hlsProcs.get(key).start || 0) - t) > 3);
887
- if (need) {
888
- for (const [k, h] of hlsProcs) if (k.startsWith(key) || k.startsWith(mH[1] + '-' + mH[2] + '-')) { try { h.proc.kill('SIGKILL'); } catch {} hlsProcs.delete(k); }
889
- await fsp.rm(dir, { recursive: true, force: true });
890
- await ensureDir(dir);
891
- const url = 'http://127.0.0.1:' + st.port + base + '/s/' + mH[1] + '/' + mH[2];
892
- const pre = t > 12 ? t - 10 : t;
893
- const post = t > 12 ? 10 : 0;
894
- const args = ['-y', '-hide_banner', '-loglevel', 'error', '-rw_timeout', '30000000', '-reconnect', '1', '-reconnect_streamed', '1', '-reconnect_delay_max', '5'];
895
- if (pre > 0) args.push('-ss', String(pre));
896
- args.push('-i', url);
897
- if (post > 0) args.push('-ss', String(post));
898
- args.push('-map', '0:v:0', '-map', '0:a:' + a, '-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k', '-af', 'aresample=async=1:first_pts=0', '-max_muxing_queue_size', '9999', '-f', 'segment', '-segment_time', '4', '-reset_timestamps', '1', '-segment_start_number', '0', dir + '/seg-%03d.ts');
899
- const proc = spawn(ffmpeg, args, { stdio: ['ignore', 'ignore', 'pipe'] });
900
- let ffErr = 0;
901
- proc.stderr.on('data', d => { if (ffErr < 3) { console.error('hls:', String(d).trim().slice(0, 200)); ffErr++; } });
902
- proc.on('exit', () => { const cur = hlsProcs.get(key); if (cur && cur.proc === proc) hlsProcs.delete(key); });
903
- hlsProcs.set(key, { proc, dir, touched: Date.now(), start: t });
904
- if (si >= 0) {
905
- const subArgs = ['-y', '-hide_banner', '-loglevel', 'error', '-rw_timeout', '30000000', '-reconnect', '1', '-reconnect_streamed', '1', '-reconnect_delay_max', '5'];
906
- if (pre > 0) subArgs.push('-ss', String(pre));
907
- subArgs.push('-i', url);
908
- if (post > 0) subArgs.push('-ss', String(post));
909
- subArgs.push('-map', '0:s:' + si, '-c:s', 'webvtt', '-f', 'webvtt', dir + '/sub.vtt');
910
- const sp = spawn(ffmpeg, subArgs, { stdio: ['ignore', 'ignore', 'pipe'] });
911
- let sf = 0;
912
- sp.stderr.on('data', d => { if (sf < 2) { console.error('vtt:', String(d).trim().slice(0, 150)); sf++; } });
913
- sp.on('exit', () => { const cur = hlsProcs.get(key + '-vtt'); if (cur && cur.proc === sp) hlsProcs.delete(key + '-vtt'); });
914
- hlsProcs.set(key + '-vtt', { proc: sp, dir, touched: Date.now() });
915
- }
1083
+ const aRaw = Number(u.searchParams.get('a') || '0');
1084
+ const audioAsk = Number.isFinite(aRaw) ? Math.max(0, Math.min(31, Math.floor(aRaw))) : 0;
1085
+ const url = 'http://127.0.0.1:' + st.port + base + '/s/' + mH[1] + '/' + mH[2];
1086
+ const pkey = mH[1] + '-' + mH[2];
1087
+ let meta = null;
1088
+ try { meta = await getProbe(hls, ffprobe, pkey, url); } catch { meta = null; }
1089
+ if (!meta || !(meta.duration > 0)) {
1090
+ res.statusCode = 503;
1091
+ res.setHeader('Retry-After', '2');
1092
+ res.setHeader('Cache-Control', 'no-store');
1093
+ res.end('not ready');
1094
+ return;
916
1095
  }
917
- const h = hlsProcs.get(key);
918
- if (h) h.touched = Date.now();
1096
+ const a = meta.tracks.length ? Math.min(audioAsk, meta.tracks.length - 1) : 0;
919
1097
  if (name === 'sub.vtt') {
920
- let b = '';
921
- try { b = fs.readFileSync(path.join(dir, 'sub.vtt'), 'utf8'); } catch {}
922
- const base0 = h ? (h.start || 0) : 0;
923
- const shift = s => { const m = /^(\d+):(\d+):([0-9.]+)$/.exec(s); if (!m) return s; let x = (+m[1]) * 3600 + (+m[2]) * 60 + parseFloat(m[3]) - base0; if (x < 0) x = 0; const hh = Math.floor(x / 3600), mm = Math.floor((x % 3600) / 60), ss = (x % 60).toFixed(3).padStart(6, '0'); return (hh ? hh + ':' : '') + String(mm).padStart(2, '0') + ':' + ss; };
924
- const outv = b.replace(/(\d[\d:.]*) --> (\d[\d:.]*)/g, (all, x, y) => shift(x.trim()) + ' --> ' + shift(y.trim()));
1098
+ const siRaw = Number(u.searchParams.get('si'));
925
1099
  res.setHeader('Content-Type', 'text/vtt; charset=utf-8');
926
- res.end(outv || 'WEBVTT\n\n');
1100
+ res.setHeader('Cache-Control', 'no-store');
1101
+ if (!Number.isFinite(siRaw) || siRaw < 0) { res.end('WEBVTT\n\n'); return; }
1102
+ const si = Math.min(15, Math.floor(siRaw));
1103
+ const sdir = path.join(hls.root, pkey + '-sub-' + si);
1104
+ const dest = path.join(sdir, 'sub.vtt');
1105
+ const skey = pkey + '-sub-' + si;
1106
+ await ensureDir(sdir);
1107
+ if (!fs.existsSync(dest)) {
1108
+ let subJob = hls.procs.get(skey);
1109
+ if (!subJob || subJob.ended) {
1110
+ const sp = spawn(ffmpeg, ['-y', '-hide_banner', '-loglevel', 'error', '-nostdin', '-i', url, '-map', '0:s:' + si, '-c:s', 'webvtt', '-f', 'webvtt', dest], { stdio: ['ignore', 'ignore', 'pipe'] });
1111
+ subJob = { proc: sp, dir: sdir, from: 0, touched: Date.now(), ended: false, aborted: false, err: '' };
1112
+ sp.on('exit', () => { subJob.ended = true; });
1113
+ hls.procs.set(skey, subJob);
1114
+ }
1115
+ subJob.touched = Date.now();
1116
+ const deadline = Date.now() + 20000;
1117
+ while (Date.now() < deadline && !fs.existsSync(dest)) {
1118
+ if (subJob.ended) break;
1119
+ await sleep(200);
1120
+ }
1121
+ }
1122
+ let sb = '';
1123
+ try { sb = fs.readFileSync(dest, 'utf8'); } catch {}
1124
+ res.end(sb || 'WEBVTT\n\n');
927
1125
  return;
928
1126
  }
929
- if (/^seg-\d+\.ts$/.test(name)) {
930
- const fp = path.join(dir, name);
931
- let b = null;
932
- try { b = fs.readFileSync(fp); } catch {}
933
- if (!b) { res.statusCode = 404; res.end('no segment'); return; }
934
- res.setHeader('Content-Type', 'video/mp2t');
1127
+ const key = pkey + '-a' + a;
1128
+ const dir = path.join(hls.root, key);
1129
+ if (name === 'index.m3u8' || name === 'master.m3u8') {
1130
+ const job = hls.procs.get(key);
1131
+ if (job) job.touched = Date.now();
1132
+ res.setHeader('Content-Type', 'application/vnd.apple.mpegurl');
935
1133
  res.setHeader('Cache-Control', 'no-store');
936
- res.end(b);
1134
+ res.end(vodPlaylist(meta.duration, a));
937
1135
  return;
938
1136
  }
939
- let segs = [];
940
- try { segs = fs.readdirSync(dir).filter(x => /^seg-\d+\.ts$/.test(x)).sort((x, y) => (+x.slice(4)) - (+y.slice(4))); } catch {}
941
- segs = segs.slice(0, -1); // последний сегмент ещё пишется
942
- const durs = segs.map(() => 4);
943
- const maxD = 6;
944
- let pl = '#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:' + maxD + '\n#EXT-X-PLAYLIST-TYPE:EVENT\n#EXT-X-MEDIA-SEQUENCE:0\n';
945
- const qs = '?a=' + encodeURIComponent(a) + (si >= 0 ? '&si=' + si : '&si=') + '&t=' + t;
946
- for (let i = 0; i < segs.length; i++) pl += '#EXTINF:' + durs[i].toFixed(3) + ',\n' + segs[i] + qs + '\n';
947
- res.setHeader('Content-Type', 'application/vnd.apple.mpegurl');
1137
+ const segM = /^seg-(\d+)\.ts$/.exec(name);
1138
+ if (!segM) { res.statusCode = 404; res.end('no'); return; }
1139
+ const index = Number(segM[1]);
1140
+ const nSeg = Math.max(1, Math.ceil(meta.duration / HLS_SEG - 1e-6));
1141
+ if (index < 0 || index >= nSeg) { res.statusCode = 404; res.end('range'); return; }
1142
+ await ensureDir(dir);
1143
+ let fp = null;
1144
+ const existing = hls.procs.get(key);
1145
+ if (hlsSegComplete(dir, index, existing)) {
1146
+ fp = path.join(dir, hlsSegName(index));
1147
+ if (existing) existing.touched = Date.now();
1148
+ } else {
1149
+ const job = ensureHlsJob(hls, key, dir, ffmpeg, url, index, a, meta);
1150
+ job.touched = Date.now();
1151
+ fp = await waitForSeg(dir, index, job, 55000);
1152
+ }
1153
+ if (!fp) {
1154
+ res.statusCode = 503;
1155
+ res.setHeader('Retry-After', '1');
1156
+ res.setHeader('Cache-Control', 'no-store');
1157
+ const job = hls.procs.get(key);
1158
+ res.end(job && job.err ? job.err.slice(0, 300) : 'segment timeout');
1159
+ return;
1160
+ }
1161
+ const buf = fs.readFileSync(fp);
1162
+ res.setHeader('Content-Type', 'video/mp2t');
948
1163
  res.setHeader('Cache-Control', 'no-store');
949
- res.end(pl);
1164
+ res.setHeader('Content-Length', buf.length);
1165
+ res.end(buf);
950
1166
  return;
951
1167
  }
952
1168
  const mV = /^\/view\/(\d+)\/(\d+)$/.exec(p);
@@ -955,48 +1171,63 @@ async function webReq(req, res, client, st, opts, cacheDir, base) {
955
1171
  const f = e?.t.files[Number(mV[2])];
956
1172
  if (!f) { res.statusCode = 404; res.end('not found'); return; }
957
1173
  ensureSel(e, Number(mV[2]));
958
- const webFriendly = /\.(mp4|m4v|webm|m4a)$/i.test(f.name);
959
1174
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
960
- res.end(`<!doctype html><meta charset=utf-8><title>${esc(f.name)}</title>
1175
+ res.end(`<!doctype html><meta charset=utf-8><title>${esc(f.name)}</title>
961
1176
  <style>body{margin:0;background:#000;color:#ddd;font:14px system-ui;display:flex;flex-direction:column;height:100vh;box-sizing:border-box}
962
1177
  .bar{display:flex;gap:10px;align-items:center;padding:6px 10px;flex-wrap:wrap}
963
1178
  select{background:#1b1b1f;color:#ddd;border:1px solid #333;border-radius:6px;padding:4px;max-width:340px}
1179
+ #tm{font-variant-numeric:tabular-nums;min-width:9em}
964
1180
  video{flex:1;width:100%;min-height:0;background:#000}
965
- #ov{position:fixed;inset:0;display:none;align-items:center;justify-content:center;background:rgba(0,0,0,.45);cursor:pointer;font-size:72px;color:#fff}</style>
966
- <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>
1181
+ #n{color:#9ab}
1182
+ #ov{position:fixed;inset:0;display:none;align-items:center;justify-content:center;background:rgba(0,0,0,.45);cursor:pointer;font-size:28px;color:#fff}</style>
1183
+ <div class=bar><b>${esc(f.name)}</b><select id=a style=display:none></select><select id=s style=display:none></select><span id=tm>00:00 / --:--</span><span id=n></span></div>
967
1184
  <video id=v controls autoplay playsinline></video>
968
- <div id=ov>▶</div>
1185
+ <div id=ov>▶ со звуком</div>
969
1186
  <script src="${base}/hls.min.js"></script>
970
1187
  <script>
971
1188
  var B=${JSON.stringify(base)},ID=${mV[1]},I=${mV[2]};
972
- var v=document.getElementById("v"),aS=document.getElementById("a"),sS=document.getElementById("s"),n=document.getElementById("n"),ov=document.getElementById("ov");
973
- ov.onclick=function(){v.play().catch(function(){});ov.style.display="none"};
974
- var tries=0,hls=null,cues=[],subUrl="";
1189
+ var v=document.getElementById("v"),aS=document.getElementById("a"),sS=document.getElementById("s"),n=document.getElementById("n"),tm=document.getElementById("tm"),ov=document.getElementById("ov");
1190
+ v.muted=false;v.volume=1;
1191
+ ov.onclick=function(){v.muted=false;v.volume=1;v.play().catch(function(){});ov.style.display="none"};
1192
+ var tries=0,player=null,cues=[],subUrl="",armed=false;
1193
+ function fmt(t){if(!isFinite(t)||t<0)return"--:--";t=Math.floor(t);var s=t%60,m=Math.floor(t/60)%60,h=Math.floor(t/3600);function z(n){return (n<10?"0":"")+n}return h?h+":"+z(m)+":"+z(s):z(m)+":"+z(s)}
1194
+ function tick(){tm.textContent=fmt(v.currentTime)+" / "+fmt(v.duration)}
975
1195
  var cueDiv=document.createElement("div");cueDiv.style.cssText="position:fixed;left:5%;right:5%;bottom:8%;text-align:center;color:#fff;font-size:clamp(14px,2.5vw,28px);text-shadow:0 1px 4px #000;pointer-events:none;z-index:5";document.body.appendChild(cueDiv);
976
1196
  var parseVtt=function(txt){var NL=String.fromCharCode(10);var C=[];txt.split(NL+NL).forEach(function(b){var m=b.match(/([0-9]+):([0-9]+):([0-9]+).([0-9]+) --> ([0-9]+):([0-9]+):([0-9]+).([0-9]+)/);if(!m)return;var t=function(h,mi,s,ms){return h*3600+mi*60+s+parseFloat("0."+ms)};var x=b.split(NL).filter(function(l){return l&&l.indexOf("-->")<0&&l.indexOf("WEBVTT")<0&&!/^[a-zA-Z-]+:/.test(l)}).join("<br>");C.push([t(+m[1],+m[2],+m[3],m[4]),t(+m[5],+m[6],+m[7],m[8]),x])});cues=C};
977
1197
  var pollSub=function(){if(!subUrl)return;fetch(subUrl).then(function(r){return r.text()}).then(parseVtt).catch(function(){})};
978
- v.addEventListener("timeupdate",function(){var t=v.currentTime,a="";for(var i=cues.length-1;i>=0;i--){if(t>=cues[i][0]&&t<=cues[i][1]){a=cues[i][2];break}}cueDiv.innerHTML=a});
1198
+ v.addEventListener("timeupdate",function(){tick();var t=v.currentTime,a="";for(var i=cues.length-1;i>=0;i--){if(t>=cues[i][0]&&t<=cues[i][1]){a=cues[i][2];break}}cueDiv.innerHTML=a});
1199
+ v.addEventListener("durationchange",tick);
1200
+ v.addEventListener("seeked",tick);
979
1201
  setInterval(pollSub,20000);
980
- var play=function(u){if(hls){try{hls.destroy()}catch(e){}hls=null}
981
- if(window.Hls&&Hls.isSupported()){hls=new Hls({enableWorker:true,lowLatencyMode:false,liveSyncDurationCount:3});hls.loadSource(u);hls.attachMedia(v);
982
- hls.on(Hls.Events.MANIFEST_PARSED,function(){v.currentTime=0;v.play().catch(function(){ov.style.display="flex"})});
983
- hls.on(Hls.Events.ERROR,function(ev,d){if(d.fatal)n.textContent="⚠ HLS: "+d.type})}
984
- else{v.src=u;v.play().catch(function(){ov.style.display="flex"})}};
1202
+ var play=function(u,at){var want=(isFinite(at)&&at>0.4)?at:0;
1203
+ if(player){try{player.destroy()}catch(e){}player=null}
1204
+ var start=function(){v.muted=false;v.volume=1;if(want>0.4){try{v.currentTime=want}catch(e){}}v.play().catch(function(){ov.style.display="flex"})};
1205
+ if(window.Hls&&Hls.isSupported()){player=new Hls({enableWorker:true,lowLatencyMode:false,startPosition:want,maxBufferLength:24,manifestLoadingTimeOut:20000,fragLoadingTimeOut:60000,fragLoadingMaxRetry:8});
1206
+ player.loadSource(u);player.attachMedia(v);
1207
+ player.on(Hls.Events.MANIFEST_PARSED,start);
1208
+ player.on(Hls.Events.ERROR,function(ev,d){if(!d.fatal)return;if(d.type===Hls.ErrorTypes.NETWORK_ERROR){player.startLoad(v.currentTime||want);return}n.textContent="HLS: "+(d.details||d.type)})}
1209
+ else{var once=function(){v.removeEventListener("loadedmetadata",once);start()};v.addEventListener("loadedmetadata",once);v.src=u}};
985
1210
  var load=function(){fetch(B+"/api/tracks/"+ID+"/"+I).then(function(r){return r.json()}).then(function(d){
986
- if(d.retry&&tries++<15){n.textContent="⏳ готовлю файл к стриму…";setTimeout(load,4000);return;}
987
- if(d.error)n.textContent="⚠ "+d.error;
1211
+ if((d.retry||!(d.duration>0))&&tries++<20){n.textContent="готовлю файл к стриму…";setTimeout(load,2000);return;}
1212
+ if(!d.ffmpeg){n.textContent="нужен ffmpeg";return;}
1213
+ if(!(d.duration>0)){n.textContent="не вижу длительность файла";return;}
1214
+ var nativeHls=!!v.canPlayType("application/vnd.apple.mpegurl");
1215
+ if(!(window.Hls&&Hls.isSupported())&&!nativeHls){n.textContent="плеер браузера не загрузился";return;}
1216
+ tm.textContent="00:00 / "+fmt(d.duration);
988
1217
  var A=d.tracks||[],S=d.subs||[];
989
- if(!d.ffmpeg){return;}
1218
+ aS.innerHTML="";sS.innerHTML="";
990
1219
  var SI=S.filter(function(t){return ["subrip","ass","ssa","srt","mov_text","webvtt","text"].indexOf(t.codec)>=0}).map(function(t){return t.s});
991
- 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)});
992
- if(A.length>1){aS.value=A[0].a;aS.style.display=""}
1220
+ 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)});
1221
+ if(A.length){aS.value=String(A[0].a);aS.style.display="";if(A.length<2)aS.disabled=true}
993
1222
  if(SI.length){var o0=document.createElement("option");o0.value="";o0.textContent="без субтитров";sS.appendChild(o0);
994
- S.filter(function(t){return SI.indexOf(t.s)>=0}).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.value="";sS.style.display=""}
995
- var go=function(){var a=aS.style.display!==""?aS.value:0;var s=sS.style.display!==""?sS.value:"";var t=Math.max(0,Math.round(v.currentTime));cues=[];cueDiv.innerHTML="";subUrl=s!==""?B+"/hls/"+ID+"/"+I+"/sub.vtt?a="+a+"&si="+s+"&t="+t:"";if(subUrl)pollSub();play(B+"/hls/"+ID+"/"+I+"/master.m3u8?a="+a+(s!==""?"&si="+s:"")+(t>0?"&t="+t:""))};
996
- aS.onchange=go;sS.onchange=go;go();
997
- }).catch(function(){if(tries++<15)setTimeout(load,4000)})};
998
- v.addEventListener("playing",function(){n.textContent="";ov.style.display="none"});
999
- setInterval(function(){fetch(B+"/api/status").then(function(r){return r.json()}).then(function(l){var t=l.find(function(x){return x.id===ID});if(t&&v.readyState<2)n.textContent="⏬ "+t.speed+" · пиры "+t.peers+(t.progress>0?" · "+t.progress+"%":"")}).catch(function(){})},2000);
1223
+ S.filter(function(t){return SI.indexOf(t.s)>=0}).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.value="";sS.style.display=""}
1224
+ var go=function(keep){var a=aS.options.length?aS.value:0;var t=0;if(keep){t=v.currentTime;if(!isFinite(t)||t<0)t=0}play(B+"/hls/"+ID+"/"+I+"/index.m3u8?a="+encodeURIComponent(a),t)};
1225
+ aS.onchange=function(){go(true)};
1226
+ sS.onchange=function(){var s=sS.value;cues=[];cueDiv.innerHTML="";subUrl=s!==""?B+"/hls/"+ID+"/"+I+"/sub.vtt?si="+encodeURIComponent(s):"";if(subUrl)pollSub()};
1227
+ if(!armed){armed=true;go(false)}
1228
+ }).catch(function(){if(tries++<20)setTimeout(load,2000)})};
1229
+ v.addEventListener("playing",function(){n.textContent="";ov.style.display="none";tick()});
1230
+ setInterval(function(){fetch(B+"/api/status").then(function(r){return r.json()}).then(function(list){var t=list.find(function(x){return x.id===ID});if(t&&v.readyState<2)n.textContent=t.speed+" · пиры "+t.peers+(t.progress>0?" · "+t.progress+"%":"")}).catch(function(){})},2000);
1000
1231
  load();
1001
1232
  </script>`);
1002
1233
  return;
@@ -1007,14 +1238,14 @@ load();
1007
1238
  async function startWeb(opts, srcArg) {
1008
1239
  const cacheDir = DEFAULT_CACHE;
1009
1240
  await ensureDir(cacheDir);
1010
- const client = new WebTorrent({ destroyStoreOnDestroy: true });
1241
+ const client = new WebTorrent({ destroyStoreOnDestroy: false });
1011
1242
  const token = opts.lan ? crypto.randomBytes(8).toString('hex') : '';
1012
1243
  const base = token ? '/t/' + token : '';
1013
1244
  const st = { torrents: new Map(), nextId: 1, vlc: [], port: 0, pending: [], autoSelect: !!srcArg };
1014
1245
  client.on('torrent', t => {
1015
1246
  t.on('error', () => {});
1016
1247
  const id = st.pending.shift();
1017
- if (id == null) return;
1248
+ if (id == null) { try { t.destroy(); } catch {} return; }
1018
1249
  const dup = [...st.torrents.values()].find(e => e.t.infoHash === t.infoHash);
1019
1250
  if (dup) { try { t.destroy(); } catch {} return; }
1020
1251
  t.files.forEach(f => f.deselect()); // webtorrent по умолчанию выбирает все файлы
@@ -1046,10 +1277,9 @@ async function startWeb(opts, srcArg) {
1046
1277
  if (cleaning) return; cleaning = true;
1047
1278
  st.vlc.forEach(v => { try { v.kill(); } catch {} });
1048
1279
  try { server.close(); } catch {}
1049
- hlsProcs.forEach(h => { try { h.proc.kill('SIGKILL'); } catch {} });
1280
+ if (st.hls) for (const h of st.hls.procs.values()) { try { h.proc.kill('SIGKILL'); } catch {} }
1050
1281
  try { fs.rmSync(path.join(os.tmpdir(), 'wtui-hls-' + process.pid), { recursive: true, force: true }); } catch {}
1051
1282
  try { await new Promise(r => client.destroy(r)); } catch {}
1052
- if (!opts.keepCache) await cleanCache(cacheDir);
1053
1283
  process.exit(code);
1054
1284
  }
1055
1285
  process.on('SIGINT', () => shutdown(0));
@@ -1057,14 +1287,14 @@ async function startWeb(opts, srcArg) {
1057
1287
  const NET_NOISE = /premature|aborted|EPIPE|ECONNRESET|ETIMEDOUT|socket|stream|duplicate torrent/i;
1058
1288
  process.on('unhandledRejection', e => { const m = (e && (e.message || e)) + ''; if (!NET_NOISE.test(m)) console.error('unhandledRejection:', m); });
1059
1289
  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')); });
1060
- console.log('Ctrl+C — выход.');
1290
+ console.log('Ctrl+C — выход. Скачанное остаётся в ' + cacheDir + '. Стирает его только кнопка «Очистить кэш».');
1061
1291
  }
1062
1292
 
1063
1293
  // ---- Main ----
1064
1294
  async function main() {
1065
1295
  const { opts, srcArg } = parseCli();
1066
1296
  watchFolder();
1067
- if (opts.web) return startWeb(opts, srcArg);
1297
+ if (opts.web || (!srcArg && !opts.cli)) return startWeb(opts, srcArg);
1068
1298
 
1069
1299
  const source = srcArg || await promptSource();
1070
1300
  let cacheDir;