torrent-online 1.4.4
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 +69 -0
- package/LICENSE +21 -0
- package/README.md +186 -0
- package/docs/menu.png +0 -0
- package/package.json +48 -0
- package/wtui.js +603 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Changelog / История изменений
|
|
2
|
+
|
|
3
|
+
Формат — [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/), версии — [Semantic Versioning](https://semver.org/lang/ru/).
|
|
4
|
+
|
|
5
|
+
## [1.4.3] — 2026-10-03
|
|
6
|
+
**RU:** .app распространяется как артефакт релиза.
|
|
7
|
+
**EN:** The .app ships as a release artifact.
|
|
8
|
+
- ➕ `build/app.sh`: в .app больше не бандлится `node_modules` — зависимости ставятся при первом запуске в `~/Library/Application Support/TorrentOnline` (один раз, потом мгновенный старт)
|
|
9
|
+
- ➕ Артефакт релиза: `TorrentOnline.app.zip` — качай и запускай без `git clone`
|
|
10
|
+
|
|
11
|
+
## [1.4.2] — 2026-10-03
|
|
12
|
+
**RU:** Русский поиск через Rutor.info.
|
|
13
|
+
**EN:** Russian-language search via Rutor.info.
|
|
14
|
+
- ➕ `searchRutor`: RSS `rutor.info/rss.php?search=` — находит по кириллице сразу («человек паук» → 26 результатов)
|
|
15
|
+
- ➕ CLI принимает `https://…file.torrent` как источник
|
|
16
|
+
- 🔀 Результаты Rutor всегда вверху списка, TPB — ниже
|
|
17
|
+
- ➖ 1337x убран из дефолта (за Cloudflare), остался опциональной заметкой
|
|
18
|
+
|
|
19
|
+
## [1.4.1] — 2026-10-03
|
|
20
|
+
**RU:** Починен поиск индексаторов.
|
|
21
|
+
**EN:** Indexer search fixed.
|
|
22
|
+
- 🐛 TPB: RSS отдавал HTML вместо XML → парсер видел 0 результатов; теперь парсится страница поиска (magnet + имя + размер + сиды)
|
|
23
|
+
- 🐛 1337x: подставной API-ключ → настоящий; кривой путь запроса; поле `magnetLink` вместо `magnet_link`
|
|
24
|
+
- ➕ Авто-транслит кириллицы: «человек паук» → ищет и кириллицей, и `chelovek pauk`
|
|
25
|
+
- ➕ Дедуп по btih, сортировка по сидам, в списке: `источник · имя · размер · 🌱сиды`
|
|
26
|
+
- ➕ Честные заметки об ошибках источников (`⚠ 1337x: HTTP 403 (Cloudflare)`) вместо молчаливого «0 результатов»
|
|
27
|
+
|
|
28
|
+
## [1.4.0] — 2026-10-03
|
|
29
|
+
**RU:** Большой релиз: фиксы ревью + фичи.
|
|
30
|
+
**EN:** Big release: review fixes + features.
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
- 🐛 `makeServer`: бесконечная рекурсия (мёртвая строка с `createServer`) удалена
|
|
34
|
+
- 🐛 Суффиксные Range-запросы `bytes=-N` теперь корректные 206; битый range → 416
|
|
35
|
+
- 🐛 Ctrl+C: VLC убивается (`vlc.kill()`), больше не остаётся зомби-процесс
|
|
36
|
+
- 🐛 `uncaughtException` логируется и ведёт к корректному shutdown, а не глушится
|
|
37
|
+
- 🐛 Имена файлов экранируются в HTML-индексе
|
|
38
|
+
- 🐛 Magnet без пиров: таймаут метаданных 60 с с понятной ошибкой, вместо вечного висения
|
|
39
|
+
- 🐛 `nameToIdx` — Map по индексу, а не по имени
|
|
40
|
+
- 🔧 Промпт кэша предупреждает: папка кэша удаляется после выхода
|
|
41
|
+
- 🔧 Фильтр по имени (подстрока/`/regex/`) был мёртвым — подключён
|
|
42
|
+
- 🔧 `build/app.sh` читает версию из `package.json`
|
|
43
|
+
- 🔧 Дефолтный `--network-caching` поднят 1500 → 3000
|
|
44
|
+
|
|
45
|
+
### Added
|
|
46
|
+
- 🌐 Стрим в браузер: страница с `<video>` на `/view/<i>`, `--no-vlc` без VLC
|
|
47
|
+
- 🌐 `--lan`: слушать 0.0.0.0 + случайный токен в URL (смотреть с телефона/ТВ)
|
|
48
|
+
- 🔎 Поиск торрентов в меню (TPB.party RSS + 1337x API) → magnet
|
|
49
|
+
- 📺 Субтитры из торрента (`.srt/.ass/.ssa/.vtt`) → VLC через `--sub-file=`
|
|
50
|
+
- ▶️ `--resume`: продолжение с прошлой позиции (`--start-time`), состояние в `~/.config/torrent-online/state.json`
|
|
51
|
+
- 📦 CLI-режим без меню: `node wtui.js film.torrent|magnet:`
|
|
52
|
+
- 📥 Watch-папка `~/Downloads/torrent-online-watch` — `.torrent` подхватываются в меню
|
|
53
|
+
- 📊 Прогресс-строка: %, скорость, пиры
|
|
54
|
+
- ✅ Проверка наличия VLC на старте с внятной ошибкой
|
|
55
|
+
- ⚙️ Флаги: `--keep-cache`, `--port=<N>`, `--network-caching=<ms>`, `--help`
|
|
56
|
+
- 📄 LICENSE (MIT), Dockerfile (headless-стрим на NAS), `torrent-online.desktop` для Linux
|
|
57
|
+
|
|
58
|
+
### Changed
|
|
59
|
+
- 🔁 `inquirer ^8` (не дружит с Node 22+) → `@inquirer/prompts ^7`
|
|
60
|
+
- 🔒 `webtorrent` зафиксирован на `2.8.4`
|
|
61
|
+
|
|
62
|
+
## [1.3.2] — 2025-09-28
|
|
63
|
+
**RU:** Базовая версия: TUI-меню, стрим в VLC.
|
|
64
|
+
**EN:** Baseline: TUI menu, VLC streaming.
|
|
65
|
+
- Меню выбора `.torrent` / magnet
|
|
66
|
+
- Стрим в VLC через локальный HTTP-сервер с Range-поддержкой
|
|
67
|
+
- Кэш `~/Movies/WebTorrent`, удаление после выхода
|
|
68
|
+
- Авто-поиск порта в диапазоне 8123..10122
|
|
69
|
+
- Сборка `.app` для macOS (`build/app.sh`)
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Evgeny Purkov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# TorrentOnline
|
|
2
|
+
|
|
3
|
+
**RU:** Стрим из `.torrent`, `magnet:` и ссылки на `.torrent` в **VLC** или прямо в **браузер**. Один процесс VLC на весь плейлист. Кэш чистится после выхода (отключается флагом).
|
|
4
|
+
**EN:** Stream from `.torrent`, `magnet:` and `.torrent` URLs to **VLC** or straight to the **browser**. One VLC process for the whole playlist. Cache is wiped on exit (disable with a flag).
|
|
5
|
+
|
|
6
|
+
[Русский](#русский) · [English](#english)
|
|
7
|
+
|
|
8
|
+

|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# Русский
|
|
13
|
+
|
|
14
|
+
## Быстрый старт
|
|
15
|
+
```bash
|
|
16
|
+
git clone https://github.com/IamCaptainPepe/torrent-online.git
|
|
17
|
+
cd torrent-online
|
|
18
|
+
npm i
|
|
19
|
+
node wtui.js
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
CLI без меню (играют все видеофайлы торрента):
|
|
23
|
+
```bash
|
|
24
|
+
node wtui.js film.torrent
|
|
25
|
+
node wtui.js "magnet:?xt=urn:btih:..."
|
|
26
|
+
node wtui.js "https://example.com/file.torrent"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Флаги
|
|
30
|
+
| Флаг | Что делает |
|
|
31
|
+
|---|---|
|
|
32
|
+
| `--keep-cache` | не удалять кэш после закрытия VLC |
|
|
33
|
+
| `--network-caching=<ms>` | прокинуть в VLC (по умолчанию 3000) |
|
|
34
|
+
| `--resume` | продолжить с прошлой позиции (VLC `--start-time`) |
|
|
35
|
+
| `--lan` | слушать 0.0.0.0 + токен в URL — смотреть с телефона/ТВ в сети |
|
|
36
|
+
| `--no-vlc` | только браузер-стриминг, без VLC |
|
|
37
|
+
| `--port=<N>` | фиксированный порт (иначе авто-поиск 8123..10122) |
|
|
38
|
+
| `--help` | справка |
|
|
39
|
+
|
|
40
|
+
Кэш по умолчанию: `~/Movies/WebTorrent`. **Внимание: папка кэша удаляется после выхода** (кроме `--keep-cache`) — промпт об этом предупреждает.
|
|
41
|
+
|
|
42
|
+
## Возможности (v1.4)
|
|
43
|
+
- 🔎 Поиск торрентов прямо в меню: **Rutor.info (по-русски)**, TPB.party (по-английски), опционально 1337x API
|
|
44
|
+
- 🌐 Стрим в браузер: страница с плеером на `/`, режим `--lan` с токеном
|
|
45
|
+
- 📺 Субтитры из торрента (`.srt/.ass/.ssa/.vtt`) → `--sub-file=` в VLC
|
|
46
|
+
- ▶️ `--resume`: позиция сохраняется в `~/.config/torrent-online/state.json`
|
|
47
|
+
- 📥 Watch-папка: `~/Downloads/torrent-online-watch` — бросил `.torrent`, подхватился в меню
|
|
48
|
+
- Прогресс-строка: `%`, скорость, пиры; таймаут метаданных magnet (60 с)
|
|
49
|
+
- Фильтр по имени (подстрока или `/regex/`) перед выбором файлов
|
|
50
|
+
- Проверка VLC на старте с внятной ошибкой
|
|
51
|
+
|
|
52
|
+
## .app (macOS)
|
|
53
|
+
`.app` открывает **Terminal** и запускает скрипт.
|
|
54
|
+
```bash
|
|
55
|
+
./build/app.sh
|
|
56
|
+
open dist/TorrentOnline.app
|
|
57
|
+
```
|
|
58
|
+
DMG по желанию:
|
|
59
|
+
```bash
|
|
60
|
+
brew install create-dmg
|
|
61
|
+
./build/app.sh
|
|
62
|
+
open dist/TorrentOnline.dmg
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Linux
|
|
66
|
+
```bash
|
|
67
|
+
# ярлык в меню приложений
|
|
68
|
+
cp build/torrent-online.desktop ~/.local/share/applications/
|
|
69
|
+
# поправь Exec= на путь к проекту (или замени %h на абсолютный путь)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Docker (headless, стрим в браузер)
|
|
73
|
+
```bash
|
|
74
|
+
docker build -t torrent-online .
|
|
75
|
+
docker run -p 8123:8123 torrent-online node wtui.js "magnet:?xt=..." --lan --no-vlc --port=8123
|
|
76
|
+
# открой напечатанный URL (токен в нём)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Требования
|
|
80
|
+
- macOS 11+ / Linux
|
|
81
|
+
- Node.js ≥ 18
|
|
82
|
+
- VLC (`/Applications/VLC.app` или в PATH) — не нужен с `--no-vlc`
|
|
83
|
+
|
|
84
|
+
## Частые вопросы
|
|
85
|
+
- **Node не виден из .app** — .app запускает `zsh -l`, PATH подтянется. Если нет — проверь `which node`.
|
|
86
|
+
- **«Writable stream closed prematurely»** — VLC рвёт пробные коннекты; мы их игнорим.
|
|
87
|
+
- **Порт занят** — авто-поиск в диапазоне 8123..10122 или `--port=`.
|
|
88
|
+
- **Magnet без пиров** — приложение отвалится через 60 с с понятной ошибкой, не будет висеть вечно.
|
|
89
|
+
- **Поиск по-русски** — Rutor ищет по кириллице сразу; TPB — только по-английски (транслит пробуем сами).
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
# English
|
|
94
|
+
|
|
95
|
+
## Quick start
|
|
96
|
+
```bash
|
|
97
|
+
git clone https://github.com/IamCaptainPepe/torrent-online.git
|
|
98
|
+
cd torrent-online
|
|
99
|
+
npm i
|
|
100
|
+
node wtui.js
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
CLI without the menu (all video files of the torrent play):
|
|
104
|
+
```bash
|
|
105
|
+
node wtui.js film.torrent
|
|
106
|
+
node wtui.js "magnet:?xt=urn:btih:..."
|
|
107
|
+
node wtui.js "https://example.com/file.torrent"
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Flags
|
|
111
|
+
| Flag | What it does |
|
|
112
|
+
|---|---|
|
|
113
|
+
| `--keep-cache` | don't delete the cache after VLC closes |
|
|
114
|
+
| `--network-caching=<ms>` | passed to VLC (default 3000) |
|
|
115
|
+
| `--resume` | continue from the last position (VLC `--start-time`) |
|
|
116
|
+
| `--lan` | listen on 0.0.0.0 + token in URL — watch from phone/TV on your network |
|
|
117
|
+
| `--no-vlc` | browser streaming only, no VLC |
|
|
118
|
+
| `--port=<N>` | fixed port (otherwise auto-scan 8123..10122) |
|
|
119
|
+
| `--help` | help |
|
|
120
|
+
|
|
121
|
+
Default cache: `~/Movies/WebTorrent`. **Note: the cache folder is deleted on exit** (unless `--keep-cache`) — the prompt warns about it.
|
|
122
|
+
|
|
123
|
+
## Features (v1.4)
|
|
124
|
+
- 🔎 Built-in torrent search: **Rutor.info (Russian)**, TPB.party (English), optional 1337x API
|
|
125
|
+
- 🌐 Browser streaming: player page at `/`, `--lan` mode with a token
|
|
126
|
+
- 📺 Subtitles from the torrent (`.srt/.ass/.ssa/.vtt`) → `--sub-file=` in VLC
|
|
127
|
+
- ▶️ `--resume`: playback position saved in `~/.config/torrent-online/state.json`
|
|
128
|
+
- 📥 Watch folder: `~/Downloads/torrent-online-watch` — drop a `.torrent`, it shows up in the menu
|
|
129
|
+
- Progress line: %, speed, peers; magnet metadata timeout (60 s)
|
|
130
|
+
- Name filter (substring or `/regex/`) before picking files
|
|
131
|
+
- VLC presence checked at startup with a clear error
|
|
132
|
+
|
|
133
|
+
## .app (macOS)
|
|
134
|
+
The `.app` opens **Terminal** and runs the script.
|
|
135
|
+
```bash
|
|
136
|
+
./build/app.sh
|
|
137
|
+
open dist/TorrentOnline.app
|
|
138
|
+
```
|
|
139
|
+
Optional DMG:
|
|
140
|
+
```bash
|
|
141
|
+
brew install create-dmg
|
|
142
|
+
./build/app.sh
|
|
143
|
+
open dist/TorrentOnline.dmg
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Linux
|
|
147
|
+
```bash
|
|
148
|
+
# app menu shortcut
|
|
149
|
+
cp build/torrent-online.desktop ~/.local/share/applications/
|
|
150
|
+
# fix Exec= to your project path (or replace %h with an absolute path)
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Docker (headless, stream to browser)
|
|
154
|
+
```bash
|
|
155
|
+
docker build -t torrent-online .
|
|
156
|
+
docker run -p 8123:8123 torrent-online node wtui.js "magnet:?xt=..." --lan --no-vlc --port=8123
|
|
157
|
+
# open the printed URL (token included)
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Requirements
|
|
161
|
+
- macOS 11+ / Linux
|
|
162
|
+
- Node.js ≥ 18
|
|
163
|
+
- VLC (`/Applications/VLC.app` or in PATH) — not needed with `--no-vlc`
|
|
164
|
+
|
|
165
|
+
## FAQ
|
|
166
|
+
- **Node not visible from the .app** — the .app runs `zsh -l`, PATH is picked up. If not — check `which node`.
|
|
167
|
+
- **"Writable stream closed prematurely"** — VLC drops probe connections; we ignore them.
|
|
168
|
+
- **Port busy** — auto-scan in 8123..10122 or use `--port=`.
|
|
169
|
+
- **Magnet with no peers** — the app fails after 60 s with a clear error instead of hanging forever.
|
|
170
|
+
- **Search in Russian** — Rutor searches Cyrillic directly; TPB is English-only (we try transliteration too).
|
|
171
|
+
|
|
172
|
+
## Структура / Structure
|
|
173
|
+
```
|
|
174
|
+
.
|
|
175
|
+
├─ wtui.js # основной скрипт / main script (ESM)
|
|
176
|
+
├─ package.json
|
|
177
|
+
├─ LICENSE # MIT
|
|
178
|
+
├─ Dockerfile
|
|
179
|
+
├─ build/
|
|
180
|
+
│ ├─ app.sh # сборка .app (Terminal launcher)
|
|
181
|
+
│ └─ torrent-online.desktop
|
|
182
|
+
└─ README.md
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## Лицензия / License
|
|
186
|
+
MIT
|
package/docs/menu.png
ADDED
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "torrent-online",
|
|
3
|
+
"version": "1.4.4",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Stream .torrent / magnet to VLC or browser — torrent streaming for macOS/Linux without a torrent client. Стрим торрентов в VLC или браузер.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"torrent",
|
|
8
|
+
"magnet",
|
|
9
|
+
"streaming",
|
|
10
|
+
"vlc",
|
|
11
|
+
"webtorrent",
|
|
12
|
+
"macos",
|
|
13
|
+
"linux",
|
|
14
|
+
"cli",
|
|
15
|
+
"rutor",
|
|
16
|
+
"piratebay"
|
|
17
|
+
],
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"author": "IamCaptainPepe",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/IamCaptainPepe/torrent-online.git"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://github.com/IamCaptainPepe/torrent-online#readme",
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/IamCaptainPepe/torrent-online/issues"
|
|
27
|
+
},
|
|
28
|
+
"bin": {
|
|
29
|
+
"torrent-online": "./wtui.js"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"wtui.js",
|
|
33
|
+
"README.md",
|
|
34
|
+
"CHANGELOG.md",
|
|
35
|
+
"LICENSE",
|
|
36
|
+
"docs/menu.png"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"start": "node wtui.js"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@inquirer/prompts": "^7.0.0",
|
|
43
|
+
"webtorrent": "2.8.4"
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=18"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/wtui.js
ADDED
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import { promises as fsp } from 'fs';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import os from 'os';
|
|
7
|
+
import crypto from 'crypto';
|
|
8
|
+
import { spawn, spawnSync } from 'child_process';
|
|
9
|
+
import { fileURLToPath } from 'url';
|
|
10
|
+
import { select, checkbox, input, confirm, Separator } from '@inquirer/prompts';
|
|
11
|
+
import WebTorrent from 'webtorrent';
|
|
12
|
+
|
|
13
|
+
/*
|
|
14
|
+
torrent-online — WebTorrent → VLC / браузер (ESM)
|
|
15
|
+
TLA-совместимо с WebTorrent 2.x на Node 18+.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// ---- CLI ----
|
|
19
|
+
const HELP = `torrent-online — стрим торрента в VLC или браузер
|
|
20
|
+
|
|
21
|
+
Использование:
|
|
22
|
+
torrent-online интерактивное меню
|
|
23
|
+
torrent-online <file.torrent| magnet:...> без меню (играют все видеофайлы)
|
|
24
|
+
|
|
25
|
+
Флаги:
|
|
26
|
+
--keep-cache не удалять кэш после выхода
|
|
27
|
+
--network-caching=<ms> кэш VLC (по умолчанию 3000)
|
|
28
|
+
--resume продолжить с прошлой позиции (VLC --start-time)
|
|
29
|
+
--lan слушать 0.0.0.0 + токен в URL (смотреть с телефона)
|
|
30
|
+
--no-vlc только браузер-стриминг, без VLC
|
|
31
|
+
--port=<N> фиксированный порт (иначе авто 8123..10122)
|
|
32
|
+
--help, -h эта справка`;
|
|
33
|
+
|
|
34
|
+
function parseCli() {
|
|
35
|
+
const opts = { keepCache: false, resume: false, lan: false, noVlc: false, caching: 3000, port: 0 };
|
|
36
|
+
const positional = [];
|
|
37
|
+
for (const a of process.argv.slice(2)) {
|
|
38
|
+
if (a === '--keep-cache') opts.keepCache = true;
|
|
39
|
+
else if (a === '--resume') opts.resume = true;
|
|
40
|
+
else if (a === '--lan') opts.lan = true;
|
|
41
|
+
else if (a === '--no-vlc') opts.noVlc = true;
|
|
42
|
+
else if (a === '--help' || a === '-h') { console.log(HELP); process.exit(0); }
|
|
43
|
+
else if (a.startsWith('--network-caching=')) opts.caching = Number(a.split('=')[1]) || 0;
|
|
44
|
+
else if (a.startsWith('--port=')) opts.port = Number(a.split('=')[1]) || 0;
|
|
45
|
+
else if (a.startsWith('--')) { console.error(`Неизвестный флаг: ${a}\n${HELP}`); process.exit(1); }
|
|
46
|
+
else positional.push(a);
|
|
47
|
+
}
|
|
48
|
+
if (positional.length > 1) { console.error('Только один источник: .torrent или magnet:'); process.exit(1); }
|
|
49
|
+
const srcArg = positional[0] || null;
|
|
50
|
+
if (srcArg && !srcArg.startsWith('magnet:') && !/^https?:\/\//.test(srcArg) && !srcArg.endsWith('.torrent')) {
|
|
51
|
+
console.error('Нужен файл .torrent или ссылка magnet:'); process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
return { opts, srcArg };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---- Константы ----
|
|
57
|
+
const PORT_MIN = 8123;
|
|
58
|
+
const PORT_MAX = 10122;
|
|
59
|
+
const META_TIMEOUT_MS = 60_000;
|
|
60
|
+
const DEFAULT_CACHE = path.join(os.homedir(), 'Movies', 'WebTorrent');
|
|
61
|
+
const WATCH_DIR = path.join(os.homedir(), 'Downloads', 'torrent-online-watch');
|
|
62
|
+
const STATE_FILE = path.join(os.homedir(), '.config', 'torrent-online', 'state.json');
|
|
63
|
+
const UA = 'Mozilla/5.0 (X11; Linux x86_64) torrent-online/1.4';
|
|
64
|
+
|
|
65
|
+
const VIDEO_EXT = new Set(['.mp4', '.m4v', '.mkv', '.mov', '.avi', '.webm', '.mpg', '.mpeg']);
|
|
66
|
+
const SUB_EXT = new Set(['.srt', '.ass', '.ssa', '.vtt']);
|
|
67
|
+
|
|
68
|
+
// ---- Утилиты ----
|
|
69
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
70
|
+
|
|
71
|
+
function naturalCompare(a, b) {
|
|
72
|
+
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function esc(s) {
|
|
76
|
+
return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function fmtBytes(n) {
|
|
80
|
+
if (!n) return '0 B';
|
|
81
|
+
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
82
|
+
const i = Math.min(u.length - 1, Math.floor(Math.log(n) / Math.log(1024)));
|
|
83
|
+
return (n / 1024 ** i).toFixed(i ? 1 : 0) + ' ' + u[i];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function decodeXml(s) {
|
|
87
|
+
return s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function localIP() {
|
|
91
|
+
for (const list of Object.values(os.networkInterfaces())) {
|
|
92
|
+
for (const ni of list || []) if (ni.family === 'IPv4' && !ni.internal) return ni.address;
|
|
93
|
+
}
|
|
94
|
+
return '127.0.0.1';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function listTorrentFiles(dir) {
|
|
98
|
+
try {
|
|
99
|
+
const items = await fsp.readdir(dir, { withFileTypes: true });
|
|
100
|
+
return items.filter(d => d.isFile() && d.name.endsWith('.torrent')).map(d => path.join(dir, d.name));
|
|
101
|
+
} catch { return []; }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function listTorrentFilesSortedByMtime(dir) {
|
|
105
|
+
const files = await listTorrentFiles(dir);
|
|
106
|
+
const detailed = await Promise.all(files.map(async full => {
|
|
107
|
+
try { return { full, mtime: (await fsp.stat(full)).mtimeMs }; }
|
|
108
|
+
catch { return { full, mtime: 0 }; }
|
|
109
|
+
}));
|
|
110
|
+
detailed.sort((a, b) => b.mtime - a.mtime);
|
|
111
|
+
return detailed.map(x => x.full);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function ensureDir(p) { await fsp.mkdir(p, { recursive: true }); }
|
|
115
|
+
|
|
116
|
+
async function loadState() {
|
|
117
|
+
try { return JSON.parse(await fsp.readFile(STATE_FILE, 'utf8')); } catch { return {}; }
|
|
118
|
+
}
|
|
119
|
+
async function saveState(st) {
|
|
120
|
+
await ensureDir(path.dirname(STATE_FILE));
|
|
121
|
+
await fsp.writeFile(STATE_FILE, JSON.stringify(st, null, 2));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---- Порты ----
|
|
125
|
+
function pickRandomPort() {
|
|
126
|
+
const span = PORT_MAX - PORT_MIN + 1;
|
|
127
|
+
return PORT_MIN + Math.floor(Math.random() * span);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function listenOnFreePort(server, host, fixedPort) {
|
|
131
|
+
if (fixedPort) {
|
|
132
|
+
await new Promise((resolve, reject) => {
|
|
133
|
+
server.once('error', reject);
|
|
134
|
+
server.once('listening', () => { server.off('error', reject); resolve(); });
|
|
135
|
+
server.listen(fixedPort, host);
|
|
136
|
+
});
|
|
137
|
+
return fixedPort;
|
|
138
|
+
}
|
|
139
|
+
let port = pickRandomPort();
|
|
140
|
+
for (let i = 0; i < 30; i++) {
|
|
141
|
+
try {
|
|
142
|
+
await new Promise((resolve, reject) => {
|
|
143
|
+
const onError = (err) => { server.off('listening', onListening); reject(err); };
|
|
144
|
+
const onListening = () => { server.off('error', onError); resolve(); };
|
|
145
|
+
server.once('error', onError);
|
|
146
|
+
server.once('listening', onListening);
|
|
147
|
+
server.listen(port, host);
|
|
148
|
+
});
|
|
149
|
+
return port;
|
|
150
|
+
} catch {
|
|
151
|
+
port = port + 1; if (port > PORT_MAX) port = PORT_MIN;
|
|
152
|
+
await sleep(10);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
throw new Error('Не удалось занять порт для HTTP-сервера');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---- VLC ----
|
|
159
|
+
function detectVLCPath() {
|
|
160
|
+
const macVLC = '/Applications/VLC.app/Contents/MacOS/VLC';
|
|
161
|
+
if (fs.existsSync(macVLC)) return macVLC;
|
|
162
|
+
return 'vlc';
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function checkVlc(bin) {
|
|
166
|
+
const r = spawnSync(bin, ['--version'], { encoding: 'utf8', timeout: 5000 });
|
|
167
|
+
if (r.error || r.status !== 0) {
|
|
168
|
+
throw new Error(`VLC не найден/не запускается ("${bin}"). Установите VLC или смотрите в браузере: --no-vlc (а при --no-vlc уже открыта страница).`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function buildVLCArgs({ urls, caching, subs, startAt }) {
|
|
173
|
+
const args = [...urls];
|
|
174
|
+
args.push('--play-and-exit', '--no-video-title-show');
|
|
175
|
+
if (caching > 0) args.push(`--network-caching=${caching}`);
|
|
176
|
+
for (const s of subs) args.push(`--sub-file=${s}`);
|
|
177
|
+
if (startAt > 0) args.push(`--start-time=${startAt}`);
|
|
178
|
+
return args;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---- HTTP-сервер ----
|
|
182
|
+
function contentTypeByExt(ext) {
|
|
183
|
+
switch (ext) {
|
|
184
|
+
case '.mp4': return 'video/mp4';
|
|
185
|
+
case '.m4v': return 'video/x-m4v';
|
|
186
|
+
case '.mkv': return 'video/x-matroska';
|
|
187
|
+
case '.mov': return 'video/quicktime';
|
|
188
|
+
case '.webm': return 'video/webm';
|
|
189
|
+
case '.avi': return 'video/x-msvideo';
|
|
190
|
+
case '.mpg': case '.mpeg': return 'video/mpeg';
|
|
191
|
+
default: return 'application/octet-stream';
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function makeServer(torrent, base) {
|
|
196
|
+
return http.createServer((req, res) => {
|
|
197
|
+
const u = new URL(req.url, 'http://127.0.0.1');
|
|
198
|
+
let p = u.pathname;
|
|
199
|
+
if (base) {
|
|
200
|
+
if (p !== base && !p.startsWith(base + '/')) { res.statusCode = 404; res.end('not found'); return; }
|
|
201
|
+
p = p.slice(base.length) || '/';
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (p === '/' || p === '') {
|
|
205
|
+
const list = torrent.files.map((f, i) =>
|
|
206
|
+
`<li><a href="${base}/view/${i}">${esc(f.path)}</a> — ${fmtBytes(f.length)}</li>`).join('');
|
|
207
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
208
|
+
res.end(`<!doctype html><meta charset="utf-8"><title>TorrentOnline</title>
|
|
209
|
+
<style>body{font-family:sans-serif;margin:2em;background:#111;color:#eee}a{color:#7fd}</style>
|
|
210
|
+
<h2>TorrentOnline</h2><ul>${list}</ul>`);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const mView = /^\/view\/(\d+)$/.exec(p);
|
|
215
|
+
if (mView) {
|
|
216
|
+
const i = Number(mView[1]);
|
|
217
|
+
const f = torrent.files[i];
|
|
218
|
+
if (!f) { res.statusCode = 404; res.end('not found'); return; }
|
|
219
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
220
|
+
res.end(`<!doctype html><meta charset="utf-8"><title>${esc(f.name)}</title>
|
|
221
|
+
<style>body{margin:0;background:#000}video{width:100vw;height:100vh}</style>
|
|
222
|
+
<video controls autoplay src="${base}/${i}"></video>`);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const mFile = /^\/(\d+)$/.exec(p);
|
|
227
|
+
if (!mFile) { res.statusCode = 404; res.end('not found'); return; }
|
|
228
|
+
const file = torrent.files[Number(mFile[1])];
|
|
229
|
+
if (!file) { res.statusCode = 404; res.end('not found'); return; }
|
|
230
|
+
|
|
231
|
+
const total = file.length;
|
|
232
|
+
res.setHeader('Accept-Ranges', 'bytes');
|
|
233
|
+
res.setHeader('Content-Type', contentTypeByExt(path.extname(file.name).toLowerCase()));
|
|
234
|
+
|
|
235
|
+
const range = req.headers.range;
|
|
236
|
+
if (range) {
|
|
237
|
+
const m = /^bytes=(\d*)-(\d*)$/.exec(range.trim());
|
|
238
|
+
if (!m || (m[1] === '' && m[2] === '')) {
|
|
239
|
+
res.writeHead(416, { 'Content-Range': `bytes */${total}` }); res.end(); return;
|
|
240
|
+
}
|
|
241
|
+
let start, end;
|
|
242
|
+
if (m[1] === '') { const n = Number(m[2]); start = Math.max(0, total - n); end = total - 1; }
|
|
243
|
+
else { start = Number(m[1]); end = m[2] === '' ? total - 1 : Number(m[2]); }
|
|
244
|
+
if (total === 0 || start >= total || end >= total || start > end) {
|
|
245
|
+
res.writeHead(416, { 'Content-Range': `bytes */${total}` }); res.end(); return;
|
|
246
|
+
}
|
|
247
|
+
res.writeHead(206, {
|
|
248
|
+
'Content-Range': `bytes ${start}-${end}/${total}`,
|
|
249
|
+
'Content-Length': end - start + 1
|
|
250
|
+
});
|
|
251
|
+
file.createReadStream({ start, end }).pipe(res);
|
|
252
|
+
} else {
|
|
253
|
+
res.setHeader('Content-Length', total);
|
|
254
|
+
file.createReadStream().pipe(res);
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ---- Поиск индексаторов ----
|
|
260
|
+
const TRANSLIT = { а:'a',б:'b',в:'v',г:'g',д:'d',е:'e',ё:'e',ж:'zh',з:'z',и:'i',й:'y',к:'k',л:'l',м:'m',н:'n',о:'o',п:'p',р:'r',с:'s',т:'t',у:'u',ф:'f',х:'kh',ц:'ts',ч:'ch',ш:'sh',щ:'shch',ъ:'',ы:'y',ь:'',э:'e',ю:'yu',я:'ya' };
|
|
261
|
+
function translit(s) { return s.toLowerCase().split('').map(c => TRANSLIT[c] ?? c).join(''); }
|
|
262
|
+
function btihKey(mag) { const m = /btih:([0-9a-fA-F]{40}|[0-9a-fA-F]{32})/.exec(mag); return m ? m[1].toLowerCase() : mag.slice(0, 90); }
|
|
263
|
+
|
|
264
|
+
async function search1337x(q) {
|
|
265
|
+
const r = await fetch(`https://1337x.st/api/v1/search/${encodeURIComponent(q)}/1/1/`, {
|
|
266
|
+
headers: { 'x-api-key': 'sk1337x73871873371873', 'user-agent': UA, accept: 'application/json' },
|
|
267
|
+
signal: AbortSignal.timeout(9000),
|
|
268
|
+
});
|
|
269
|
+
const text = await r.text();
|
|
270
|
+
if (!r.ok) throw new Error(`1337x: HTTP ${r.status}${/Just a moment/.test(text) ? ' (Cloudflare)' : ''}`);
|
|
271
|
+
let j;
|
|
272
|
+
try { j = JSON.parse(text); } catch { throw new Error('1337x: не JSON'); }
|
|
273
|
+
const out = [];
|
|
274
|
+
for (const it of j.data || []) {
|
|
275
|
+
const mag = it.magnetLink || it.magnet_link || it.torrent_magnet;
|
|
276
|
+
if (mag && mag.startsWith('magnet:'))
|
|
277
|
+
out.push({ name: String(it.name || ''), mag, size: String(it.size || ''), seeds: Number(it.seeders) || 0, src: '1337x' });
|
|
278
|
+
}
|
|
279
|
+
return out;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function searchTPB(q, host = 'https://tpb.party') {
|
|
283
|
+
const r = await fetch(`${host}/s/?q=${encodeURIComponent(q)}&page=0&sort=0`, {
|
|
284
|
+
headers: { 'user-agent': UA },
|
|
285
|
+
signal: AbortSignal.timeout(9000),
|
|
286
|
+
});
|
|
287
|
+
if (!r.ok) throw new Error(`TPB: HTTP ${r.status}`);
|
|
288
|
+
const html = await r.text();
|
|
289
|
+
const out = [];
|
|
290
|
+
for (const row of html.split('<tr>').slice(1)) {
|
|
291
|
+
const magRaw = /href="(magnet:[^"]+)"/.exec(row)?.[1];
|
|
292
|
+
const name = decodeXml(/title="Details for ([^"]*)"/.exec(row)?.[1] || '');
|
|
293
|
+
if (!magRaw || !name) continue;
|
|
294
|
+
const mag = decodeXml(magRaw);
|
|
295
|
+
const cells = [...row.matchAll(/<td[^>]*align="right"[^>]*>([^<]*)</g)]
|
|
296
|
+
.map(m => decodeXml(m[1]).replace(/ /g, ' ').trim());
|
|
297
|
+
out.push({ name, mag, size: cells[0] || '', seeds: Number(cells[1]) || 0, src: 'TPB' });
|
|
298
|
+
if (out.length >= 25) break;
|
|
299
|
+
}
|
|
300
|
+
return out;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function searchRutor(q) {
|
|
304
|
+
const r = await fetch(`https://rutor.info/rss.php?search=${encodeURIComponent(q)}`, {
|
|
305
|
+
headers: { 'user-agent': UA },
|
|
306
|
+
signal: AbortSignal.timeout(9000),
|
|
307
|
+
});
|
|
308
|
+
if (!r.ok) throw new Error(`Rutor: HTTP ${r.status}`);
|
|
309
|
+
const xml = await r.text();
|
|
310
|
+
const out = [];
|
|
311
|
+
for (const item of xml.split('<item>').slice(1, 26)) {
|
|
312
|
+
const title = decodeXml(/<title>([^<]*)<\/title>/.exec(item)?.[1] || '')
|
|
313
|
+
.replace(/\s*\([^()]*\.torrent\)\s*$/, '');
|
|
314
|
+
const link = /<link>([^<]+)<\/link>/.exec(item)?.[1] || '';
|
|
315
|
+
if (title && link.includes('download.php'))
|
|
316
|
+
out.push({ name: title, mag: link, size: '', seeds: 0, src: 'Rutor' });
|
|
317
|
+
}
|
|
318
|
+
return out;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function searchIndexers(q) {
|
|
322
|
+
const queries = [...new Set([q.trim(), translit(q.trim())].filter(Boolean))];
|
|
323
|
+
const results = [];
|
|
324
|
+
const notes = [];
|
|
325
|
+
const jobs = [];
|
|
326
|
+
jobs.push(searchRutor(queries[0]).then(rs => results.push(...rs), e => notes.push(e.message)));
|
|
327
|
+
for (const qq of queries) {
|
|
328
|
+
jobs.push(searchTPB(qq).then(rs => results.push(...rs), e => notes.push(e.message)));
|
|
329
|
+
jobs.push(search1337x(qq).then(rs => results.push(...rs), e => notes.push(e.message)));
|
|
330
|
+
}
|
|
331
|
+
await Promise.allSettled(jobs);
|
|
332
|
+
const seen = new Set();
|
|
333
|
+
const out = [];
|
|
334
|
+
for (const it of results) {
|
|
335
|
+
const k = btihKey(it.mag);
|
|
336
|
+
if (!seen.has(k)) { seen.add(k); out.push(it); }
|
|
337
|
+
}
|
|
338
|
+
out.sort((a, b) => (a.src === 'Rutor' ? 0 : 1) - (b.src === 'Rutor' ? 0 : 1) || b.seeds - a.seeds);
|
|
339
|
+
return { items: out.slice(0, 40), notes: [...new Set(notes)] };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ---- Промпты ----
|
|
343
|
+
async function promptSource() {
|
|
344
|
+
const groups = [
|
|
345
|
+
['Текущая папка (новые сверху)', await listTorrentFilesSortedByMtime(process.cwd())],
|
|
346
|
+
['~/Downloads (новые сверху)', await listTorrentFilesSortedByMtime(path.join(os.homedir(), 'Downloads'))],
|
|
347
|
+
['Watch-папка', await listTorrentFilesSortedByMtime(WATCH_DIR)],
|
|
348
|
+
];
|
|
349
|
+
const choices = [];
|
|
350
|
+
for (const [label, files] of groups) {
|
|
351
|
+
if (!files.length) continue;
|
|
352
|
+
choices.push(new Separator(label));
|
|
353
|
+
for (const f of files) choices.push({ name: path.basename(f), value: f });
|
|
354
|
+
}
|
|
355
|
+
choices.push(new Separator());
|
|
356
|
+
choices.push({ name: '🔎 Поиск (Rutor / TPB)', value: 'SEARCH' });
|
|
357
|
+
choices.push({ name: 'Вставить magnet-ссылку', value: 'MAGNET' });
|
|
358
|
+
choices.push({ name: 'Указать путь к .torrent', value: 'PATH' });
|
|
359
|
+
|
|
360
|
+
const src = await select({ message: 'Источник', choices, pageSize: 15 });
|
|
361
|
+
if (src === 'SEARCH') {
|
|
362
|
+
const q = await input({ message: 'Запрос (по-русски — Rutor, по-английски — TPB)' });
|
|
363
|
+
process.stdout.write('Ищу… ');
|
|
364
|
+
const { items, notes } = await searchIndexers(q);
|
|
365
|
+
process.stdout.write(`\r${items.length} результатов \n`);
|
|
366
|
+
for (const n of notes.slice(0, 3)) console.log(`⚠ ${n}`);
|
|
367
|
+
if (!items.length) throw new Error('Ничего не найдено');
|
|
368
|
+
return await select({
|
|
369
|
+
message: 'Найденное',
|
|
370
|
+
pageSize: 15,
|
|
371
|
+
choices: items.map(r => ({
|
|
372
|
+
name: `${r.src} · ${r.name.slice(0, 64)}${r.size ? ' · ' + r.size : ''}${r.seeds ? ' · 🌱' + r.seeds : ''}`,
|
|
373
|
+
value: r.mag,
|
|
374
|
+
})),
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
if (src === 'MAGNET') {
|
|
378
|
+
const mag = await input({ message: 'magnet:' });
|
|
379
|
+
if (!mag.startsWith('magnet:')) throw new Error('Нужна ссылка, которая начинается с magnet:');
|
|
380
|
+
return mag;
|
|
381
|
+
}
|
|
382
|
+
if (src === 'PATH') {
|
|
383
|
+
const p = await input({ message: 'Путь к .torrent' });
|
|
384
|
+
if (!p.endsWith('.torrent')) throw new Error('Нужен файл .torrent');
|
|
385
|
+
return p;
|
|
386
|
+
}
|
|
387
|
+
return src;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function promptFilter(files) {
|
|
391
|
+
const useFilter = await confirm({ message: 'Фильтр по имени?', default: false });
|
|
392
|
+
if (!useFilter) return files;
|
|
393
|
+
const f = await input({ message: 'Подстрока или /regex/' });
|
|
394
|
+
try {
|
|
395
|
+
if (f.length > 1 && f.startsWith('/') && f.endsWith('/')) {
|
|
396
|
+
const re = new RegExp(f.slice(1, -1), 'i');
|
|
397
|
+
return files.filter(x => re.test(x.name));
|
|
398
|
+
}
|
|
399
|
+
const s = f.toLowerCase();
|
|
400
|
+
return files.filter(x => x.name.toLowerCase().includes(s));
|
|
401
|
+
} catch {
|
|
402
|
+
console.error('Некорректный regex, игнорируем.');
|
|
403
|
+
return files;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async function promptSelect(shown) {
|
|
408
|
+
const picked = await checkbox({
|
|
409
|
+
message: 'Выбери файлы для проигрывания',
|
|
410
|
+
pageSize: 20,
|
|
411
|
+
choices: shown.map((f, i) => ({ name: `${f.name} (${fmtBytes(f.length)})`, value: i })),
|
|
412
|
+
});
|
|
413
|
+
if (!picked.length) throw new Error('Ничего не выбрано');
|
|
414
|
+
return picked.map(i => shown[i].idx);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async function promptCacheDir() {
|
|
418
|
+
const dir = await input({
|
|
419
|
+
message: 'Куда складывать кэш? ВНИМАНИЕ: папка будет удалена после выхода (кроме --keep-cache)',
|
|
420
|
+
default: DEFAULT_CACHE,
|
|
421
|
+
});
|
|
422
|
+
await ensureDir(dir);
|
|
423
|
+
return dir;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ---- Торрент ----
|
|
427
|
+
async function addTorrent(client, source, cacheDir) {
|
|
428
|
+
return new Promise((resolve, reject) => {
|
|
429
|
+
const to = setTimeout(
|
|
430
|
+
() => reject(new Error(`Метаданные не получены за ${META_TIMEOUT_MS / 1000} с — проверьте magnet/трекеры`)),
|
|
431
|
+
META_TIMEOUT_MS
|
|
432
|
+
);
|
|
433
|
+
client.add(source, { path: cacheDir }, t => {
|
|
434
|
+
if (t.ready) { clearTimeout(to); return resolve(t); }
|
|
435
|
+
const spin = setInterval(() => {
|
|
436
|
+
process.stdout.write(`\r⏳ Метаданные: пиры ${t.peers.length}, получено ${fmtBytes(t.received)} `);
|
|
437
|
+
}, 1000);
|
|
438
|
+
t.once('ready', () => { clearInterval(spin); process.stdout.write('\x1b[K'); clearTimeout(to); resolve(t); });
|
|
439
|
+
});
|
|
440
|
+
client.on('error', e => { clearTimeout(to); reject(e); });
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function startProgress(torrent) {
|
|
445
|
+
const t = setInterval(() => {
|
|
446
|
+
process.stdout.write(
|
|
447
|
+
`\r⏬ ${(torrent.progress * 100).toFixed(1)}% · ${fmtBytes(torrent.downloadSpeed)}/s · пиры: ${torrent.peers.length} `
|
|
448
|
+
);
|
|
449
|
+
}, 1000);
|
|
450
|
+
return () => { clearInterval(t); process.stdout.write('\x1b[K\n'); };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async function collectSubtitles(torrent, chosenIdx) {
|
|
454
|
+
const subs = torrent.files.filter(f => SUB_EXT.has(path.extname(f.name).toLowerCase()));
|
|
455
|
+
if (!subs.length) return [];
|
|
456
|
+
const videos = chosenIdx.map(i => path.basename(torrent.files[i].name, path.extname(torrent.files[i].name)).toLowerCase());
|
|
457
|
+
const wanted = subs.filter(s => {
|
|
458
|
+
const sb = path.basename(s.name, path.extname(s.name)).toLowerCase();
|
|
459
|
+
return videos.some(v => v === sb || v.startsWith(sb) || sb.startsWith(v)) || ['subs', 'subtitles', 'субтитры'].includes(sb);
|
|
460
|
+
});
|
|
461
|
+
const subDir = path.join(os.tmpdir(), 'torrent-online-subs');
|
|
462
|
+
await ensureDir(subDir);
|
|
463
|
+
const out = [];
|
|
464
|
+
for (const s of wanted.slice(0, 4)) {
|
|
465
|
+
const dest = path.join(subDir, path.basename(s.name));
|
|
466
|
+
try {
|
|
467
|
+
await Promise.race([s.downloadToPath(dest), sleep(20000).then(() => { throw new Error('sub timeout'); })]);
|
|
468
|
+
out.push(dest);
|
|
469
|
+
} catch {}
|
|
470
|
+
}
|
|
471
|
+
return out;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async function cleanCache(dir) {
|
|
475
|
+
try { await fsp.rm(dir, { recursive: true, force: true }); }
|
|
476
|
+
catch (e) { console.warn('Не удалось удалить кэш:', e.message); }
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// ---- Watch folder ----
|
|
480
|
+
function watchFolder() {
|
|
481
|
+
ensureDir(WATCH_DIR).then(() => {
|
|
482
|
+
try {
|
|
483
|
+
fs.watch(WATCH_DIR, { persistent: false }, (ev, file) => {
|
|
484
|
+
if (file && file.endsWith('.torrent')) console.log(`📥 Новый торрент в watch-папке: ${WATCH_DIR}/${file}`);
|
|
485
|
+
});
|
|
486
|
+
} catch {}
|
|
487
|
+
}).catch(() => {});
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// ---- Main ----
|
|
491
|
+
async function main() {
|
|
492
|
+
const { opts, srcArg } = parseCli();
|
|
493
|
+
watchFolder();
|
|
494
|
+
|
|
495
|
+
const source = srcArg || await promptSource();
|
|
496
|
+
let cacheDir;
|
|
497
|
+
if (srcArg) { cacheDir = DEFAULT_CACHE; await ensureDir(cacheDir); console.log(`Кэш: ${cacheDir}`); }
|
|
498
|
+
else cacheDir = await promptCacheDir();
|
|
499
|
+
|
|
500
|
+
const client = new WebTorrent({ destroyStoreOnDestroy: true });
|
|
501
|
+
const torrent = await addTorrent(client, source, cacheDir);
|
|
502
|
+
|
|
503
|
+
const files = torrent.files
|
|
504
|
+
.map((f, idx) => ({ name: f.path, idx, ext: path.extname(f.name).toLowerCase(), length: f.length }))
|
|
505
|
+
.filter(f => VIDEO_EXT.has(f.ext))
|
|
506
|
+
.sort((a, b) => naturalCompare(a.name, b.name));
|
|
507
|
+
if (!files.length) throw new Error('Видео файлов не найдено');
|
|
508
|
+
|
|
509
|
+
let chosenTorrentIdx;
|
|
510
|
+
if (srcArg) {
|
|
511
|
+
chosenTorrentIdx = files.map(f => f.idx);
|
|
512
|
+
console.log(`Плейлист: ${files.map(f => f.name).join(', ')}`);
|
|
513
|
+
} else {
|
|
514
|
+
const shown = await promptFilter(files);
|
|
515
|
+
if (!shown.length) throw new Error('После фильтра не осталось файлов');
|
|
516
|
+
chosenTorrentIdx = await promptSelect(shown);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
torrent.files.forEach(f => f.deselect());
|
|
520
|
+
chosenTorrentIdx.forEach(i => torrent.files[i].select());
|
|
521
|
+
|
|
522
|
+
// Resume
|
|
523
|
+
const state = await loadState();
|
|
524
|
+
let startAt = 0;
|
|
525
|
+
if (opts.resume && chosenTorrentIdx.length === 1) {
|
|
526
|
+
const key = `${torrent.infoHash}:${chosenTorrentIdx[0]}`;
|
|
527
|
+
startAt = state[key]?.seconds || 0;
|
|
528
|
+
if (startAt) console.log(`▶️ Продолжаю с ${Math.floor(startAt / 60)}:${String(startAt % 60).padStart(2, '0')}`);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Субтитры (для VLC)
|
|
532
|
+
const host = opts.lan ? '0.0.0.0' : '127.0.0.1';
|
|
533
|
+
const token = opts.lan ? crypto.randomBytes(8).toString('hex') : '';
|
|
534
|
+
const base = token ? `/t/${token}` : '';
|
|
535
|
+
|
|
536
|
+
const server = makeServer(torrent, base);
|
|
537
|
+
const port = await listenOnFreePort(server, host, opts.port);
|
|
538
|
+
const urls = chosenTorrentIdx.map(i => `http://127.0.0.1:${port}${base}/${i}`);
|
|
539
|
+
|
|
540
|
+
let vlc = null, vlcStart = 0;
|
|
541
|
+
let subs = [];
|
|
542
|
+
if (!opts.noVlc) {
|
|
543
|
+
const vlcBin = detectVLCPath();
|
|
544
|
+
checkVlc(vlcBin);
|
|
545
|
+
subs = await collectSubtitles(torrent, chosenTorrentIdx);
|
|
546
|
+
if (subs.length) console.log('Субтитры:', subs.map(s => path.basename(s)).join(', '));
|
|
547
|
+
const args = buildVLCArgs({ urls, caching: opts.caching, subs, startAt: chosenTorrentIdx.length === 1 ? startAt : 0 });
|
|
548
|
+
console.log('VLC args:', args.join(' '));
|
|
549
|
+
vlc = spawn(vlcBin, args, { stdio: 'ignore' });
|
|
550
|
+
vlc.on('error', e => console.error('Не удалось запустить VLC:', e.message));
|
|
551
|
+
vlcStart = Date.now();
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
console.log(`🌐 Страница: http://127.0.0.1:${port}${base}/`);
|
|
555
|
+
if (opts.lan) console.log(`📱 В сети: http://${localIP()}:${port}${base}/ (токен в URL)`);
|
|
556
|
+
|
|
557
|
+
const stopProgress = startProgress(torrent);
|
|
558
|
+
|
|
559
|
+
let cleaning = false;
|
|
560
|
+
async function shutdown(reason, code = 0) {
|
|
561
|
+
if (cleaning) return; cleaning = true;
|
|
562
|
+
stopProgress();
|
|
563
|
+
try { vlc?.kill(); } catch {}
|
|
564
|
+
try { server.close(); } catch {}
|
|
565
|
+
if (vlc) {
|
|
566
|
+
const elapsed = Math.round((Date.now() - vlcStart) / 1000);
|
|
567
|
+
for (const i of chosenTorrentIdx) {
|
|
568
|
+
const key = `${torrent.infoHash}:${i}`;
|
|
569
|
+
state[key] = { seconds: (state[key]?.seconds || 0) + elapsed, ts: Date.now() };
|
|
570
|
+
}
|
|
571
|
+
await saveState(state).catch(() => {});
|
|
572
|
+
}
|
|
573
|
+
try { await new Promise(r => torrent.destroy(r)); } catch {}
|
|
574
|
+
try { await new Promise(r => client.destroy(r)); } catch {}
|
|
575
|
+
if (!opts.keepCache) await cleanCache(cacheDir);
|
|
576
|
+
process.exit(code);
|
|
577
|
+
}
|
|
578
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
579
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
580
|
+
const NET_NOISE = /premature|aborted|EPIPE|ECONNRESET|ETIMEDOUT|socket|stream/i;
|
|
581
|
+
process.on('unhandledRejection', e => {
|
|
582
|
+
const msg = (e && (e.message || e)) + '';
|
|
583
|
+
if (NET_NOISE.test(msg)) return;
|
|
584
|
+
console.error('unhandledRejection:', msg);
|
|
585
|
+
});
|
|
586
|
+
process.on('uncaughtException', e => {
|
|
587
|
+
const msg = (e && (e.message || e)) + '';
|
|
588
|
+
if (NET_NOISE.test(msg)) return;
|
|
589
|
+
console.error('uncaughtException:', msg);
|
|
590
|
+
shutdown('uncaught', 1);
|
|
591
|
+
});
|
|
592
|
+
if (vlc) vlc.on('exit', () => shutdown('VLC exit'));
|
|
593
|
+
if (opts.noVlc) {
|
|
594
|
+
console.log('Смотрите в браузере; Ctrl+C — выход.');
|
|
595
|
+
process.on('SIGINT', () => {}); // shutdown уже зарегистрирован выше
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const isMain = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
|
|
600
|
+
if (isMain) main().catch(err => { console.error('Ошибка:', err?.stack || err?.message || err); process.exit(1); });
|
|
601
|
+
|
|
602
|
+
export { makeServer, naturalCompare, fmtBytes, esc, buildVLCArgs, contentTypeByExt, searchIndexers, searchTPB, translit };
|
|
603
|
+
|