torrent-online 1.7.9 → 1.7.10

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 CHANGED
@@ -2,10 +2,17 @@
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.10] — 2026-10-04
6
+ **RU:** На странице есть кнопка «Закрыть»: она гасит фоновый сервер и не удаляет кэш. Язык переключается кнопками RU и EN.
7
+ **EN:** The page has a Quit button. It stops the background server and does not delete the cache. The language switches with the RU and EN buttons.
8
+ - ➕ `POST /api/quit` останавливает процесс. Скачанное в `~/Movies/WebTorrent` остаётся
9
+ - ➕ Переключатель языка в шапке и в плеере. Выбор запоминается в браузере. По умолчанию русский
10
+
5
11
  ## [1.7.9] — 2026-10-04
6
12
  **RU:** В браузере звук больше не идёт рывками и не отстаёт от картинки.
7
13
  **EN:** In the browser, audio no longer stutters or drifts off the picture.
8
14
  - 🐛 Поток резался на MPEG-TS, и каждый кусок обнулял время. Браузер ставил звук не на ту секунду. Теперь фрагменты fMP4 с одной шкалой времени
15
+ - ➕ В релиз снова положен `TorrentOnline.app.zip`: двойной щелчок открывает страницу, окно Терминала не появляется
9
16
 
10
17
  ## [1.7.8] — 2026-10-04
11
18
  **RU:** `npm i -g` снова доходит до конца. Зависимость `ip-set` больше не вызывает `npx only-allow pnpm` и не роняет установку.
package/README.md CHANGED
@@ -47,7 +47,7 @@ node wtui.js "https://example.com/file.torrent"
47
47
  | `--port=<N>` | фиксированный порт (иначе авто-поиск 8123..10122) |
48
48
  | `--help` | справка |
49
49
 
50
- Кэш по умолчанию: `~/Movies/WebTorrent`. В окне он остаётся после выхода, стирает его кнопка «Очистить кэш». В режиме `--cli` папка по-прежнему удаляется при выходе, если нет `--keep-cache`.
50
+ Кэш по умолчанию: `~/Movies/WebTorrent`. В окне он остаётся после выхода и после кнопки «Закрыть». Стирает его только «Очистить кэш». Язык страницы — кнопки RU и EN (по умолчанию русский). В режиме `--cli` папка по-прежнему удаляется при выходе, если нет `--keep-cache`.
51
51
 
52
52
  ## Возможности (v1.4)
53
53
  - 🔎 Поиск торрентов прямо в меню: **Rutor.info (по-русски)**, TPB.party (по-английски), опционально 1337x API
@@ -63,7 +63,7 @@ node wtui.js "https://example.com/file.torrent"
63
63
  ```bash
64
64
  node wtui.js --web
65
65
  ```
66
- Тот же процесс, тот же порт. В браузере: поиск, поле magnet, кнопка файла `.torrent`, карточки с прогрессом, у каждого файла кнопки VLC и Браузер. VLC играет исходник. Браузер играет тот же поток со звуком: обычная шкала, перемотка и смена дорожки. С телефона в Wi-Fi — через `--lan` (токен в URL).
66
+ Тот же процесс, тот же порт. В браузере: поиск, поле magnet, кнопка файла `.torrent`, карточки с прогрессом, у каждого файла кнопки VLC и Браузер. VLC играет исходник. Браузер играет тот же поток со звуком: обычная шкала, перемотка и смена дорожки. Кнопки RU и EN меняют язык страницы и плеера. «Закрыть» останавливает сервер и не трогает кэш. С телефона в Wi-Fi — через `--lan` (токен в URL).
67
67
 
68
68
  ## Нативное приложение (Tauri, macOS)
69
69
  Окошко с иконкой, без терминала: над тем же веб-GUI.
@@ -80,7 +80,7 @@ cd desktop && ./build-mac.sh
80
80
  xattr -dr com.apple.quarantine /Applications/TorrentOnline.app
81
81
  open /Applications/TorrentOnline.app
82
82
  ```
83
- Свои сборка (`.app` открывает **Terminal** и запускает скрипт):
83
+ Своя сборка (двойной щелчок открывает страницу в браузере, Terminal не нужен):
84
84
  ```bash
85
85
  ./build/app.sh
86
86
  open dist/TorrentOnline.app
@@ -112,7 +112,7 @@ docker run -p 8123:8123 torrent-online node wtui.js "magnet:?xt=..." --lan --no-
112
112
  - VLC (`/Applications/VLC.app` или в PATH) — не нужен с `--no-vlc`
113
113
 
114
114
  ## Частые вопросы
115
- - **Node не виден из .app** — .app запускает `zsh -l`, PATH подтянется. Если нет — проверь `which node`.
115
+ - **Node не виден из .app** — .app ищет `node` в `/opt/homebrew/bin` и `/usr/local/bin`. Если нет — `brew install node`.
116
116
  - **«Writable stream closed prematurely»** — VLC рвёт пробные коннекты; мы их игнорим.
117
117
  - **Порт занят** — авто-поиск в диапазоне 8123..10122 или `--port=`.
118
118
  - **Magnet без пиров** — приложение отвалится через 60 с с понятной ошибкой, не будет висеть вечно.
@@ -158,7 +158,7 @@ node wtui.js "https://example.com/file.torrent"
158
158
  | `--port=<N>` | fixed port (otherwise auto-scan 8123..10122) |
159
159
  | `--help` | help |
160
160
 
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.
161
+ Default cache: `~/Movies/WebTorrent`. The window keeps it after exit and after Quit. Only Clear cache deletes it. The page language is the RU and EN buttons (Russian by default). In `--cli` mode the folder is still removed on exit unless `--keep-cache` is set.
162
162
 
163
163
  ## Features (v1.4)
164
164
  - 🔎 Built-in torrent search: **Rutor.info (Russian)**, TPB.party (English), optional 1337x API
@@ -174,7 +174,7 @@ Default cache: `~/Movies/WebTorrent`. The window keeps it after exit; the «Оч
174
174
  ```bash
175
175
  node wtui.js --web
176
176
  ```
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).
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. The RU and EN buttons switch the page and the player. Quit stops the server and leaves the cache. From a phone on your Wi-Fi — use `--lan` (token in URL).
178
178
 
179
179
  ## Native app (Tauri, macOS)
180
180
  A window with an icon, no terminal — wraps the same web GUI.
@@ -191,7 +191,7 @@ The app is unsigned, so macOS adds a quarantine flag — clear it once:
191
191
  xattr -dr com.apple.quarantine /Applications/TorrentOnline.app
192
192
  open /Applications/TorrentOnline.app
193
193
  ```
194
- Build it yourself (the `.app` opens **Terminal** and runs the script):
194
+ Build it yourself (a double-click opens the page in the browser, no Terminal window):
195
195
  ```bash
196
196
  ./build/app.sh
197
197
  open dist/TorrentOnline.app
@@ -223,7 +223,7 @@ docker run -p 8123:8123 torrent-online node wtui.js "magnet:?xt=..." --lan --no-
223
223
  - VLC (`/Applications/VLC.app` or in PATH) — not needed with `--no-vlc`
224
224
 
225
225
  ## FAQ
226
- - **Node not visible from the .app** — the .app runs `zsh -l`, PATH is picked up. If not — check `which node`.
226
+ - **Node not visible from the .app** — the .app looks for `node` in `/opt/homebrew/bin` and `/usr/local/bin`. If it is missing, run `brew install node`.
227
227
  - **"Writable stream closed prematurely"** — VLC drops probe connections; we ignore them.
228
228
  - **Port busy** — auto-scan in 8123..10122 or use `--port=`.
229
229
  - **Magnet with no peers** — the app fails after 60 s with a clear error instead of hanging forever.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "torrent-online",
3
- "version": "1.7.9",
3
+ "version": "1.7.10",
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
@@ -555,9 +555,13 @@ function watchFolder() {
555
555
  const WEB_HTML = `<!doctype html><html><head><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><title>TorrentOnline</title><style>
556
556
  :root{--bg:#0f1115;--card:#171a21;--line:#232833;--txt:#e8ecf1;--mut:#8a93a3;--acc:#7fd4ff}
557
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}
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)}
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);flex-wrap:wrap}
559
559
  header b{font-size:18px}
560
- #clr{margin-left:auto;color:#ffb4b4;border-color:#5a3038;background:#2a1c22}
560
+ #langs{margin-left:auto;display:flex;gap:4px}
561
+ #langs button{min-width:44px;padding:6px 10px}
562
+ #langs button.on{background:#2a3c58;color:#fff;border-color:var(--acc)}
563
+ #clr{color:#ffd7a8;border-color:#5a4630;background:#2a241c}
564
+ #quit{color:#ffb4b4;border-color:#5a3038;background:#2a1c22}
561
565
  .wrap{max-width:900px;margin:0 auto;padding:16px}
562
566
  .search{display:flex;gap:8px}
563
567
  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}
@@ -589,45 +593,57 @@ body.drop{outline:3px dashed #3b82f6;outline-offset:-10px;background:rgba(59,130
589
593
  h3{color:var(--mut);font-size:14px;margin:18px 0 4px;font-weight:500}
590
594
  video{width:100vw;height:100vh;background:#000;display:block;margin:0}
591
595
  </style></head><body>
592
- <header>🦀 <b>TorrentOnline</b><button id=clr type=button>Очистить кэш</button></header>
596
+ <header>🦀 <b>TorrentOnline</b><span id=langs><button type=button id=lang-ru class=on>RU</button><button type=button id=lang-en>EN</button></span><button id=clr type=button>Очистить кэш</button><button id=quit type=button>Закрыть</button></header>
593
597
  <div class=wrap>
594
598
  <div class=search><input id=q placeholder="Поиск: пацаны 4 сезон · breaking bad" autocomplete=off><button id=go type=button>Искать</button></div>
595
599
  <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>
600
+ <div class=note id=lead>Поток идёт сразу, целиком ждать не нужно. У каждого файла кнопки VLC и Браузер. Кэш при выходе не удаляется — только кнопкой «Очистить кэш». «Закрыть» останавливает сервер и не трогает скачанное.</div>
597
601
  <div id=notes></div>
598
602
  <div id=results></div>
599
- <h3>Торренты</h3>
603
+ <h3 id=th>Торренты</h3>
600
604
  <div id=torrents></div>
601
605
  </div>
602
606
  <script>
603
607
  var BASE="__BASE__";
608
+ var DICT={
609
+ ru:{phSearch:'Поиск: пацаны 4 сезон · breaking bad',search:'Искать',phMag:'Вставь magnet:?xt=urn:btih:…',addMag:'Добавить magnet',fileBtn:'Файл .torrent',lead:'Поток идёт сразу, целиком ждать не нужно. У каждого файла кнопки VLC и Браузер. Кэш при выходе не удаляется — только кнопкой «Очистить кэш». «Закрыть» останавливает сервер и не трогает скачанное.',clear:'Очистить кэш',quit:'Закрыть',torrents:'Торренты',adding:'добавляю в клиент…',torrent:'торрент',searching:'Ищу…',emptyFind:'Ничего не найдено',searchErr:'Ошибка поиска',needMag:'Нужна ссылка, которая начинается с magnet:',clearAsk:'Удалить скачанные файлы из кэша? Список торрентов тоже очистится.',cleared:'Кэш очищен',clearFail:'Не удалось очистить кэш',uploading:'Загружаю',uploadErr:'Ошибка загрузки',empty:'Пусто. Найди торрент и кликни по строке.',browser:'Браузер',all:'выбрать все',none:'снять все',waitMeta:'ждём метаданные…',peers:'пиры',hint:'Отметь серии галочками — ▶ VLC / ▶ Браузер запустят выбранное. Одна серия стартует автоматически.',del:'✕ удалить',quitAsk:'Остановить сервер? Скачанные файлы останутся в кэше.',quitDone:'Сервер остановлен. Вкладку можно закрыть.',quitFail:'Сервер всё ещё отвечает.',addErr:'Ошибка /api/add','empty-file':'пустой файл','need-source':'нужна ссылка или файл','no-torrent':'нет торрента','no-file':'нет файла','pick-files':'выбери файлы','no-vlc':'VLC не найден'},
610
+ en:{phSearch:'Search: the boys season 4 · breaking bad',search:'Search',phMag:'Paste magnet:?xt=urn:btih:…',addMag:'Add magnet',fileBtn:'.torrent file',lead:'Playback starts right away. You do not wait for the full download. Each file has VLC and Browser buttons. Exit does not delete the cache. Only Clear cache does. Quit stops the server and leaves downloads on disk.',clear:'Clear cache',quit:'Quit',torrents:'Torrents',adding:'adding to the client…',torrent:'torrent',searching:'Searching…',emptyFind:'Nothing found',searchErr:'Search failed',needMag:'The link must start with magnet:',clearAsk:'Delete downloaded files from the cache? The torrent list will be cleared too.',cleared:'Cache cleared',clearFail:'Could not clear the cache',uploading:'Uploading',uploadErr:'Upload failed',empty:'Nothing here yet. Search for a torrent and click a row.',browser:'Browser',all:'select all',none:'select none',waitMeta:'waiting for metadata…',peers:'peers',hint:'Tick the episodes. VLC and Browser play the selection. A single episode starts on its own.',del:'✕ remove',quitAsk:'Stop the server? Downloaded files stay in the cache.',quitDone:'Server stopped. You can close this tab.',quitFail:'The server is still running.',addErr:'/api/add error','empty-file':'empty file','need-source':'a link or a file is required','no-torrent':'no such torrent','no-file':'no such file','pick-files':'pick files first','no-vlc':'VLC was not found'}
611
+ };
612
+ function lang(){try{return localStorage.getItem('to-lang')==='en'?'en':'ru'}catch(e){return 'ru'}}
613
+ function tr(k){var d=DICT[lang()]||DICT.ru;if(d&&d[k]!=null)return d[k];return (DICT.ru&&DICT.ru[k]!=null)?DICT.ru[k]:k}
614
+ function errText(code){if(code==null||code==='')return '';var s=tr(String(code));return s===String(code)?String(code):s}
615
+ function applyLang(){var L=lang();document.documentElement.lang=L;var ruB=document.getElementById('lang-ru'),enB=document.getElementById('lang-en');if(ruB)ruB.className=L==='ru'?'on':'';if(enB)enB.className=L==='en'?'on':'';document.getElementById('clr').textContent=tr('clear');document.getElementById('quit').textContent=tr('quit');document.getElementById('go').textContent=tr('search');document.getElementById('mag').textContent=tr('addMag');document.getElementById('tpath').textContent=tr('fileBtn');document.getElementById('q').placeholder=tr('phSearch');document.getElementById('magin').placeholder=tr('phMag');document.getElementById('lead').textContent=tr('lead');document.getElementById('th').textContent=tr('torrents');}
604
616
  function esc(s){return String(s).replace(/[&<>"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];});}
605
617
  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();});}
606
618
  function addTorrent(mag){
607
619
  var box=document.getElementById('torrents');
608
620
  var dn=(/dn=([^&]*)/.exec(mag)||[,''])[1];try{dn=decodeURIComponent(dn.replace(/\\+/g,' '))}catch(e){}
609
621
  var ph=document.createElement('div');ph.className='card';
610
- ph.innerHTML='<div class=tname>⏳ '+esc(dn||'торрент')+'</div><div class=tmeta>добавляю в клиент…</div>';
622
+ ph.innerHTML='<div class=tname>⏳ '+esc(dn||tr('torrent'))+'</div><div class=tmeta>'+esc(tr('adding'))+'</div>';
611
623
  box.prepend(ph);
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>';});
624
+ api('/api/add',{source:mag}).then(function(d){if(d&&d.error){ph.innerHTML='<div class=tname>⚠ '+esc(errText(d.error))+'</div>';}poll();toTorrents();}).catch(function(e){ph.innerHTML='<div class=tname>⚠ '+esc(tr('addErr'))+': '+esc(e&&e.message||e)+'</div>';});
613
625
  }
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>';
626
+ 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>'+esc(tr('searching'))+'</div>';
615
627
  api('/api/search?q='+encodeURIComponent(q)).then(function(d){
616
628
  var h='';(d.notes||[]).forEach(function(n){h+='<div class=note>⚠ '+esc(n)+'</div>';});
617
- if(!d.items.length){box.innerHTML=h+'<div class=note>Ничего не найдено</div>';return;}
629
+ if(!d.items.length){box.innerHTML=h+'<div class=note>'+esc(tr('emptyFind'))+'</div>';return;}
618
630
  h+=d.items.map(function(it){return '<div class=row data-mag="'+esc(it.mag)+'"><span class=tag>'+esc(it.src)+'</span><span class=rname>'+esc(it.name)+'</span>'+(it.size?'<span class=meta>'+esc(it.size)+'</span>':'')+(it.seeds?'<span class=meta>🌱 '+it.seeds+'</span>':'')+'</div>';}).join('');
619
631
  box.innerHTML=h;
620
632
  [].forEach.call(box.querySelectorAll('.row'),function(r){r.onclick=function(){addTorrent(r.getAttribute('data-mag'));};});
621
- }).catch(function(){box.innerHTML='<div class=note>Ошибка поиска</div>';});}
633
+ }).catch(function(){box.innerHTML='<div class=note>'+esc(tr('searchErr'))+'</div>';});}
622
634
  document.getElementById('go').onclick=doSearch;
623
635
  document.getElementById('q').addEventListener('keydown',function(e){if(e.key==='Enter')doSearch();});
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='';};
636
+ 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>'+esc(tr('needMag'))+'</div>';return;}nb.innerHTML='';addTorrent(m);document.getElementById('magin').value='';};
625
637
  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>';});};
638
+ document.getElementById('clr').onclick=function(){if(!confirm(tr('clearAsk')))return;api('/api/clear-cache',{ok:1}).then(function(){document.getElementById('notes').innerHTML='<div class=note>'+esc(tr('cleared'))+'</div>';poll();}).catch(function(){document.getElementById('notes').innerHTML='<div class=note>'+esc(tr('clearFail'))+'</div>';});};
639
+ document.getElementById('quit').onclick=function(){if(!confirm(tr('quitAsk')))return;var nb=document.getElementById('notes');fetch(BASE+'/api/quit',{method:'POST',headers:{'content-type':'application/json'},body:'{}'}).catch(function(){}).then(function(){if(pollTimer){clearInterval(pollTimer);pollTimer=null;}setTimeout(function(){fetch(BASE+'/api/status').then(function(r){nb.innerHTML='<div class=note>'+esc(r.ok?tr('quitFail'):tr('quitDone'))+'</div>';}).catch(function(){nb.innerHTML='<div class=note>'+esc(tr('quitDone'))+'</div>';});},700);});};
640
+ function setLang(code){try{localStorage.setItem('to-lang',code)}catch(e){}applyLang();poll();}
641
+ document.getElementById('lang-ru').onclick=function(){setLang('ru');};
642
+ document.getElementById('lang-en').onclick=function(){setLang('en');};
627
643
  document.getElementById('tpath').onclick=function(){document.getElementById('tt').click();};
628
644
  document.getElementById('tt').onchange=function(){if(this.files&&this.files[0])uploadFile(this.files[0]);this.value='';};
629
- function uploadFile(f){var nb=document.getElementById('notes');nb.innerHTML='<div class=note>⏳ Загружаю '+esc(f.name)+'…</div>';
630
- fetch(BASE+'/api/add-file',{method:'POST',headers:{'content-type':'application/octet-stream'},body:f}).then(function(r){return r.json()}).then(function(d){nb.innerHTML=d.error?'<div class=note>⚠ '+esc(d.error)+'</div>':'';poll();toTorrents();}).catch(function(){nb.innerHTML='<div class=note>⚠ Ошибка загрузки</div>';});}
645
+ function uploadFile(f){var nb=document.getElementById('notes');nb.innerHTML='<div class=note>⏳ '+esc(tr('uploading'))+' '+esc(f.name)+'…</div>';
646
+ fetch(BASE+'/api/add-file',{method:'POST',headers:{'content-type':'application/octet-stream'},body:f}).then(function(r){return r.json()}).then(function(d){nb.innerHTML=d.error?'<div class=note>⚠ '+esc(errText(d.error))+'</div>':'';poll();toTorrents();}).catch(function(){nb.innerHTML='<div class=note>⚠ '+esc(tr('uploadErr'))+'</div>';});}
631
647
  document.addEventListener('dragover',function(e){e.preventDefault();document.body.classList.add('drop');});
632
648
  document.addEventListener('dragleave',function(e){if(e.target===document.body||e.relatedTarget===null)document.body.classList.remove('drop');});
633
649
  document.addEventListener('drop',function(e){e.preventDefault();document.body.classList.remove('drop');
@@ -637,27 +653,30 @@ var t=(e.dataTransfer.getData('text/uri-list')||'')+(e.dataTransfer.getData('tex
637
653
  var mm=t.match(/magnet:\\?[^\\s]+/);if(mm){addTorrent(mm[0]);}
638
654
  });
639
655
  function render(list){var box=document.getElementById('torrents');
640
- if(!list.length){box.innerHTML='<div class=note>Пусто. Найди торрент и кликни по строке.</div>';return;}
656
+ if(!list.length){box.innerHTML='<div class=note>'+esc(tr('empty'))+'</div>';return;}
641
657
  box.innerHTML=list.map(function(t){
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('');
658
+ 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+'>'+esc(tr('browser'))+'</button></div>';}).join('');
643
659
  var first=t.files.filter(function(f){return f.selected;})[0];
644
660
  var wi=first?first.i:0;
645
661
  var nSel=t.files.filter(function(f){return f.selected;}).length;
646
662
  var bar=t.ready?t.progress:0;
647
- var hint=(t.ready&&nSel===0)?'<div class=note>Отметь серии галочками — ▶ VLC / ▶ Браузер запустят выбранное. Одна серия стартует автоматически.</div>':'';
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>';
663
+ var hint=(t.ready&&nSel===0)?'<div class=note>'+esc(tr('hint'))+'</div>':'';
664
+ return '<div class=card><div class=tname>'+esc(t.name)+'</div><div class=tmeta>'+(t.ready?'⏬ '+t.progress+'% · '+esc(t.speed)+' · '+esc(tr('peers'))+' '+t.peers:'⏳ '+esc(tr('waitMeta')))+'</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+'>'+esc(tr('all'))+'</button><button data-act=none data-id='+t.id+'>'+esc(tr('none'))+'</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+'>▶ '+esc(tr('browser'))+'</button><button class=del data-act=del data-id='+t.id+'>'+esc(tr('del'))+'</button></div></div>';
649
665
  }).join('');
650
666
  [].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});};});
651
667
  [].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;});}
668
+ 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?('⚠ '+errText(d.error).slice(0,80)):label;});}
653
669
  if(act==='web'){window.open(BASE+'/view/'+id+'/'+b.getAttribute('data-i'));}
654
670
  if(act==='del'){api('/api/remove',{id:id}).then(poll);}
655
671
  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);}
656
672
  };});
657
673
  }
674
+ var pollTimer=null;
658
675
  function poll(){api('/api/status').then(render).catch(function(){});}
659
676
  function toTorrents(){var el=document.getElementById('torrents');el.scrollIntoView({behavior:'smooth',block:'start'});}
660
- poll();setInterval(poll,1500);
677
+ applyLang();
678
+ poll();
679
+ pollTimer=setInterval(poll,1500);
661
680
  </script></body></html>`;
662
681
 
663
682
  function readBody(req) {
@@ -875,6 +894,31 @@ function makeWebServer(client, st, opts, cacheDir, base) {
875
894
  });
876
895
  }
877
896
 
897
+ const PLAYER_I18N = {
898
+ ru: {
899
+ sound: '▶ со звуком',
900
+ prep: 'готовлю файл к стриму…',
901
+ ffmpeg: 'нужен ffmpeg',
902
+ nodur: 'не вижу длительность файла',
903
+ noplayer: 'плеер браузера не загрузился',
904
+ track: 'дорожка',
905
+ nosub: 'без субтитров',
906
+ sub: 'субтитры',
907
+ peers: 'пиры',
908
+ },
909
+ en: {
910
+ sound: '▶ with sound',
911
+ prep: 'preparing the stream…',
912
+ ffmpeg: 'ffmpeg is required',
913
+ nodur: 'cannot read the duration',
914
+ noplayer: 'the browser player did not load',
915
+ track: 'track',
916
+ nosub: 'no subtitles',
917
+ sub: 'subtitles',
918
+ peers: 'peers',
919
+ },
920
+ };
921
+
878
922
  async function webReq(req, res, client, st, opts, cacheDir, base) {
879
923
  const u = new URL(req.url, 'http://127.0.0.1');
880
924
  const ensureSel = (e, i) => { const f = e?.t.files[i]; if (f && !e.sel.has(i)) { e.sel.add(i); try { f.select(); } catch {} } };
@@ -900,6 +944,17 @@ async function webReq(req, res, client, st, opts, cacheDir, base) {
900
944
  } catch { res.statusCode = 404; res.end('no hls.js'); }
901
945
  return;
902
946
  }
947
+ if (p === '/api/quit' && req.method === 'POST') {
948
+ res.statusCode = 200;
949
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
950
+ res.setHeader('Connection', 'close');
951
+ res.end(JSON.stringify({ ok: true }));
952
+ setTimeout(() => {
953
+ if (typeof st.quit === 'function') st.quit();
954
+ else process.exit(0);
955
+ }, 150);
956
+ return;
957
+ }
903
958
  if (p === '/api/search') {
904
959
  const q = u.searchParams.get('q') || '';
905
960
  const { items, notes } = await searchIndexers(q);
@@ -914,7 +969,7 @@ async function webReq(req, res, client, st, opts, cacheDir, base) {
914
969
  req.on('error', reject);
915
970
  });
916
971
  const buf = Buffer.concat(chunks);
917
- if (!buf.length) return json(400, { error: 'пустой файл' });
972
+ if (!buf.length) return json(400, { error: 'empty-file' });
918
973
  const id = st.nextId++;
919
974
  st.pending.push(id);
920
975
  try { client.add(buf, { path: cacheDir }); }
@@ -924,7 +979,7 @@ async function webReq(req, res, client, st, opts, cacheDir, base) {
924
979
  if (p === '/api/add' && req.method === 'POST') {
925
980
  const b = await readBody(req);
926
981
  const source = String(b.source || '');
927
- if (!source) return json(400, { error: 'нужен source' });
982
+ if (!source) return json(400, { error: 'need-source' });
928
983
  const btih = (source.match(/btih:([0-9a-f]{40})/i) || [])[1];
929
984
  if (btih) {
930
985
  const ex = [...st.torrents.values()].find(e => e.t.infoHash === btih.toLowerCase());
@@ -953,7 +1008,7 @@ async function webReq(req, res, client, st, opts, cacheDir, base) {
953
1008
  if (p === '/api/select' && req.method === 'POST') {
954
1009
  const b = await readBody(req);
955
1010
  const e = st.torrents.get(Number(b.id));
956
- if (!e) return json(404, { error: 'нет торрента' });
1011
+ if (!e) return json(404, { error: 'no-torrent' });
957
1012
  e.t.files.forEach(f => f.deselect());
958
1013
  e.sel = new Set((b.indices || []).map(Number));
959
1014
  e.sel.forEach(i => e.t.files[i]?.select());
@@ -962,18 +1017,18 @@ async function webReq(req, res, client, st, opts, cacheDir, base) {
962
1017
  if (p === '/api/vlc' && req.method === 'POST') {
963
1018
  const b = await readBody(req);
964
1019
  const e = st.torrents.get(Number(b.id));
965
- if (!e) return json(404, { error: 'нет торрента' });
1020
+ if (!e) return json(404, { error: 'no-torrent' });
966
1021
  let idx;
967
1022
  if (b.index != null && b.index !== '' && Number.isFinite(Number(b.index))) {
968
1023
  const i = Number(b.index);
969
- if (!e.t.files[i]) return json(400, { error: 'нет файла' });
1024
+ if (!e.t.files[i]) return json(400, { error: 'no-file' });
970
1025
  e.sel.add(i);
971
1026
  try { e.t.files[i].select(); } catch {}
972
1027
  idx = [i];
973
1028
  } else idx = [...e.sel];
974
- if (!idx.length) return json(400, { error: 'выбери файлы' });
1029
+ if (!idx.length) return json(400, { error: 'pick-files' });
975
1030
  const vlcBin = detectVLCPath();
976
- if (!vlcBin) return json(500, { error: 'VLC не найден' });
1031
+ if (!vlcBin) return json(500, { error: 'no-vlc' });
977
1032
  const subs = await collectSubtitles(e.t, idx);
978
1033
  const urls = idx.map(i => 'http://127.0.0.1:' + st.port + base + '/s/' + e.id + '/' + i);
979
1034
  const args = buildVLCArgs({ urls, caching: opts.caching, subs, startAt: 0 });
@@ -1016,7 +1071,7 @@ async function webReq(req, res, client, st, opts, cacheDir, base) {
1016
1071
  if (mT) {
1017
1072
  const e = st.torrents.get(Number(mT[1]));
1018
1073
  const f = e?.t.files[Number(mT[2])];
1019
- if (!f) return json(404, { error: 'нет файла' });
1074
+ if (!f) return json(404, { error: 'no-file' });
1020
1075
  ensureSel(e, Number(mT[2]));
1021
1076
  const ffprobe = detectFFBin('ffprobe');
1022
1077
  if (!ffprobe) return json(200, { ffmpeg: false, tracks: [] });
@@ -1212,14 +1267,22 @@ select{background:#1b1b1f;color:#ddd;border:1px solid #333;border-radius:6px;pad
1212
1267
  #tm{font-variant-numeric:tabular-nums;min-width:9em}
1213
1268
  video{flex:1;width:100%;min-height:0;background:#000}
1214
1269
  #n{color:#9ab}
1215
- #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>
1216
- <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>
1270
+ #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}
1271
+ #langs{display:flex;gap:4px}#langs button{background:#1b1b1f;color:#ddd;border:1px solid #333;border-radius:6px;padding:2px 8px;cursor:pointer}#langs button.on{border-color:#7fd4ff;color:#fff}</style>
1272
+ <div class=bar><b>${esc(f.name)}</b><span id=langs><button type=button id=lru>RU</button><button type=button id=len>EN</button></span><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>
1217
1273
  <video id=v controls autoplay playsinline></video>
1218
1274
  <div id=ov>▶ со звуком</div>
1219
1275
  <script src="${base}/hls.min.js"></script>
1220
1276
  <script>
1221
1277
  var B=${JSON.stringify(base)},ID=${mV[1]},I=${mV[2]};
1278
+ var PK=${JSON.stringify(PLAYER_I18N)};
1222
1279
  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");
1280
+ function LG(){try{return localStorage.getItem("to-lang")==="en"?"en":"ru"}catch(e){return "ru"}}
1281
+ function pt(k){var d=PK[LG()]||PK.ru;return (d&&d[k])||(PK.ru&&PK.ru[k])||k}
1282
+ function paintLang(){var L=LG();document.documentElement.lang=L;var a=document.getElementById("lru"),b=document.getElementById("len");if(a)a.className=L==="ru"?"on":"";if(b)b.className=L==="en"?"on":"";ov.textContent=pt("sound")}
1283
+ document.getElementById("lru").onclick=function(){try{localStorage.setItem("to-lang","ru")}catch(e){}paintLang();fillTracks()};
1284
+ document.getElementById("len").onclick=function(){try{localStorage.setItem("to-lang","en")}catch(e){}paintLang();fillTracks()};
1285
+ paintLang();
1223
1286
  v.muted=false;v.volume=1;
1224
1287
  ov.onclick=function(){v.muted=false;v.volume=1;v.play().catch(function(){});ov.style.display="none"};
1225
1288
  var tries=0,player=null,cues=[],subUrl="",armed=false;
@@ -1240,27 +1303,29 @@ player.loadSource(u);player.attachMedia(v);
1240
1303
  player.on(Hls.Events.MANIFEST_PARSED,start);
1241
1304
  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)})}
1242
1305
  else{var once=function(){v.removeEventListener("loadedmetadata",once);start()};v.addEventListener("loadedmetadata",once);v.src=u}};
1306
+ var lastD=null,filling=false;
1307
+ function fillTracks(){if(!lastD)return;filling=true;var d=lastD;var prevA=aS.value,prevS=sS.value;var A=d.tracks||[],S=d.subs||[];aS.innerHTML="";sS.innerHTML="";
1308
+ 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});
1309
+ A.forEach(function(t){var o=document.createElement("option");o.value=t.a;o.textContent=(t.lang&&t.lang!=="und"?t.lang:pt("track")+" "+(t.a+1))+(t.title?" — "+t.title:"")+" ["+t.codec+"]";aS.appendChild(o)});
1310
+ if(A.length){aS.value=(prevA!==""&&A.some(function(t){return String(t.a)===prevA}))?prevA:String(A[0].a);aS.style.display="";if(A.length<2)aS.disabled=true}
1311
+ if(SI.length){var o0=document.createElement("option");o0.value="";o0.textContent=pt("nosub");sS.appendChild(o0);
1312
+ 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:pt("sub")+" "+(t.s+1))+(t.title?" — "+t.title:"");sS.appendChild(o)});sS.value=prevS||"";sS.style.display=""}
1313
+ filling=false}
1243
1314
  var load=function(){fetch(B+"/api/tracks/"+ID+"/"+I).then(function(r){return r.json()}).then(function(d){
1244
- if((d.retry||!(d.duration>0))&&tries++<20){n.textContent="готовлю файл к стриму…";setTimeout(load,2000);return;}
1245
- if(!d.ffmpeg){n.textContent="нужен ffmpeg";return;}
1246
- if(!(d.duration>0)){n.textContent="не вижу длительность файла";return;}
1315
+ if((d.retry||!(d.duration>0))&&tries++<20){n.textContent=pt("prep");setTimeout(load,2000);return;}
1316
+ if(!d.ffmpeg){n.textContent=pt("ffmpeg");return;}
1317
+ if(!(d.duration>0)){n.textContent=pt("nodur");return;}
1247
1318
  var nativeHls=!!v.canPlayType("application/vnd.apple.mpegurl");
1248
- if(!(window.Hls&&Hls.isSupported())&&!nativeHls){n.textContent="плеер браузера не загрузился";return;}
1319
+ if(!(window.Hls&&Hls.isSupported())&&!nativeHls){n.textContent=pt("noplayer");return;}
1249
1320
  tm.textContent="00:00 / "+fmt(d.duration);
1250
- var A=d.tracks||[],S=d.subs||[];
1251
- aS.innerHTML="";sS.innerHTML="";
1252
- 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});
1253
- 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)});
1254
- if(A.length){aS.value=String(A[0].a);aS.style.display="";if(A.length<2)aS.disabled=true}
1255
- if(SI.length){var o0=document.createElement("option");o0.value="";o0.textContent="без субтитров";sS.appendChild(o0);
1256
- 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=""}
1321
+ lastD=d;fillTracks();
1257
1322
  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)};
1258
- aS.onchange=function(){go(true)};
1259
- sS.onchange=function(){var s=sS.value;cues=[];cueDiv.innerHTML="";subUrl=s!==""?B+"/hls/"+ID+"/"+I+"/sub.vtt?si="+encodeURIComponent(s):"";if(subUrl)pollSub()};
1323
+ aS.onchange=function(){if(filling)return;go(true)};
1324
+ sS.onchange=function(){if(filling)return;var s=sS.value;cues=[];cueDiv.innerHTML="";subUrl=s!==""?B+"/hls/"+ID+"/"+I+"/sub.vtt?si="+encodeURIComponent(s):"";if(subUrl)pollSub()};
1260
1325
  if(!armed){armed=true;go(false)}
1261
1326
  }).catch(function(){if(tries++<20)setTimeout(load,2000)})};
1262
1327
  v.addEventListener("playing",function(){n.textContent="";ov.style.display="none";tick()});
1263
- 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);
1328
+ 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+" · "+pt("peers")+" "+t.peers+(t.progress>0?" · "+t.progress+"%":"")}).catch(function(){})},2000);
1264
1329
  load();
1265
1330
  </script>`);
1266
1331
  return;
@@ -1312,15 +1377,19 @@ async function startWeb(opts, srcArg) {
1312
1377
  try { server.close(); } catch {}
1313
1378
  if (st.hls) for (const h of st.hls.procs.values()) { try { h.proc.kill('SIGKILL'); } catch {} }
1314
1379
  try { fs.rmSync(path.join(os.tmpdir(), 'wtui-hls-' + process.pid), { recursive: true, force: true }); } catch {}
1380
+ const bye = setTimeout(() => process.exit(code), 4000);
1381
+ bye.unref();
1382
+ // «Закрыть» и Ctrl+C гасят процесс. Кэш на диске остаётся: destroyStoreOnDestroy выключен.
1315
1383
  try { await new Promise(r => client.destroy(r)); } catch {}
1316
1384
  process.exit(code);
1317
1385
  }
1386
+ st.quit = () => { shutdown(0); };
1318
1387
  process.on('SIGINT', () => shutdown(0));
1319
1388
  process.on('SIGTERM', () => shutdown(0));
1320
1389
  const NET_NOISE = /premature|aborted|EPIPE|ECONNRESET|ETIMEDOUT|socket|stream|duplicate torrent/i;
1321
1390
  process.on('unhandledRejection', e => { const m = (e && (e.message || e)) + ''; if (!NET_NOISE.test(m)) console.error('unhandledRejection:', m); });
1322
1391
  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')); });
1323
- console.log('Ctrl+C — выход. Скачанное остаётся в ' + cacheDir + '. Стирает его только кнопка «Очистить кэш».');
1392
+ console.log('Выход: кнопка «Закрыть» на странице или Ctrl+C. Скачанное остаётся в ' + cacheDir + '. Стирает его только кнопка «Очистить кэш».');
1324
1393
  }
1325
1394
 
1326
1395
  // ---- Main ----