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 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
+ ![Меню / Menu](docs/menu.png)
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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/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(/&nbsp;/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
+