torrent-tui 0.0.4 → 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 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,7 +52,7 @@ From inside the app:
53
52
  | Key | Action |
54
53
  | --- | --- |
55
54
  | `j` / `k` or arrow keys | Move selection |
56
- | `Tab` | Change focus |
55
+ | `Tab` / `Shift+Tab` | Change focus |
57
56
  | `a` | Add a `.torrent` file or magnet link |
58
57
  | `/` in add dialog | Type a magnet link manually |
59
58
  | `Space` | Pause or resume the selected torrent |
@@ -61,9 +60,11 @@ From inside the app:
61
60
  | `D` | Remove the selected torrent and downloaded files |
62
61
  | `q` | Quit |
63
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
+
64
65
  ## Commands
65
66
 
66
- 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:
67
68
 
68
69
  ```bash
69
70
  torrent-tui --help
@@ -74,6 +75,9 @@ torrent-tui file.torrent --verify
74
75
  torrent-tui file.torrent --handshake
75
76
  torrent-tui file.torrent --download
76
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
77
81
  ```
78
82
 
79
83
  | Command | Description |
@@ -81,12 +85,15 @@ torrent-tui 'magnet:?xt=urn:btih:...' --download
81
85
  | `torrent-tui` | Start the terminal UI. |
82
86
  | `torrent-tui <file.torrent>` | Start the TUI and add the torrent. |
83
87
  | `torrent-tui <magnet-uri>` | Start the TUI, fetch magnet metadata, cache it, and start the torrent. |
84
- | `torrent-tui <file.torrent> --verify` | Create storage and verify local pieces. |
88
+ | `torrent-tui <file.torrent> --verify` | Create storage, verify local pieces, and print a tracker summary. |
85
89
  | `torrent-tui <file.torrent> --handshake` | Connect to peers and print a connection summary. |
86
90
  | `torrent-tui <file.torrent> --download` | Run the downloader without launching the TUI. |
87
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. |
88
95
 
89
- Magnet support covers BitTorrent v1 `btih` magnets with trackers (`tr`), explicit peers (`x.pe`), or DHT-discovered peers. After metadata is cached, `--verify` and `--handshake` can use the same magnet URI.
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.
90
97
 
91
98
  ## Configuration
92
99
 
@@ -102,7 +109,18 @@ Default settings:
102
109
  {
103
110
  "downloadPath": "~/Downloads",
104
111
  "maxConnections": 50,
105
- "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
106
124
  }
107
125
  ```
108
126
 
@@ -135,6 +153,7 @@ The TUI shows detailed per-torrent states while keeping the sidebar filters simp
135
153
  | `Paused` | The active downloader was paused by the user. |
136
154
  | `Seeding` | All pieces are present and the torrent can upload to peers. |
137
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. |
138
157
  | `Error` | Startup, storage, or torrent metadata handling failed. |
139
158
 
140
159
  The `Downloading` sidebar filter includes queued, checking, connecting, downloading, and stalled torrents so active work stays grouped together.
@@ -146,12 +165,26 @@ The `Downloading` sidebar filter includes queued, checking, connecting, download
146
165
  | `downloadPath` | Where torrent payload files are written and verified. | On torrent add, resume, verify, and startup restore. |
147
166
  | `torrentFolder` | Folder shown by the add-torrent dialog. | When you open the add dialog. |
148
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. |
149
179
 
150
180
  ### Tuning Tips
151
181
 
152
182
  - Use a fast local SSD for `downloadPath` if you want quicker verification and fewer stalls on reopen.
153
183
  - Point `torrentFolder` at the directory where you keep `.torrent` files so adding torrents is faster.
154
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.
155
188
  - Fresh torrents skip full zero-file verification. Existing files are checked cooperatively, so the TUI should stay responsive during large rechecks.
156
189
  - If a torrent stays `Stalled`, the client did not find a usable peer. Try again later with `Space`, or check tracker availability.
157
190
  - Settings are read when the app starts. If you edit `settings.json` manually, restart the app to pick up the changes.
@@ -160,7 +193,7 @@ The `Downloading` sidebar filter includes queued, checking, connecting, download
160
193
 
161
194
  ## Status
162
195
 
163
- `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.
164
197
 
165
198
  | Area | Status |
166
199
  | --- | --- |
@@ -170,7 +203,15 @@ The `Downloading` sidebar filter includes queued, checking, connecting, download
170
203
  | Resume data | Available |
171
204
  | Multi-torrent TUI | Available |
172
205
  | Magnet links | Available for v1 magnets with tracker, explicit-peer, or DHT discovery |
173
- | Peer discovery | Trackers, DHT, and PEX |
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 |
174
215
  | Standalone binaries | Not included yet |
175
216
 
176
217
  ## Development
@@ -184,7 +225,9 @@ Before opening a PR:
184
225
 
185
226
  ```bash
186
227
  bun run typecheck
187
- bun publish --dry-run
228
+ bun test
229
+ bun run smoke
230
+ npm publish --dry-run
188
231
  ```
189
232
 
190
233
  For formatting and lint fixes:
@@ -197,12 +240,14 @@ bun run check:fix
197
240
 
198
241
  Releases are published from GitHub Actions with generated GitHub release notes.
199
242
 
200
- 1. Update `package.json` and `src/constants/index.ts` to the new version.
243
+ 1. Update `package.json` to the new version.
201
244
  2. Run local checks:
202
245
 
203
246
  ```bash
204
247
  bun run typecheck
205
- bun publish --dry-run
248
+ bun test
249
+ bun run smoke
250
+ npm publish --dry-run
206
251
  ```
207
252
 
208
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.4",
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
@@ -8,6 +8,7 @@ import { AppController } from "./controllers/app-controller";
8
8
  import { AddTorrentDialog } from "./layout/add-torrent-dialog";
9
9
  import { ConfirmDialog } from "./layout/confirm-dialog";
10
10
  import { ContentWindow } from "./layout/content-window";
11
+ import { FilePickerDialog } from "./layout/file-picker-dialog";
11
12
  import { Sidebar } from "./layout/sidebar";
12
13
  import { StatusBar } from "./layout/status-bar";
13
14
  import { ToastManager } from "./layout/toast-manager";
@@ -37,6 +38,7 @@ export class App {
37
38
  private bridge!: TorrentBridge;
38
39
  private addDialog!: AddTorrentDialog;
39
40
  private confirmDialog!: ConfirmDialog;
41
+ private filePickerDialog!: FilePickerDialog;
40
42
  private layout!: LayoutDimensions;
41
43
  private resizeTimeout: ReturnType<typeof setTimeout> | null = null;
42
44
 
@@ -66,6 +68,7 @@ export class App {
66
68
  config.torrentFolder,
67
69
  );
68
70
  this.confirmDialog = new ConfirmDialog(this.renderer, this.layout);
71
+ this.filePickerDialog = new FilePickerDialog(this.renderer, this.layout);
69
72
 
70
73
  this.controller = new AppController(
71
74
  this.renderer,
@@ -84,13 +87,32 @@ export class App {
84
87
  };
85
88
 
86
89
  this.controller.onDialogClose = () => {
87
- this.addDialog.close();
90
+ if (this.filePickerDialog.getIsOpen()) {
91
+ this.filePickerDialog.confirmWithAllFiles();
92
+ } else {
93
+ this.addDialog.close();
94
+ }
88
95
  };
89
96
 
90
97
  this.controller.onDialogInput = (key) => {
98
+ if (this.filePickerDialog.getIsOpen())
99
+ return this.filePickerDialog.handleInput(key);
91
100
  return this.addDialog.handleInput(key);
92
101
  };
93
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
+
94
116
  this.controller.onDialogPaste = (event: PasteEvent) => {
95
117
  return this.addDialog.handlePaste(event);
96
118
  };
@@ -153,6 +175,7 @@ export class App {
153
175
  this.toastManager.updateLayout(this.layout);
154
176
  this.addDialog.updateLayout(this.layout);
155
177
  this.confirmDialog.updateLayout(this.layout);
178
+ this.filePickerDialog.updateLayout(this.layout);
156
179
  }, 100);
157
180
  }
158
181
 
@@ -173,14 +196,22 @@ export class App {
173
196
  this.renderer.requestRender();
174
197
 
175
198
  if (result.added) {
176
- this.bridge.startTorrent(result.id).catch((err: unknown) => {
177
- this.toastManager.show({
178
- id: `start-err-${Date.now()}`,
179
- type: "error",
180
- title: "Failed to start",
181
- 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
+ });
182
213
  });
183
- });
214
+ }
184
215
  }
185
216
  } catch (err) {
186
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
  };