torrent-tui 0.0.2 → 0.0.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/README.md +61 -4
- package/package.json +6 -2
- package/src/app.ts +67 -30
- package/src/config/index.ts +7 -9
- package/src/constants/index.ts +1 -1
- package/src/controllers/app-controller.ts +239 -26
- package/src/index.ts +87 -36
- package/src/layout/add-torrent-dialog.ts +316 -51
- package/src/layout/confirm-dialog.ts +37 -15
- package/src/layout/content-window.ts +130 -25
- package/src/layout/detail-panel.ts +458 -0
- package/src/layout/sidebar.ts +5 -2
- package/src/layout/status-bar.ts +23 -5
- package/src/layout/toast.ts +1 -2
- package/src/layout/torrent-view.ts +167 -50
- package/src/store/index.ts +32 -1
- package/src/torrent/bridge.ts +699 -79
- 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 +231 -0
- package/src/torrent/downloader.ts +177 -119
- 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 +62 -21
- package/src/torrent/parser.ts +8 -4
- package/src/torrent/peer/connection.ts +209 -13
- package/src/torrent/peer/extension.ts +270 -0
- package/src/torrent/peer/handshake.ts +2 -0
- package/src/torrent/peer/listener.ts +1 -1
- package/src/torrent/peer/manager.ts +80 -29
- package/src/torrent/peer/protocol.ts +13 -0
- package/src/torrent/piece-picker.ts +4 -1
- package/src/torrent/resume.ts +147 -0
- package/src/torrent/session.ts +53 -8
- package/src/torrent/storage.ts +309 -48
- package/src/torrent/tracker/announce.ts +42 -15
- package/src/torrent/tracker/coordinator.ts +254 -0
- package/src/torrent/tracker/http-tracker.ts +113 -59
- package/src/torrent/tracker/udp-tracker.ts +91 -34
- package/src/torrent/types.ts +25 -2
- package/src/torrent/upload-accounting.ts +35 -0
- package/src/utils/filter.ts +28 -6
- package/src/utils/json.ts +23 -0
- package/src/torrent/get_peers.ts +0 -212
package/README.md
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
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
|
+
[](https://www.npmjs.com/package/torrent-tui)
|
|
8
|
+
[](https://github.com/ryadios/torrent-tui/actions/workflows/ci.yml)
|
|
7
9
|
[](./LICENSE)
|
|
8
10
|
|
|
9
11
|
[Install](#install) · [Quickstart](#quickstart) · [Commands](#commands) · [Configuration](#configuration) · [Development](#development)
|
|
@@ -52,7 +54,8 @@ From inside the app:
|
|
|
52
54
|
| --- | --- |
|
|
53
55
|
| `j` / `k` or arrow keys | Move selection |
|
|
54
56
|
| `Tab` | Change focus |
|
|
55
|
-
| `a` | Add a `.torrent` file |
|
|
57
|
+
| `a` | Add a `.torrent` file or magnet link |
|
|
58
|
+
| `/` in add dialog | Type a magnet link manually |
|
|
56
59
|
| `Space` | Pause or resume the selected torrent |
|
|
57
60
|
| `d` | Remove the selected torrent |
|
|
58
61
|
| `D` | Remove the selected torrent and downloaded files |
|
|
@@ -65,18 +68,25 @@ The package also exposes a few command-line checks around the same torrent engin
|
|
|
65
68
|
```bash
|
|
66
69
|
torrent-tui --help
|
|
67
70
|
torrent-tui --version
|
|
71
|
+
torrent-tui file.torrent
|
|
72
|
+
torrent-tui 'magnet:?xt=urn:btih:...'
|
|
68
73
|
torrent-tui file.torrent --verify
|
|
69
74
|
torrent-tui file.torrent --handshake
|
|
70
75
|
torrent-tui file.torrent --download
|
|
76
|
+
torrent-tui 'magnet:?xt=urn:btih:...' --download
|
|
71
77
|
```
|
|
72
78
|
|
|
73
79
|
| Command | Description |
|
|
74
80
|
| --- | --- |
|
|
75
81
|
| `torrent-tui` | Start the terminal UI. |
|
|
76
|
-
| `torrent-tui <file.torrent>` |
|
|
82
|
+
| `torrent-tui <file.torrent>` | Start the TUI and add the torrent. |
|
|
83
|
+
| `torrent-tui <magnet-uri>` | Start the TUI, fetch magnet metadata, cache it, and start the torrent. |
|
|
77
84
|
| `torrent-tui <file.torrent> --verify` | Create storage and verify local pieces. |
|
|
78
85
|
| `torrent-tui <file.torrent> --handshake` | Connect to peers and print a connection summary. |
|
|
79
86
|
| `torrent-tui <file.torrent> --download` | Run the downloader without launching the TUI. |
|
|
87
|
+
| `torrent-tui <magnet-uri> --download` | Fetch magnet metadata, cache it, then run the downloader without launching the TUI. |
|
|
88
|
+
|
|
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.
|
|
80
90
|
|
|
81
91
|
## Configuration
|
|
82
92
|
|
|
@@ -102,6 +112,52 @@ Resume data is stored under:
|
|
|
102
112
|
${XDG_DATA_HOME:-~/.local/share}/torrent-tui/resume
|
|
103
113
|
```
|
|
104
114
|
|
|
115
|
+
The session registry is stored at:
|
|
116
|
+
|
|
117
|
+
```text
|
|
118
|
+
${XDG_DATA_HOME:-~/.local/share}/torrent-tui/session.json
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
It keeps the list of torrents the TUI should restore on startup.
|
|
122
|
+
|
|
123
|
+
### Torrent States
|
|
124
|
+
|
|
125
|
+
The TUI shows detailed per-torrent states while keeping the sidebar filters simple.
|
|
126
|
+
|
|
127
|
+
| State | Meaning |
|
|
128
|
+
| --- | --- |
|
|
129
|
+
| `Queued` | The `.torrent` was accepted and is waiting for engine startup. |
|
|
130
|
+
| `Metadata` | A magnet link was accepted and metadata is being fetched from peers. |
|
|
131
|
+
| `Checking` | Local files are being checked against torrent piece hashes. |
|
|
132
|
+
| `Connecting` | Trackers were contacted and the client is connecting to peers. |
|
|
133
|
+
| `Downloading` | Pieces are actively being requested or received. |
|
|
134
|
+
| `Stalled` | The torrent is incomplete but has no usable peers right now. Press `Space` to retry. |
|
|
135
|
+
| `Paused` | The active downloader was paused by the user. |
|
|
136
|
+
| `Seeding` | All pieces are present and the torrent can upload to peers. |
|
|
137
|
+
| `Stopped` | The torrent is saved in the session but not running. |
|
|
138
|
+
| `Error` | Startup, storage, or torrent metadata handling failed. |
|
|
139
|
+
|
|
140
|
+
The `Downloading` sidebar filter includes queued, checking, connecting, downloading, and stalled torrents so active work stays grouped together.
|
|
141
|
+
|
|
142
|
+
### What Each Setting Does
|
|
143
|
+
|
|
144
|
+
| Setting | Purpose | When it applies |
|
|
145
|
+
| --- | --- | --- |
|
|
146
|
+
| `downloadPath` | Where torrent payload files are written and verified. | On torrent add, resume, verify, and startup restore. |
|
|
147
|
+
| `torrentFolder` | Folder shown by the add-torrent dialog. | When you open the add dialog. |
|
|
148
|
+
| `maxConnections` | Maximum number of peers the client will connect to per torrent. | During peer discovery and download. |
|
|
149
|
+
|
|
150
|
+
### Tuning Tips
|
|
151
|
+
|
|
152
|
+
- Use a fast local SSD for `downloadPath` if you want quicker verification and fewer stalls on reopen.
|
|
153
|
+
- Point `torrentFolder` at the directory where you keep `.torrent` files so adding torrents is faster.
|
|
154
|
+
- Lower `maxConnections` if your network or CPU struggles with many peers; raise it if you want more parallel peer selection.
|
|
155
|
+
- Fresh torrents skip full zero-file verification. Existing files are checked cooperatively, so the TUI should stay responsive during large rechecks.
|
|
156
|
+
- If a torrent stays `Stalled`, the client did not find a usable peer. Try again later with `Space`, or check tracker availability.
|
|
157
|
+
- Settings are read when the app starts. If you edit `settings.json` manually, restart the app to pick up the changes.
|
|
158
|
+
- If the settings file is invalid, torrent-tui falls back to defaults and logs a config warning.
|
|
159
|
+
- `session.json` and the resume files are rewritten automatically as torrent state changes, so you normally do not need to edit them by hand.
|
|
160
|
+
|
|
105
161
|
## Status
|
|
106
162
|
|
|
107
163
|
`0.0.1` is a basic release intended for early CLI usage.
|
|
@@ -113,7 +169,8 @@ ${XDG_DATA_HOME:-~/.local/share}/torrent-tui/resume
|
|
|
113
169
|
| Peer handshakes and piece download | Available |
|
|
114
170
|
| Resume data | Available |
|
|
115
171
|
| Multi-torrent TUI | Available |
|
|
116
|
-
| Magnet links |
|
|
172
|
+
| Magnet links | Available for v1 magnets with tracker, explicit-peer, or DHT discovery |
|
|
173
|
+
| Peer discovery | Trackers, DHT, and PEX |
|
|
117
174
|
| Standalone binaries | Not included yet |
|
|
118
175
|
|
|
119
176
|
## Development
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "torrent-tui",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.4",
|
|
4
4
|
"description": "A Bun-powered terminal BitTorrent client.",
|
|
5
5
|
"module": "src/index.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -20,9 +20,13 @@
|
|
|
20
20
|
"dev": "bun --watch src/index.ts",
|
|
21
21
|
"check": "bun src/index.ts --download",
|
|
22
22
|
"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
|
+
"bench:startup": "bun scripts/benchmark-startup.ts",
|
|
24
|
+
"bench:verify": "bun scripts/benchmark-verify.ts",
|
|
25
|
+
"test": "bun test",
|
|
26
|
+
"test:watch": "bun test --watch",
|
|
23
27
|
"typecheck": "tsc --noEmit",
|
|
24
28
|
"check:fix": "biome check --write --unsafe",
|
|
25
|
-
"release:check": "bun run typecheck && bun publish --dry-run"
|
|
29
|
+
"release:check": "bun run typecheck && bun test && bun publish --dry-run"
|
|
26
30
|
},
|
|
27
31
|
"keywords": [
|
|
28
32
|
"bittorrent",
|
package/src/app.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
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";
|
|
@@ -9,7 +13,9 @@ import { StatusBar } from "./layout/status-bar";
|
|
|
9
13
|
import { ToastManager } from "./layout/toast-manager";
|
|
10
14
|
import { Store } from "./store";
|
|
11
15
|
import { TorrentBridge } from "./torrent/bridge";
|
|
16
|
+
import { isMagnetUri } from "./torrent/magnet";
|
|
12
17
|
import type { LayoutDimensions } from "./types/layout";
|
|
18
|
+
import { env } from "./utils/env";
|
|
13
19
|
import { calculateLayout } from "./utils/layout";
|
|
14
20
|
|
|
15
21
|
const INITIAL_STATE = {
|
|
@@ -34,10 +40,14 @@ export class App {
|
|
|
34
40
|
private layout!: LayoutDimensions;
|
|
35
41
|
private resizeTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
36
42
|
|
|
37
|
-
async start(): Promise<void> {
|
|
43
|
+
async start(initialTorrentPath?: string): Promise<void> {
|
|
38
44
|
const config = loadConfig();
|
|
39
45
|
|
|
40
|
-
this.renderer = await createCliRenderer({
|
|
46
|
+
this.renderer = await createCliRenderer({
|
|
47
|
+
exitOnCtrlC: true,
|
|
48
|
+
openConsoleOnError: env.SHOW_CONSOLE,
|
|
49
|
+
useConsole: env.SHOW_CONSOLE,
|
|
50
|
+
});
|
|
41
51
|
this.store = new Store(INITIAL_STATE);
|
|
42
52
|
this.layout = calculateLayout(this.renderer.width, this.renderer.height);
|
|
43
53
|
this.bridge = new TorrentBridge(this.store, config);
|
|
@@ -62,6 +72,7 @@ export class App {
|
|
|
62
72
|
this.store,
|
|
63
73
|
this.sidebar,
|
|
64
74
|
this.contentWindow,
|
|
75
|
+
this.statusBar,
|
|
65
76
|
this.toastManager,
|
|
66
77
|
);
|
|
67
78
|
// Wire ConfirmDialog — callbacks are set inside the controller setter
|
|
@@ -80,6 +91,10 @@ export class App {
|
|
|
80
91
|
return this.addDialog.handleInput(key);
|
|
81
92
|
};
|
|
82
93
|
|
|
94
|
+
this.controller.onDialogPaste = (event: PasteEvent) => {
|
|
95
|
+
return this.addDialog.handlePaste(event);
|
|
96
|
+
};
|
|
97
|
+
|
|
83
98
|
this.controller.onQuit = async () => {
|
|
84
99
|
await this.bridge.stopAll();
|
|
85
100
|
this.renderer.destroy();
|
|
@@ -101,41 +116,27 @@ export class App {
|
|
|
101
116
|
this.bridge.removeTorrent(id, deleteFiles);
|
|
102
117
|
};
|
|
103
118
|
|
|
104
|
-
this.addDialog.onSelect =
|
|
119
|
+
this.addDialog.onSelect = (filePath) => {
|
|
105
120
|
this.controller.focusMode = "global";
|
|
106
121
|
const filename = filePath.split("/").pop() ?? filePath;
|
|
107
|
-
this.
|
|
108
|
-
id: `add-${Date.now()}`,
|
|
109
|
-
type: "info",
|
|
110
|
-
title: "Adding torrent",
|
|
111
|
-
message: filename,
|
|
112
|
-
});
|
|
122
|
+
this.renderer.requestRender();
|
|
113
123
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
id: `added-${Date.now()}`,
|
|
118
|
-
type: "success",
|
|
119
|
-
title: "Download started",
|
|
120
|
-
message: filename,
|
|
121
|
-
});
|
|
122
|
-
} catch (err) {
|
|
123
|
-
this.toastManager.show({
|
|
124
|
-
id: `err-${Date.now()}`,
|
|
125
|
-
type: "error",
|
|
126
|
-
title: "Failed to add",
|
|
127
|
-
message: err instanceof Error ? err.message : String(err),
|
|
128
|
-
});
|
|
129
|
-
}
|
|
124
|
+
setTimeout(() => {
|
|
125
|
+
this.addTorrentInBackground(filePath, filename);
|
|
126
|
+
}, 0);
|
|
130
127
|
};
|
|
131
128
|
|
|
132
|
-
this.store.subscribe((state) => {
|
|
133
|
-
this.statusBar.update(state);
|
|
134
|
-
});
|
|
135
|
-
|
|
136
129
|
await this.bridge.restoreSession();
|
|
137
130
|
this.controller.start();
|
|
138
131
|
|
|
132
|
+
if (initialTorrentPath) {
|
|
133
|
+
const filename =
|
|
134
|
+
initialTorrentPath.split("/").pop() ?? initialTorrentPath;
|
|
135
|
+
setTimeout(() => {
|
|
136
|
+
this.addTorrentInBackground(initialTorrentPath, filename);
|
|
137
|
+
}, 0);
|
|
138
|
+
}
|
|
139
|
+
|
|
139
140
|
this.renderer.on("resize", (width: number, height: number) => {
|
|
140
141
|
this.handleResize(width, height);
|
|
141
142
|
});
|
|
@@ -154,4 +155,40 @@ export class App {
|
|
|
154
155
|
this.confirmDialog.updateLayout(this.layout);
|
|
155
156
|
}, 100);
|
|
156
157
|
}
|
|
158
|
+
|
|
159
|
+
private async addTorrentInBackground(
|
|
160
|
+
input: string,
|
|
161
|
+
filename: string,
|
|
162
|
+
): Promise<void> {
|
|
163
|
+
try {
|
|
164
|
+
const result = isMagnetUri(input)
|
|
165
|
+
? await this.bridge.addMagnet(input)
|
|
166
|
+
: await this.bridge.addTorrent(input);
|
|
167
|
+
this.toastManager.show({
|
|
168
|
+
id: `added-${Date.now()}`,
|
|
169
|
+
type: result.added ? "success" : "info",
|
|
170
|
+
title: result.added ? "Torrent added" : "Torrent already added",
|
|
171
|
+
message: result.name || filename,
|
|
172
|
+
});
|
|
173
|
+
this.renderer.requestRender();
|
|
174
|
+
|
|
175
|
+
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),
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
} catch (err) {
|
|
186
|
+
this.toastManager.show({
|
|
187
|
+
id: `err-${Date.now()}`,
|
|
188
|
+
type: "error",
|
|
189
|
+
title: "Failed to add",
|
|
190
|
+
message: err instanceof Error ? err.message : String(err),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
157
194
|
}
|
package/src/config/index.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// Config loader - reads from ~/.config/torrent-tui/settings.json
|
|
2
2
|
|
|
3
|
-
import { existsSync,
|
|
4
|
-
import {
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { log } from "../torrent/metadata";
|
|
5
|
+
import { writeJsonAtomic } from "../utils/json";
|
|
6
|
+
import { getConfigPath } from "../utils/paths";
|
|
5
7
|
import { type AppSettings, DEFAULT_SETTINGS, settingsSchema } from "./settings";
|
|
6
8
|
|
|
7
9
|
const SETTINGS_FILE = "settings.json";
|
|
@@ -24,18 +26,14 @@ export function loadConfig(): AppSettings {
|
|
|
24
26
|
return result.data;
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
log("config", "invalid settings file — using defaults");
|
|
27
30
|
return { ...DEFAULT_SETTINGS };
|
|
28
31
|
} catch {
|
|
32
|
+
log("config", "could not read settings file — using defaults");
|
|
29
33
|
return { ...DEFAULT_SETTINGS };
|
|
30
34
|
}
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
export function saveConfig(settings: AppSettings): void {
|
|
34
|
-
|
|
35
|
-
mkdirSync(getConfigDir(), { recursive: true });
|
|
36
|
-
const configPath = getConfigPath(SETTINGS_FILE);
|
|
37
|
-
writeFileSync(configPath, JSON.stringify(settings, null, 2), "utf-8");
|
|
38
|
-
} catch {
|
|
39
|
-
// Silently fail
|
|
40
|
-
}
|
|
38
|
+
writeJsonAtomic(getConfigPath(SETTINGS_FILE), settings);
|
|
41
39
|
}
|
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;
|