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.
Files changed (48) hide show
  1. package/README.md +65 -11
  2. package/man/torrent-tui.1 +61 -0
  3. package/package.json +8 -3
  4. package/src/app.ts +59 -12
  5. package/src/cli/info.ts +133 -0
  6. package/src/cli/parse.ts +82 -0
  7. package/src/config/settings.ts +22 -0
  8. package/src/constants/index.ts +1 -1
  9. package/src/controllers/app-controller.ts +62 -6
  10. package/src/index.ts +124 -35
  11. package/src/layout/add-torrent-dialog.ts +288 -52
  12. package/src/layout/content-window.ts +2 -0
  13. package/src/layout/detail-panel.ts +133 -38
  14. package/src/layout/file-picker-dialog.ts +344 -0
  15. package/src/layout/toast-manager.ts +1 -1
  16. package/src/layout/toast.ts +0 -6
  17. package/src/layout/torrent-view.ts +89 -33
  18. package/src/store/index.ts +6 -0
  19. package/src/torrent/blocklist.ts +171 -0
  20. package/src/torrent/bridge.ts +505 -66
  21. package/src/torrent/dht/node.ts +380 -0
  22. package/src/torrent/dht/protocol.ts +225 -0
  23. package/src/torrent/dht/routing.ts +98 -0
  24. package/src/torrent/discovery/coordinator.ts +261 -0
  25. package/src/torrent/discovery/lsd.ts +115 -0
  26. package/src/torrent/downloader.ts +550 -22
  27. package/src/torrent/magnet-resolver.ts +245 -0
  28. package/src/torrent/magnet.ts +103 -0
  29. package/src/torrent/metadata-cache.ts +152 -0
  30. package/src/torrent/metadata.ts +78 -6
  31. package/src/torrent/peer/connection.ts +292 -18
  32. package/src/torrent/peer/extension.ts +270 -0
  33. package/src/torrent/peer/handshake.ts +2 -0
  34. package/src/torrent/peer/manager.ts +201 -16
  35. package/src/torrent/peer/mse.ts +385 -0
  36. package/src/torrent/peer/protocol.ts +13 -0
  37. package/src/torrent/piece-picker.ts +3 -1
  38. package/src/torrent/resume.ts +21 -0
  39. package/src/torrent/session.ts +9 -2
  40. package/src/torrent/storage.ts +43 -5
  41. package/src/torrent/tracker/announce.ts +42 -18
  42. package/src/torrent/tracker/coordinator.ts +255 -0
  43. package/src/torrent/tracker/http-tracker.ts +54 -30
  44. package/src/torrent/tracker/udp-tracker.ts +38 -19
  45. package/src/torrent/types.ts +19 -0
  46. package/src/torrent/upload-accounting.ts +35 -0
  47. package/src/utils/filter.ts +1 -0
  48. 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
  [![npm version](https://img.shields.io/npm/v/torrent-tui?style=for-the-badge&logo=npm)](https://www.npmjs.com/package/torrent-tui)
6
- [![gzipped](https://img.shields.io/badge/gzipped-39%20KB-2563eb?style=for-the-badge)](https://www.npmjs.com/package/torrent-tui)
7
6
  [![npm unpacked size](https://img.shields.io/npm/unpacked-size/torrent-tui?style=for-the-badge)](https://www.npmjs.com/package/torrent-tui)
8
7
  [![CI](https://img.shields.io/github/actions/workflow/status/ryadios/torrent-tui/release.yml?branch=main&style=for-the-badge&logo=github)](https://github.com/ryadios/torrent-tui/actions/workflows/ci.yml)
9
8
  [![license](https://img.shields.io/npm/l/torrent-tui?style=for-the-badge)](./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 a few command-line checks around the same torrent engine:
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 <file.torrent> --verify` | Create storage and verify local pieces. |
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
- `0.0.1` is a basic release intended for early CLI usage.
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 | Not included yet |
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 publish --dry-run
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` and `src/constants/index.ts` to the new version.
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 publish --dry-run
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",
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 src/index.ts --download",
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 { type CliRenderer, createCliRenderer } from "@opentui/core";
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({ exitOnCtrlC: true });
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.addDialog.close();
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
- filePath: string,
183
+ input: string,
147
184
  filename: string,
148
185
  ): Promise<void> {
149
186
  try {
150
- const result = await this.bridge.addTorrent(filePath);
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
- this.bridge.startTorrent(result.id).catch((err: unknown) => {
161
- this.toastManager.show({
162
- id: `start-err-${Date.now()}`,
163
- type: "error",
164
- title: "Failed to start",
165
- message: err instanceof Error ? err.message : String(err),
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({
@@ -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
+ }
@@ -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
+ }
@@ -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
  };
@@ -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;