torrent-tui 0.0.3 → 0.0.5
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/README.md +65 -11
- package/man/torrent-tui.1 +61 -0
- package/package.json +8 -3
- package/src/app.ts +59 -12
- package/src/cli/info.ts +133 -0
- package/src/cli/parse.ts +82 -0
- package/src/config/settings.ts +22 -0
- package/src/constants/index.ts +1 -1
- package/src/controllers/app-controller.ts +62 -6
- package/src/index.ts +124 -35
- package/src/layout/add-torrent-dialog.ts +288 -52
- package/src/layout/content-window.ts +2 -0
- package/src/layout/detail-panel.ts +133 -38
- package/src/layout/file-picker-dialog.ts +344 -0
- package/src/layout/toast-manager.ts +1 -1
- package/src/layout/toast.ts +0 -6
- package/src/layout/torrent-view.ts +89 -33
- package/src/store/index.ts +6 -0
- package/src/torrent/blocklist.ts +171 -0
- package/src/torrent/bridge.ts +505 -66
- package/src/torrent/dht/node.ts +380 -0
- package/src/torrent/dht/protocol.ts +225 -0
- package/src/torrent/dht/routing.ts +98 -0
- package/src/torrent/discovery/coordinator.ts +261 -0
- package/src/torrent/discovery/lsd.ts +115 -0
- package/src/torrent/downloader.ts +550 -22
- package/src/torrent/magnet-resolver.ts +245 -0
- package/src/torrent/magnet.ts +103 -0
- package/src/torrent/metadata-cache.ts +152 -0
- package/src/torrent/metadata.ts +78 -6
- package/src/torrent/peer/connection.ts +292 -18
- package/src/torrent/peer/extension.ts +270 -0
- package/src/torrent/peer/handshake.ts +2 -0
- package/src/torrent/peer/manager.ts +201 -16
- package/src/torrent/peer/mse.ts +385 -0
- package/src/torrent/peer/protocol.ts +13 -0
- package/src/torrent/piece-picker.ts +3 -1
- package/src/torrent/resume.ts +21 -0
- package/src/torrent/session.ts +9 -2
- package/src/torrent/storage.ts +43 -5
- package/src/torrent/tracker/announce.ts +42 -18
- package/src/torrent/tracker/coordinator.ts +255 -0
- package/src/torrent/tracker/http-tracker.ts +54 -30
- package/src/torrent/tracker/udp-tracker.ts +38 -19
- package/src/torrent/types.ts +19 -0
- package/src/torrent/upload-accounting.ts +35 -0
- package/src/utils/filter.ts +1 -0
- package/src/torrent/get_peers.ts +0 -212
package/README.md
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
**A terminal BitTorrent client for focused download management.** Add `.torrent` files, track active transfers, and manage sessions from a clean keyboard-driven interface.
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/torrent-tui)
|
|
6
|
-
[](https://www.npmjs.com/package/torrent-tui)
|
|
7
6
|
[](https://www.npmjs.com/package/torrent-tui)
|
|
8
7
|
[](https://github.com/ryadios/torrent-tui/actions/workflows/ci.yml)
|
|
9
8
|
[](./LICENSE)
|
|
@@ -53,33 +52,48 @@ From inside the app:
|
|
|
53
52
|
| Key | Action |
|
|
54
53
|
| --- | --- |
|
|
55
54
|
| `j` / `k` or arrow keys | Move selection |
|
|
56
|
-
| `Tab` | Change focus |
|
|
57
|
-
| `a` | Add a `.torrent` file |
|
|
55
|
+
| `Tab` / `Shift+Tab` | Change focus |
|
|
56
|
+
| `a` | Add a `.torrent` file or magnet link |
|
|
57
|
+
| `/` in add dialog | Type a magnet link manually |
|
|
58
58
|
| `Space` | Pause or resume the selected torrent |
|
|
59
59
|
| `d` | Remove the selected torrent |
|
|
60
60
|
| `D` | Remove the selected torrent and downloaded files |
|
|
61
61
|
| `q` | Quit |
|
|
62
62
|
|
|
63
|
+
The detail panel has `Pieces`, `Peers`, and `Files` tabs. Focus it with `Tab`, then use `h` / `l`, `[` / `]`, or left/right arrows to switch tabs. Multi-file torrents open a file picker before download; use `Space` to toggle a file, `a` to select all, `n` to select none, and `Enter` to confirm.
|
|
64
|
+
|
|
63
65
|
## Commands
|
|
64
66
|
|
|
65
|
-
The package also exposes
|
|
67
|
+
The package also exposes command-line workflows around the same torrent engine:
|
|
66
68
|
|
|
67
69
|
```bash
|
|
68
70
|
torrent-tui --help
|
|
69
71
|
torrent-tui --version
|
|
70
72
|
torrent-tui file.torrent
|
|
73
|
+
torrent-tui 'magnet:?xt=urn:btih:...'
|
|
71
74
|
torrent-tui file.torrent --verify
|
|
72
75
|
torrent-tui file.torrent --handshake
|
|
73
76
|
torrent-tui file.torrent --download
|
|
77
|
+
torrent-tui 'magnet:?xt=urn:btih:...' --download
|
|
78
|
+
torrent-tui file.torrent --info
|
|
79
|
+
torrent-tui file.torrent --info --json
|
|
80
|
+
torrent-tui 'magnet:?xt=urn:btih:...' --info
|
|
74
81
|
```
|
|
75
82
|
|
|
76
83
|
| Command | Description |
|
|
77
84
|
| --- | --- |
|
|
78
85
|
| `torrent-tui` | Start the terminal UI. |
|
|
79
86
|
| `torrent-tui <file.torrent>` | Start the TUI and add the torrent. |
|
|
80
|
-
| `torrent-tui <
|
|
87
|
+
| `torrent-tui <magnet-uri>` | Start the TUI, fetch magnet metadata, cache it, and start the torrent. |
|
|
88
|
+
| `torrent-tui <file.torrent> --verify` | Create storage, verify local pieces, and print a tracker summary. |
|
|
81
89
|
| `torrent-tui <file.torrent> --handshake` | Connect to peers and print a connection summary. |
|
|
82
90
|
| `torrent-tui <file.torrent> --download` | Run the downloader without launching the TUI. |
|
|
91
|
+
| `torrent-tui <magnet-uri> --download` | Fetch magnet metadata, cache it, then run the downloader without launching the TUI. |
|
|
92
|
+
| `torrent-tui <file.torrent> --info` | Print torrent metadata without launching the TUI. |
|
|
93
|
+
| `torrent-tui <file.torrent> --info --json` | Print torrent metadata as machine-readable JSON. |
|
|
94
|
+
| `torrent-tui <magnet-uri> --info` | Print cached magnet metadata without launching the TUI. |
|
|
95
|
+
|
|
96
|
+
Magnet support covers BitTorrent v1 `btih` magnets with trackers (`tr`), explicit peers (`x.pe`), or DHT-discovered peers. After metadata is cached, `--verify`, `--handshake`, and `--info` can use the same magnet URI.
|
|
83
97
|
|
|
84
98
|
## Configuration
|
|
85
99
|
|
|
@@ -95,7 +109,18 @@ Default settings:
|
|
|
95
109
|
{
|
|
96
110
|
"downloadPath": "~/Downloads",
|
|
97
111
|
"maxConnections": 50,
|
|
98
|
-
"torrentFolder": "~/Downloads"
|
|
112
|
+
"torrentFolder": "~/Downloads",
|
|
113
|
+
"downloadRateLimitBps": 0,
|
|
114
|
+
"uploadRateLimitBps": 0,
|
|
115
|
+
"enableWebSeeds": true,
|
|
116
|
+
"maxWebSeedConnections": 3,
|
|
117
|
+
"webSeedMaxRequestBytes": 16777216,
|
|
118
|
+
"blocklistEnabled": false,
|
|
119
|
+
"blocklistPaths": [],
|
|
120
|
+
"blocklistUrl": "",
|
|
121
|
+
"blocklistRefreshHours": 168,
|
|
122
|
+
"encryption": "preferred",
|
|
123
|
+
"enableLsd": true
|
|
99
124
|
}
|
|
100
125
|
```
|
|
101
126
|
|
|
@@ -120,6 +145,7 @@ The TUI shows detailed per-torrent states while keeping the sidebar filters simp
|
|
|
120
145
|
| State | Meaning |
|
|
121
146
|
| --- | --- |
|
|
122
147
|
| `Queued` | The `.torrent` was accepted and is waiting for engine startup. |
|
|
148
|
+
| `Metadata` | A magnet link was accepted and metadata is being fetched from peers. |
|
|
123
149
|
| `Checking` | Local files are being checked against torrent piece hashes. |
|
|
124
150
|
| `Connecting` | Trackers were contacted and the client is connecting to peers. |
|
|
125
151
|
| `Downloading` | Pieces are actively being requested or received. |
|
|
@@ -127,6 +153,7 @@ The TUI shows detailed per-torrent states while keeping the sidebar filters simp
|
|
|
127
153
|
| `Paused` | The active downloader was paused by the user. |
|
|
128
154
|
| `Seeding` | All pieces are present and the torrent can upload to peers. |
|
|
129
155
|
| `Stopped` | The torrent is saved in the session but not running. |
|
|
156
|
+
| `Missing` | Previously tracked files are missing from disk after restore or recheck. |
|
|
130
157
|
| `Error` | Startup, storage, or torrent metadata handling failed. |
|
|
131
158
|
|
|
132
159
|
The `Downloading` sidebar filter includes queued, checking, connecting, downloading, and stalled torrents so active work stays grouped together.
|
|
@@ -138,12 +165,26 @@ The `Downloading` sidebar filter includes queued, checking, connecting, download
|
|
|
138
165
|
| `downloadPath` | Where torrent payload files are written and verified. | On torrent add, resume, verify, and startup restore. |
|
|
139
166
|
| `torrentFolder` | Folder shown by the add-torrent dialog. | When you open the add dialog. |
|
|
140
167
|
| `maxConnections` | Maximum number of peers the client will connect to per torrent. | During peer discovery and download. |
|
|
168
|
+
| `downloadRateLimitBps` | Download speed cap in bytes per second. `0` means unlimited. | During downloads. |
|
|
169
|
+
| `uploadRateLimitBps` | Upload speed cap in bytes per second. `0` means unlimited. | During uploads to peers. |
|
|
170
|
+
| `enableWebSeeds` | Enables BEP 19 HTTP web seed downloads. | For torrents with `url-list` web seeds. |
|
|
171
|
+
| `maxWebSeedConnections` | Maximum concurrent web seed workers. | During web seed downloads. |
|
|
172
|
+
| `webSeedMaxRequestBytes` | Largest HTTP range request sent to a web seed. | During web seed downloads. |
|
|
173
|
+
| `blocklistEnabled` | Enables peer blocklist filtering. | Before peer connections are accepted or opened. |
|
|
174
|
+
| `blocklistPaths` | Local blocklist files to load. | When blocklists are enabled. |
|
|
175
|
+
| `blocklistUrl` | Optional remote blocklist URL to cache and load. | When blocklists are enabled. |
|
|
176
|
+
| `blocklistRefreshHours` | Remote blocklist cache refresh interval. | When `blocklistUrl` is configured. |
|
|
177
|
+
| `encryption` | Peer encryption policy: `allowed`, `preferred`, or `required`. | During peer connection setup. |
|
|
178
|
+
| `enableLsd` | Enables local peer discovery on the LAN. | For non-private torrents. |
|
|
141
179
|
|
|
142
180
|
### Tuning Tips
|
|
143
181
|
|
|
144
182
|
- Use a fast local SSD for `downloadPath` if you want quicker verification and fewer stalls on reopen.
|
|
145
183
|
- Point `torrentFolder` at the directory where you keep `.torrent` files so adding torrents is faster.
|
|
146
184
|
- Lower `maxConnections` if your network or CPU struggles with many peers; raise it if you want more parallel peer selection.
|
|
185
|
+
- Use `downloadRateLimitBps` and `uploadRateLimitBps` when you need bandwidth caps.
|
|
186
|
+
- Set `encryption` to `required` only if you want to reject plaintext peers.
|
|
187
|
+
- Enable blocklists only with lists you trust; malformed or unreachable lists are ignored or fall back to cached data.
|
|
147
188
|
- Fresh torrents skip full zero-file verification. Existing files are checked cooperatively, so the TUI should stay responsive during large rechecks.
|
|
148
189
|
- If a torrent stays `Stalled`, the client did not find a usable peer. Try again later with `Space`, or check tracker availability.
|
|
149
190
|
- Settings are read when the app starts. If you edit `settings.json` manually, restart the app to pick up the changes.
|
|
@@ -152,7 +193,7 @@ The `Downloading` sidebar filter includes queued, checking, connecting, download
|
|
|
152
193
|
|
|
153
194
|
## Status
|
|
154
195
|
|
|
155
|
-
`
|
|
196
|
+
`torrent-tui` is an early Bun-first torrent client with a TUI and CLI inspection workflows.
|
|
156
197
|
|
|
157
198
|
| Area | Status |
|
|
158
199
|
| --- | --- |
|
|
@@ -161,7 +202,16 @@ The `Downloading` sidebar filter includes queued, checking, connecting, download
|
|
|
161
202
|
| Peer handshakes and piece download | Available |
|
|
162
203
|
| Resume data | Available |
|
|
163
204
|
| Multi-torrent TUI | Available |
|
|
164
|
-
| Magnet links |
|
|
205
|
+
| Magnet links | Available for v1 magnets with tracker, explicit-peer, or DHT discovery |
|
|
206
|
+
| Detail panel | Pieces, peers, and files tabs |
|
|
207
|
+
| File selection | Available for multi-file torrents before download |
|
|
208
|
+
| Engine controls | Download/upload rate limits and max peer connections |
|
|
209
|
+
| Peer discovery | Trackers, DHT, PEX, and LSD |
|
|
210
|
+
| Web seeds | BEP 19 HTTP web seeds |
|
|
211
|
+
| Peer filtering | Local or cached remote blocklists |
|
|
212
|
+
| Protocol encryption | MSE/PE with allowed, preferred, or required policy |
|
|
213
|
+
| Padding files | BEP 47 padding files are hidden from payload file lists |
|
|
214
|
+
| CLI inspection | `--info`, `--info --json`, and man page packaging |
|
|
165
215
|
| Standalone binaries | Not included yet |
|
|
166
216
|
|
|
167
217
|
## Development
|
|
@@ -175,7 +225,9 @@ Before opening a PR:
|
|
|
175
225
|
|
|
176
226
|
```bash
|
|
177
227
|
bun run typecheck
|
|
178
|
-
bun
|
|
228
|
+
bun test
|
|
229
|
+
bun run smoke
|
|
230
|
+
npm publish --dry-run
|
|
179
231
|
```
|
|
180
232
|
|
|
181
233
|
For formatting and lint fixes:
|
|
@@ -188,12 +240,14 @@ bun run check:fix
|
|
|
188
240
|
|
|
189
241
|
Releases are published from GitHub Actions with generated GitHub release notes.
|
|
190
242
|
|
|
191
|
-
1. Update `package.json`
|
|
243
|
+
1. Update `package.json` to the new version.
|
|
192
244
|
2. Run local checks:
|
|
193
245
|
|
|
194
246
|
```bash
|
|
195
247
|
bun run typecheck
|
|
196
|
-
bun
|
|
248
|
+
bun test
|
|
249
|
+
bun run smoke
|
|
250
|
+
npm publish --dry-run
|
|
197
251
|
```
|
|
198
252
|
|
|
199
253
|
3. Commit and push the version change.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
.TH TORRENT-TUI 1
|
|
2
|
+
.SH NAME
|
|
3
|
+
torrent-tui \- Bun-powered terminal BitTorrent client
|
|
4
|
+
.SH SYNOPSIS
|
|
5
|
+
.B torrent-tui
|
|
6
|
+
[\fIfile.torrent\fR|\fImagnet-uri\fR]
|
|
7
|
+
[\fB--verify\fR|\fB--handshake\fR|\fB--download\fR|\fB--info\fR]
|
|
8
|
+
[\fB--json\fR]
|
|
9
|
+
.SH DESCRIPTION
|
|
10
|
+
.B torrent-tui
|
|
11
|
+
starts a terminal BitTorrent client for adding, inspecting, and managing torrents.
|
|
12
|
+
It can also run selected torrent engine workflows directly from the command line.
|
|
13
|
+
.SH OPTIONS
|
|
14
|
+
.TP
|
|
15
|
+
.B --help, -h
|
|
16
|
+
Show command help.
|
|
17
|
+
.TP
|
|
18
|
+
.B --version, -v
|
|
19
|
+
Print the package version.
|
|
20
|
+
.TP
|
|
21
|
+
.B --verify
|
|
22
|
+
Verify local pieces for a torrent and print a tracker summary.
|
|
23
|
+
.TP
|
|
24
|
+
.B --handshake
|
|
25
|
+
Contact trackers, connect to peers, and print a handshake summary.
|
|
26
|
+
.TP
|
|
27
|
+
.B --download
|
|
28
|
+
Download a torrent without starting the terminal UI.
|
|
29
|
+
.TP
|
|
30
|
+
.B --info
|
|
31
|
+
Print torrent metadata without starting the terminal UI.
|
|
32
|
+
.TP
|
|
33
|
+
.B --json
|
|
34
|
+
Print machine-readable JSON for
|
|
35
|
+
.BR --info .
|
|
36
|
+
.SH COMMANDS
|
|
37
|
+
.TP
|
|
38
|
+
.B torrent-tui
|
|
39
|
+
Start the terminal UI.
|
|
40
|
+
.TP
|
|
41
|
+
.B torrent-tui file.torrent
|
|
42
|
+
Start the terminal UI and add a torrent.
|
|
43
|
+
.TP
|
|
44
|
+
.B torrent-tui magnet-uri
|
|
45
|
+
Start the terminal UI and fetch magnet metadata.
|
|
46
|
+
.TP
|
|
47
|
+
.B torrent-tui file.torrent --info
|
|
48
|
+
Print a framed metadata summary with padded lowercase labels, matching other
|
|
49
|
+
command summary output.
|
|
50
|
+
.TP
|
|
51
|
+
.B torrent-tui file.torrent --info --json
|
|
52
|
+
Print the same metadata as pretty JSON for scripts.
|
|
53
|
+
.SH FILES
|
|
54
|
+
.TP
|
|
55
|
+
.I ${XDG_CONFIG_HOME:-~/.config}/torrent-tui/settings.json
|
|
56
|
+
User settings.
|
|
57
|
+
.TP
|
|
58
|
+
.I ${XDG_DATA_HOME:-~/.local/share}/torrent-tui/session.json
|
|
59
|
+
Session registry restored by the TUI.
|
|
60
|
+
.SH SEE ALSO
|
|
61
|
+
.BR bun (1)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "torrent-tui",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5",
|
|
4
4
|
"description": "A Bun-powered terminal BitTorrent client.",
|
|
5
5
|
"module": "src/index.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -9,8 +9,12 @@
|
|
|
9
9
|
"bin": {
|
|
10
10
|
"torrent-tui": "bin/torrent-tui"
|
|
11
11
|
},
|
|
12
|
+
"man": [
|
|
13
|
+
"man/torrent-tui.1"
|
|
14
|
+
],
|
|
12
15
|
"files": [
|
|
13
16
|
"bin",
|
|
17
|
+
"man",
|
|
14
18
|
"src",
|
|
15
19
|
"README.md",
|
|
16
20
|
"LICENSE"
|
|
@@ -18,15 +22,16 @@
|
|
|
18
22
|
"scripts": {
|
|
19
23
|
"start": "bun src/index.ts",
|
|
20
24
|
"dev": "bun --watch src/index.ts",
|
|
21
|
-
"check": "bun
|
|
25
|
+
"check": "bun run typecheck && bun test",
|
|
22
26
|
"smoke": "bin/torrent-tui --help && bin/torrent-tui --version && if bin/torrent-tui nope.txt; then echo \"Expected invalid torrent path to fail\"; exit 1; fi",
|
|
23
27
|
"bench:startup": "bun scripts/benchmark-startup.ts",
|
|
28
|
+
"bench:webseed": "bun scripts/benchmark-webseed.ts",
|
|
24
29
|
"bench:verify": "bun scripts/benchmark-verify.ts",
|
|
25
30
|
"test": "bun test",
|
|
26
31
|
"test:watch": "bun test --watch",
|
|
27
32
|
"typecheck": "tsc --noEmit",
|
|
28
33
|
"check:fix": "biome check --write --unsafe",
|
|
29
|
-
"release:check": "bun run typecheck && bun test && bun publish --dry-run"
|
|
34
|
+
"release:check": "bun run typecheck && bun test && bun run smoke && npm publish --dry-run"
|
|
30
35
|
},
|
|
31
36
|
"keywords": [
|
|
32
37
|
"bittorrent",
|
package/src/app.ts
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type CliRenderer,
|
|
3
|
+
createCliRenderer,
|
|
4
|
+
type PasteEvent,
|
|
5
|
+
} from "@opentui/core";
|
|
2
6
|
import { loadConfig } from "./config";
|
|
3
7
|
import { AppController } from "./controllers/app-controller";
|
|
4
8
|
import { AddTorrentDialog } from "./layout/add-torrent-dialog";
|
|
5
9
|
import { ConfirmDialog } from "./layout/confirm-dialog";
|
|
6
10
|
import { ContentWindow } from "./layout/content-window";
|
|
11
|
+
import { FilePickerDialog } from "./layout/file-picker-dialog";
|
|
7
12
|
import { Sidebar } from "./layout/sidebar";
|
|
8
13
|
import { StatusBar } from "./layout/status-bar";
|
|
9
14
|
import { ToastManager } from "./layout/toast-manager";
|
|
10
15
|
import { Store } from "./store";
|
|
11
16
|
import { TorrentBridge } from "./torrent/bridge";
|
|
17
|
+
import { isMagnetUri } from "./torrent/magnet";
|
|
12
18
|
import type { LayoutDimensions } from "./types/layout";
|
|
19
|
+
import { env } from "./utils/env";
|
|
13
20
|
import { calculateLayout } from "./utils/layout";
|
|
14
21
|
|
|
15
22
|
const INITIAL_STATE = {
|
|
@@ -31,13 +38,18 @@ export class App {
|
|
|
31
38
|
private bridge!: TorrentBridge;
|
|
32
39
|
private addDialog!: AddTorrentDialog;
|
|
33
40
|
private confirmDialog!: ConfirmDialog;
|
|
41
|
+
private filePickerDialog!: FilePickerDialog;
|
|
34
42
|
private layout!: LayoutDimensions;
|
|
35
43
|
private resizeTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
36
44
|
|
|
37
45
|
async start(initialTorrentPath?: string): Promise<void> {
|
|
38
46
|
const config = loadConfig();
|
|
39
47
|
|
|
40
|
-
this.renderer = await createCliRenderer({
|
|
48
|
+
this.renderer = await createCliRenderer({
|
|
49
|
+
exitOnCtrlC: true,
|
|
50
|
+
openConsoleOnError: env.SHOW_CONSOLE,
|
|
51
|
+
useConsole: env.SHOW_CONSOLE,
|
|
52
|
+
});
|
|
41
53
|
this.store = new Store(INITIAL_STATE);
|
|
42
54
|
this.layout = calculateLayout(this.renderer.width, this.renderer.height);
|
|
43
55
|
this.bridge = new TorrentBridge(this.store, config);
|
|
@@ -56,6 +68,7 @@ export class App {
|
|
|
56
68
|
config.torrentFolder,
|
|
57
69
|
);
|
|
58
70
|
this.confirmDialog = new ConfirmDialog(this.renderer, this.layout);
|
|
71
|
+
this.filePickerDialog = new FilePickerDialog(this.renderer, this.layout);
|
|
59
72
|
|
|
60
73
|
this.controller = new AppController(
|
|
61
74
|
this.renderer,
|
|
@@ -74,13 +87,36 @@ export class App {
|
|
|
74
87
|
};
|
|
75
88
|
|
|
76
89
|
this.controller.onDialogClose = () => {
|
|
77
|
-
this.
|
|
90
|
+
if (this.filePickerDialog.getIsOpen()) {
|
|
91
|
+
this.filePickerDialog.confirmWithAllFiles();
|
|
92
|
+
} else {
|
|
93
|
+
this.addDialog.close();
|
|
94
|
+
}
|
|
78
95
|
};
|
|
79
96
|
|
|
80
97
|
this.controller.onDialogInput = (key) => {
|
|
98
|
+
if (this.filePickerDialog.getIsOpen())
|
|
99
|
+
return this.filePickerDialog.handleInput(key);
|
|
81
100
|
return this.addDialog.handleInput(key);
|
|
82
101
|
};
|
|
83
102
|
|
|
103
|
+
this.filePickerDialog.onConfirm = (id, selectedIndices) => {
|
|
104
|
+
this.controller.focusMode = "global";
|
|
105
|
+
this.bridge.setFileSelection(id, selectedIndices);
|
|
106
|
+
this.bridge.startTorrent(id).catch((err: unknown) => {
|
|
107
|
+
this.toastManager.show({
|
|
108
|
+
id: `start-err-${Date.now()}`,
|
|
109
|
+
type: "error",
|
|
110
|
+
title: "Failed to start",
|
|
111
|
+
message: err instanceof Error ? err.message : String(err),
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
this.controller.onDialogPaste = (event: PasteEvent) => {
|
|
117
|
+
return this.addDialog.handlePaste(event);
|
|
118
|
+
};
|
|
119
|
+
|
|
84
120
|
this.controller.onQuit = async () => {
|
|
85
121
|
await this.bridge.stopAll();
|
|
86
122
|
this.renderer.destroy();
|
|
@@ -139,15 +175,18 @@ export class App {
|
|
|
139
175
|
this.toastManager.updateLayout(this.layout);
|
|
140
176
|
this.addDialog.updateLayout(this.layout);
|
|
141
177
|
this.confirmDialog.updateLayout(this.layout);
|
|
178
|
+
this.filePickerDialog.updateLayout(this.layout);
|
|
142
179
|
}, 100);
|
|
143
180
|
}
|
|
144
181
|
|
|
145
182
|
private async addTorrentInBackground(
|
|
146
|
-
|
|
183
|
+
input: string,
|
|
147
184
|
filename: string,
|
|
148
185
|
): Promise<void> {
|
|
149
186
|
try {
|
|
150
|
-
const result =
|
|
187
|
+
const result = isMagnetUri(input)
|
|
188
|
+
? await this.bridge.addMagnet(input)
|
|
189
|
+
: await this.bridge.addTorrent(input);
|
|
151
190
|
this.toastManager.show({
|
|
152
191
|
id: `added-${Date.now()}`,
|
|
153
192
|
type: result.added ? "success" : "info",
|
|
@@ -157,14 +196,22 @@ export class App {
|
|
|
157
196
|
this.renderer.requestRender();
|
|
158
197
|
|
|
159
198
|
if (result.added) {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
199
|
+
const torrent = this.store
|
|
200
|
+
.getState()
|
|
201
|
+
.torrents.find((t) => t.id === result.id);
|
|
202
|
+
if (torrent && torrent.files.length > 1) {
|
|
203
|
+
this.controller.focusMode = "dialog";
|
|
204
|
+
this.filePickerDialog.open(result.id, torrent.files, torrent.name);
|
|
205
|
+
} else {
|
|
206
|
+
this.bridge.startTorrent(result.id).catch((err: unknown) => {
|
|
207
|
+
this.toastManager.show({
|
|
208
|
+
id: `start-err-${Date.now()}`,
|
|
209
|
+
type: "error",
|
|
210
|
+
title: "Failed to start",
|
|
211
|
+
message: err instanceof Error ? err.message : String(err),
|
|
212
|
+
});
|
|
166
213
|
});
|
|
167
|
-
}
|
|
214
|
+
}
|
|
168
215
|
}
|
|
169
216
|
} catch (err) {
|
|
170
217
|
this.toastManager.show({
|
package/src/cli/info.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { decode, type BencodeValue } from "../torrent/parser";
|
|
3
|
+
import { TorrentMetadata } from "../torrent/metadata";
|
|
4
|
+
|
|
5
|
+
export interface TorrentInfoFile {
|
|
6
|
+
path: string;
|
|
7
|
+
length: number;
|
|
8
|
+
offset: number;
|
|
9
|
+
padding?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface TorrentInfo {
|
|
13
|
+
name: string;
|
|
14
|
+
infoHash: string;
|
|
15
|
+
totalSize: number;
|
|
16
|
+
pieceLength: number;
|
|
17
|
+
pieceCount: number;
|
|
18
|
+
private: boolean;
|
|
19
|
+
isMultiFile: boolean;
|
|
20
|
+
trackers: string[][];
|
|
21
|
+
webSeeds: string[];
|
|
22
|
+
nodes: Array<{ ip: string; port: number }>;
|
|
23
|
+
files: TorrentInfoFile[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function readTorrentInfo(torrentPath: string): TorrentInfo {
|
|
27
|
+
const raw = new Uint8Array(readFileSync(torrentPath));
|
|
28
|
+
return torrentInfoFromBytes(raw);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function torrentInfoFromBytes(raw: Uint8Array): TorrentInfo {
|
|
32
|
+
const decoded = decode(raw);
|
|
33
|
+
if (
|
|
34
|
+
typeof decoded !== "object" ||
|
|
35
|
+
decoded === null ||
|
|
36
|
+
Array.isArray(decoded) ||
|
|
37
|
+
decoded instanceof Uint8Array
|
|
38
|
+
) {
|
|
39
|
+
throw new Error("Invalid torrent file");
|
|
40
|
+
}
|
|
41
|
+
return torrentInfoFromMetadata(
|
|
42
|
+
new TorrentMetadata(decoded as { [key: string]: BencodeValue }, raw),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function torrentInfoFromMetadata(metadata: TorrentMetadata): TorrentInfo {
|
|
47
|
+
return {
|
|
48
|
+
name: metadata.name,
|
|
49
|
+
infoHash: Buffer.from(metadata.infoHash).toString("hex"),
|
|
50
|
+
totalSize: metadata.totalSize,
|
|
51
|
+
pieceLength: metadata.pieceLength,
|
|
52
|
+
pieceCount: metadata.pieceCount,
|
|
53
|
+
private: metadata.private,
|
|
54
|
+
isMultiFile: metadata.isMultiFile,
|
|
55
|
+
trackers: metadata.announceList.map((tier) => [...tier]),
|
|
56
|
+
webSeeds: [...metadata.webSeeds],
|
|
57
|
+
nodes: metadata.nodes.map((node) => ({ ...node })),
|
|
58
|
+
files: metadata.files.map((file) => ({ ...file })),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function formatTorrentInfo(info: TorrentInfo): string {
|
|
63
|
+
const lines = [
|
|
64
|
+
line(),
|
|
65
|
+
" Torrent Info",
|
|
66
|
+
line(),
|
|
67
|
+
row("name", info.name),
|
|
68
|
+
row("info-hash", info.infoHash),
|
|
69
|
+
row("size", `${formatBytes(info.totalSize)} (${info.totalSize} bytes)`),
|
|
70
|
+
row("pieces", `${info.pieceCount} x ${formatBytes(info.pieceLength)}`),
|
|
71
|
+
row("private", info.private ? "yes" : "no"),
|
|
72
|
+
row("mode", info.isMultiFile ? "multi-file" : "single-file"),
|
|
73
|
+
"",
|
|
74
|
+
" trackers",
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
if (info.trackers.length === 0) {
|
|
78
|
+
lines.push(" none");
|
|
79
|
+
} else {
|
|
80
|
+
for (let i = 0; i < info.trackers.length; i++) {
|
|
81
|
+
const tier = info.trackers[i] ?? [];
|
|
82
|
+
lines.push(` tier ${i + 1}`);
|
|
83
|
+
for (const tracker of tier) lines.push(` ${tracker}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
lines.push("", " web-seeds");
|
|
88
|
+
if (info.webSeeds.length === 0) {
|
|
89
|
+
lines.push(" none");
|
|
90
|
+
} else {
|
|
91
|
+
for (const seed of info.webSeeds) lines.push(` ${seed}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
lines.push("", " dht-nodes");
|
|
95
|
+
if (info.nodes.length === 0) {
|
|
96
|
+
lines.push(" none");
|
|
97
|
+
} else {
|
|
98
|
+
for (const node of info.nodes) lines.push(` ${node.ip}:${node.port}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
lines.push("", " files");
|
|
102
|
+
for (const file of info.files) {
|
|
103
|
+
lines.push(` ${formatBytes(file.length).padStart(9)} ${file.path}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
lines.push(line());
|
|
107
|
+
return `${lines.join("\n")}\n`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function formatTorrentInfoJson(info: TorrentInfo): string {
|
|
111
|
+
return `${JSON.stringify(info, null, "\t")}\n`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function formatBytes(bytes: number): string {
|
|
115
|
+
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
|
|
116
|
+
let value = bytes;
|
|
117
|
+
let unit = 0;
|
|
118
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
119
|
+
value /= 1024;
|
|
120
|
+
unit++;
|
|
121
|
+
}
|
|
122
|
+
const label = units[unit] ?? "B";
|
|
123
|
+
if (unit === 0) return `${bytes} ${label}`;
|
|
124
|
+
return `${value.toFixed(value >= 10 ? 1 : 2)} ${label}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function line(): string {
|
|
128
|
+
return "-".repeat(80);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function row(label: string, value: string): string {
|
|
132
|
+
return ` ${label.padEnd(10)} ${value}`;
|
|
133
|
+
}
|
package/src/cli/parse.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
export type CliAction =
|
|
2
|
+
| "tui"
|
|
3
|
+
| "help"
|
|
4
|
+
| "version"
|
|
5
|
+
| "verify"
|
|
6
|
+
| "handshake"
|
|
7
|
+
| "download"
|
|
8
|
+
| "info";
|
|
9
|
+
|
|
10
|
+
export interface CliCommand {
|
|
11
|
+
action: CliAction;
|
|
12
|
+
input?: string;
|
|
13
|
+
json: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const ACTION_FLAGS = new Map<string, CliAction>([
|
|
17
|
+
["--verify", "verify"],
|
|
18
|
+
["--handshake", "handshake"],
|
|
19
|
+
["--download", "download"],
|
|
20
|
+
["--info", "info"],
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
const HELP_FLAGS = new Set(["--help", "-h"]);
|
|
24
|
+
const VERSION_FLAGS = new Set(["--version", "-v"]);
|
|
25
|
+
const VALUELESS_FLAGS = new Set([
|
|
26
|
+
...HELP_FLAGS,
|
|
27
|
+
...VERSION_FLAGS,
|
|
28
|
+
...ACTION_FLAGS.keys(),
|
|
29
|
+
"--json",
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
export function parseCliArgs(args: string[]): CliCommand {
|
|
33
|
+
if (args.some((arg) => HELP_FLAGS.has(arg))) {
|
|
34
|
+
ensureOnlyKnownFlags(args);
|
|
35
|
+
return { action: "help", json: false };
|
|
36
|
+
}
|
|
37
|
+
if (args.some((arg) => VERSION_FLAGS.has(arg))) {
|
|
38
|
+
ensureOnlyKnownFlags(args);
|
|
39
|
+
return { action: "version", json: false };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
ensureOnlyKnownFlags(args);
|
|
43
|
+
|
|
44
|
+
const json = args.includes("--json");
|
|
45
|
+
const actions = args
|
|
46
|
+
.filter((arg) => ACTION_FLAGS.has(arg))
|
|
47
|
+
.map((arg) => ACTION_FLAGS.get(arg) as CliAction);
|
|
48
|
+
if (actions.length > 1) {
|
|
49
|
+
throw new Error(`Choose only one action flag: ${actions.join(", ")}`);
|
|
50
|
+
}
|
|
51
|
+
if (json && actions[0] !== "info") {
|
|
52
|
+
throw new Error("--json can only be used with --info");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const inputs = args.filter((arg) => !VALUELESS_FLAGS.has(arg));
|
|
56
|
+
if (inputs.length > 1) {
|
|
57
|
+
throw new Error(`Expected one torrent or magnet argument, got ${inputs.length}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const input = inputs[0];
|
|
61
|
+
const action = actions[0] ?? "tui";
|
|
62
|
+
if (action !== "tui" && !input) {
|
|
63
|
+
throw new Error(`Missing torrent or magnet argument for ${flagForAction(action)}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { action, input, json };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function ensureOnlyKnownFlags(args: string[]): void {
|
|
70
|
+
for (const arg of args) {
|
|
71
|
+
if (arg.startsWith("-") && !VALUELESS_FLAGS.has(arg)) {
|
|
72
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function flagForAction(action: CliAction): string {
|
|
78
|
+
for (const [flag, flagAction] of ACTION_FLAGS) {
|
|
79
|
+
if (flagAction === action) return flag;
|
|
80
|
+
}
|
|
81
|
+
return action;
|
|
82
|
+
}
|
package/src/config/settings.ts
CHANGED
|
@@ -4,6 +4,17 @@ export const settingsSchema = z.object({
|
|
|
4
4
|
downloadPath: z.string().default("~/Downloads"),
|
|
5
5
|
maxConnections: z.number().min(1).max(500).default(50),
|
|
6
6
|
torrentFolder: z.string().default("~/Downloads"),
|
|
7
|
+
downloadRateLimitBps: z.number().min(0).default(0),
|
|
8
|
+
uploadRateLimitBps: z.number().min(0).default(0),
|
|
9
|
+
enableWebSeeds: z.boolean().default(true),
|
|
10
|
+
maxWebSeedConnections: z.number().min(0).max(20).default(3),
|
|
11
|
+
webSeedMaxRequestBytes: z.number().min(16_384).default(16_777_216),
|
|
12
|
+
blocklistEnabled: z.boolean().default(false),
|
|
13
|
+
blocklistPaths: z.array(z.string()).default([]),
|
|
14
|
+
blocklistUrl: z.string().default(""),
|
|
15
|
+
blocklistRefreshHours: z.number().min(1).default(168),
|
|
16
|
+
encryption: z.enum(["allowed", "preferred", "required"]).default("preferred"),
|
|
17
|
+
enableLsd: z.boolean().default(true),
|
|
7
18
|
});
|
|
8
19
|
|
|
9
20
|
export type AppSettings = z.infer<typeof settingsSchema>;
|
|
@@ -12,4 +23,15 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
|
|
12
23
|
downloadPath: "~/Downloads",
|
|
13
24
|
maxConnections: 50,
|
|
14
25
|
torrentFolder: "~/Downloads",
|
|
26
|
+
downloadRateLimitBps: 0,
|
|
27
|
+
uploadRateLimitBps: 0,
|
|
28
|
+
enableWebSeeds: true,
|
|
29
|
+
maxWebSeedConnections: 3,
|
|
30
|
+
webSeedMaxRequestBytes: 16_777_216,
|
|
31
|
+
blocklistEnabled: false,
|
|
32
|
+
blocklistPaths: [],
|
|
33
|
+
blocklistUrl: "",
|
|
34
|
+
blocklistRefreshHours: 168,
|
|
35
|
+
encryption: "preferred",
|
|
36
|
+
enableLsd: true,
|
|
15
37
|
};
|
package/src/constants/index.ts
CHANGED
|
@@ -3,7 +3,7 @@ export const APP_NAME = "torrent-tui";
|
|
|
3
3
|
export const SIDEBAR_WIDTH = 20;
|
|
4
4
|
|
|
5
5
|
export const SIDEBAR_ITEMS = {
|
|
6
|
-
status: ["All", "Downloading", "Seeding", "Completed", "Stopped"],
|
|
6
|
+
status: ["All", "Downloading", "Paused", "Seeding", "Completed", "Stopped"],
|
|
7
7
|
} as const;
|
|
8
8
|
|
|
9
9
|
export type SidebarSection = keyof typeof SIDEBAR_ITEMS;
|