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.
- package/CHANGELOG.md +15 -0
- package/README.md +8 -6
- package/package.json +1 -1
- 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`.
|
|
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
|
-
Тот же процесс, тот же
|
|
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` |
|
|
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`.
|
|
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
|
|
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.
|
|
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|
|
|
27
|
+
torrent-online окно: поиск, magnet, файл, VLC или браузер
|
|
28
|
+
torrent-online <file.torrent|magnet:...> сразу в VLC, без окна
|
|
29
|
+
torrent-online --cli старое меню в терминале
|
|
26
30
|
|
|
27
31
|
Флаги:
|
|
28
|
-
--
|
|
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
|
|
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(
|
|
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=
|
|
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(
|
|
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
|
|
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 '<
|
|
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',
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
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
|
-
|
|
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
|
|
878
|
-
const
|
|
879
|
-
const
|
|
880
|
-
const
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
if (
|
|
934
|
-
res.setHeader('Content-Type', '
|
|
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(
|
|
1134
|
+
res.end(vodPlaylist(meta.duration, a));
|
|
937
1135
|
return;
|
|
938
1136
|
}
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
const
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
#
|
|
966
|
-
|
|
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
|
|
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
|
-
|
|
974
|
-
|
|
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){
|
|
981
|
-
if(
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
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++<
|
|
987
|
-
if(d.
|
|
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
|
-
|
|
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=
|
|
992
|
-
if(A.length
|
|
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=
|
|
995
|
-
var go=function(){var a=aS.
|
|
996
|
-
aS.onchange=go
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
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:
|
|
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
|
-
|
|
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;
|