torrent-tui 0.0.3 → 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 +11 -2
- package/package.json +1 -1
- package/src/app.ts +20 -4
- package/src/constants/index.ts +1 -1
- package/src/controllers/app-controller.ts +21 -3
- package/src/index.ts +82 -22
- package/src/layout/add-torrent-dialog.ts +288 -52
- package/src/layout/detail-panel.ts +26 -3
- package/src/layout/torrent-view.ts +3 -0
- package/src/store/index.ts +2 -0
- package/src/torrent/bridge.ts +309 -53
- 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 +127 -4
- 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 +29 -3
- package/src/torrent/peer/connection.ts +194 -3
- package/src/torrent/peer/extension.ts +270 -0
- package/src/torrent/peer/handshake.ts +2 -0
- package/src/torrent/peer/manager.ts +61 -13
- package/src/torrent/peer/protocol.ts +13 -0
- package/src/torrent/storage.ts +8 -0
- package/src/torrent/tracker/announce.ts +42 -18
- package/src/torrent/tracker/coordinator.ts +254 -0
- package/src/torrent/tracker/http-tracker.ts +53 -30
- package/src/torrent/tracker/udp-tracker.ts +38 -23
- package/src/torrent/types.ts +18 -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
|
@@ -54,7 +54,8 @@ From inside the app:
|
|
|
54
54
|
| --- | --- |
|
|
55
55
|
| `j` / `k` or arrow keys | Move selection |
|
|
56
56
|
| `Tab` | Change focus |
|
|
57
|
-
| `a` | Add a `.torrent` file |
|
|
57
|
+
| `a` | Add a `.torrent` file or magnet link |
|
|
58
|
+
| `/` in add dialog | Type a magnet link manually |
|
|
58
59
|
| `Space` | Pause or resume the selected torrent |
|
|
59
60
|
| `d` | Remove the selected torrent |
|
|
60
61
|
| `D` | Remove the selected torrent and downloaded files |
|
|
@@ -68,18 +69,24 @@ The package also exposes a few command-line checks around the same torrent engin
|
|
|
68
69
|
torrent-tui --help
|
|
69
70
|
torrent-tui --version
|
|
70
71
|
torrent-tui file.torrent
|
|
72
|
+
torrent-tui 'magnet:?xt=urn:btih:...'
|
|
71
73
|
torrent-tui file.torrent --verify
|
|
72
74
|
torrent-tui file.torrent --handshake
|
|
73
75
|
torrent-tui file.torrent --download
|
|
76
|
+
torrent-tui 'magnet:?xt=urn:btih:...' --download
|
|
74
77
|
```
|
|
75
78
|
|
|
76
79
|
| Command | Description |
|
|
77
80
|
| --- | --- |
|
|
78
81
|
| `torrent-tui` | Start the terminal UI. |
|
|
79
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. |
|
|
80
84
|
| `torrent-tui <file.torrent> --verify` | Create storage and verify local pieces. |
|
|
81
85
|
| `torrent-tui <file.torrent> --handshake` | Connect to peers and print a connection summary. |
|
|
82
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.
|
|
83
90
|
|
|
84
91
|
## Configuration
|
|
85
92
|
|
|
@@ -120,6 +127,7 @@ The TUI shows detailed per-torrent states while keeping the sidebar filters simp
|
|
|
120
127
|
| State | Meaning |
|
|
121
128
|
| --- | --- |
|
|
122
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. |
|
|
123
131
|
| `Checking` | Local files are being checked against torrent piece hashes. |
|
|
124
132
|
| `Connecting` | Trackers were contacted and the client is connecting to peers. |
|
|
125
133
|
| `Downloading` | Pieces are actively being requested or received. |
|
|
@@ -161,7 +169,8 @@ The `Downloading` sidebar filter includes queued, checking, connecting, download
|
|
|
161
169
|
| Peer handshakes and piece download | Available |
|
|
162
170
|
| Resume data | Available |
|
|
163
171
|
| Multi-torrent TUI | Available |
|
|
164
|
-
| Magnet links |
|
|
172
|
+
| Magnet links | Available for v1 magnets with tracker, explicit-peer, or DHT discovery |
|
|
173
|
+
| Peer discovery | Trackers, DHT, and PEX |
|
|
165
174
|
| Standalone binaries | Not included yet |
|
|
166
175
|
|
|
167
176
|
## Development
|
package/package.json
CHANGED
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 = {
|
|
@@ -37,7 +43,11 @@ export class App {
|
|
|
37
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);
|
|
@@ -81,6 +91,10 @@ export class App {
|
|
|
81
91
|
return this.addDialog.handleInput(key);
|
|
82
92
|
};
|
|
83
93
|
|
|
94
|
+
this.controller.onDialogPaste = (event: PasteEvent) => {
|
|
95
|
+
return this.addDialog.handlePaste(event);
|
|
96
|
+
};
|
|
97
|
+
|
|
84
98
|
this.controller.onQuit = async () => {
|
|
85
99
|
await this.bridge.stopAll();
|
|
86
100
|
this.renderer.destroy();
|
|
@@ -143,11 +157,13 @@ export class App {
|
|
|
143
157
|
}
|
|
144
158
|
|
|
145
159
|
private async addTorrentInBackground(
|
|
146
|
-
|
|
160
|
+
input: string,
|
|
147
161
|
filename: string,
|
|
148
162
|
): Promise<void> {
|
|
149
163
|
try {
|
|
150
|
-
const result =
|
|
164
|
+
const result = isMagnetUri(input)
|
|
165
|
+
? await this.bridge.addMagnet(input)
|
|
166
|
+
: await this.bridge.addTorrent(input);
|
|
151
167
|
this.toastManager.show({
|
|
152
168
|
id: `added-${Date.now()}`,
|
|
153
169
|
type: result.added ? "success" : "info",
|
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;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CliRenderer, KeyEvent } from "@opentui/core";
|
|
1
|
+
import type { CliRenderer, KeyEvent, PasteEvent } from "@opentui/core";
|
|
2
2
|
import { SIDEBAR_ITEMS } from "../constants";
|
|
3
3
|
import type { ConfirmDialog } from "../layout/confirm-dialog";
|
|
4
4
|
import type { ContentWindow, FocusArea } from "../layout/content-window";
|
|
@@ -38,7 +38,8 @@ export class AppController {
|
|
|
38
38
|
onAddTorrent?: () => void;
|
|
39
39
|
onQuit?: () => void;
|
|
40
40
|
onDialogClose?: () => void;
|
|
41
|
-
onDialogInput?: (key:
|
|
41
|
+
onDialogInput?: (key: KeyEvent) => boolean;
|
|
42
|
+
onDialogPaste?: (event: PasteEvent) => boolean;
|
|
42
43
|
onPauseTorrent?: (id: string) => Promise<void> | void;
|
|
43
44
|
onResumeTorrent?: (id: string) => Promise<void> | void;
|
|
44
45
|
onStartTorrent?: (id: string) => Promise<void> | void;
|
|
@@ -101,6 +102,9 @@ export class AppController {
|
|
|
101
102
|
this.renderer.keyInput.on("keypress", (key) => {
|
|
102
103
|
this.handleKeyPress(key);
|
|
103
104
|
});
|
|
105
|
+
this.renderer.keyInput.on("paste", (event: PasteEvent) => {
|
|
106
|
+
this.handlePaste(event);
|
|
107
|
+
});
|
|
104
108
|
|
|
105
109
|
this.refreshView();
|
|
106
110
|
}
|
|
@@ -216,8 +220,13 @@ export class AppController {
|
|
|
216
220
|
if (key.name === "escape") {
|
|
217
221
|
this.focusMode = "global";
|
|
218
222
|
this.onDialogClose?.();
|
|
223
|
+
key.preventDefault();
|
|
224
|
+
key.stopPropagation();
|
|
219
225
|
} else {
|
|
220
|
-
this.onDialogInput?.(key
|
|
226
|
+
if (this.onDialogInput?.(key)) {
|
|
227
|
+
key.preventDefault();
|
|
228
|
+
key.stopPropagation();
|
|
229
|
+
}
|
|
221
230
|
}
|
|
222
231
|
}
|
|
223
232
|
return;
|
|
@@ -346,11 +355,20 @@ export class AppController {
|
|
|
346
355
|
} else if (key.name === "a") {
|
|
347
356
|
this.focusMode = "dialog";
|
|
348
357
|
this.onAddTorrent?.();
|
|
358
|
+
key.preventDefault();
|
|
359
|
+
key.stopPropagation();
|
|
349
360
|
} else if (key.name === "q") {
|
|
350
361
|
this.onQuit?.();
|
|
351
362
|
}
|
|
352
363
|
}
|
|
353
364
|
|
|
365
|
+
private handlePaste(event: PasteEvent): void {
|
|
366
|
+
if (this.focusMode !== "dialog" || this._confirmDialog?.getIsOpen()) return;
|
|
367
|
+
if (this.onDialogPaste?.(event)) {
|
|
368
|
+
event.preventDefault();
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
354
372
|
private nextFocusArea(): FocusArea {
|
|
355
373
|
switch (this.focusArea) {
|
|
356
374
|
case "sidebar":
|
package/src/index.ts
CHANGED
|
@@ -21,12 +21,17 @@ async function printHelp(): Promise<void> {
|
|
|
21
21
|
Usage:
|
|
22
22
|
torrent-tui Start the terminal UI
|
|
23
23
|
torrent-tui <file.torrent> Start the TUI and add the torrent
|
|
24
|
+
torrent-tui <magnet-uri> Start the TUI and fetch magnet metadata
|
|
24
25
|
torrent-tui <file.torrent> --verify Verify local pieces and trackers
|
|
25
26
|
torrent-tui <file.torrent> --handshake
|
|
26
27
|
Connect to peers and print handshake summary
|
|
27
|
-
torrent-tui <file.torrent> --download
|
|
28
|
+
torrent-tui <file.torrent|magnet> --download
|
|
28
29
|
Download from the command line
|
|
29
30
|
|
|
31
|
+
Magnet links:
|
|
32
|
+
Supported for BitTorrent v1 btih magnets with trackers, x.pe peers, or DHT peers.
|
|
33
|
+
--verify and --handshake can use a magnet after its metadata is cached.
|
|
34
|
+
|
|
30
35
|
Options:
|
|
31
36
|
--help, -h Show this help
|
|
32
37
|
--version, -v Print the version`);
|
|
@@ -42,6 +47,27 @@ function validateTorrentArg(arg: string): string {
|
|
|
42
47
|
return arg;
|
|
43
48
|
}
|
|
44
49
|
|
|
50
|
+
async function resolveTorrentArg(arg: string): Promise<string> {
|
|
51
|
+
const { isMagnetUri } = await import("./torrent/magnet");
|
|
52
|
+
if (!isMagnetUri(arg)) return validateTorrentArg(arg);
|
|
53
|
+
const { resolveMagnetToTorrent } = await import("./torrent/magnet-resolver");
|
|
54
|
+
const result = await resolveMagnetToTorrent(arg);
|
|
55
|
+
return result.torrentPath;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function cachedTorrentArg(arg: string): Promise<string> {
|
|
59
|
+
const { isMagnetUri, parseMagnetUri } = await import("./torrent/magnet");
|
|
60
|
+
if (!isMagnetUri(arg)) return validateTorrentArg(arg);
|
|
61
|
+
const { metadataCachePath, readCachedMetadata } = await import(
|
|
62
|
+
"./torrent/metadata-cache"
|
|
63
|
+
);
|
|
64
|
+
const magnet = parseMagnetUri(arg);
|
|
65
|
+
if (!readCachedMetadata(magnet.infoHashHex)) {
|
|
66
|
+
fail("Error: cached metadata not found; add or download the magnet first");
|
|
67
|
+
}
|
|
68
|
+
return metadataCachePath(magnet.infoHashHex);
|
|
69
|
+
}
|
|
70
|
+
|
|
45
71
|
function sep(): void {
|
|
46
72
|
console.log("-".repeat(44));
|
|
47
73
|
}
|
|
@@ -184,9 +210,16 @@ async function runHandshake(torrentPath: string): Promise<void> {
|
|
|
184
210
|
}
|
|
185
211
|
|
|
186
212
|
async function runDownload(torrentPath: string): Promise<void> {
|
|
187
|
-
const { announce } = await import("./torrent/tracker/announce");
|
|
188
213
|
const { PeerManager } = await import("./torrent/peer/manager");
|
|
189
214
|
const { getPeerId, peerIdToString } = await import("./torrent/peer/peer-id");
|
|
215
|
+
const { DiscoveryCoordinator } = await import(
|
|
216
|
+
"./torrent/discovery/coordinator"
|
|
217
|
+
);
|
|
218
|
+
const {
|
|
219
|
+
createUploadedAccumulator,
|
|
220
|
+
recordRemovedPeerUpload,
|
|
221
|
+
uploadedSnapshot,
|
|
222
|
+
} = await import("./torrent/upload-accounting");
|
|
190
223
|
const { metadata, session } = await loadTorrent(torrentPath);
|
|
191
224
|
const { log } = await import("./torrent/metadata");
|
|
192
225
|
|
|
@@ -195,25 +228,42 @@ async function runDownload(torrentPath: string): Promise<void> {
|
|
|
195
228
|
|
|
196
229
|
await session.start();
|
|
197
230
|
|
|
198
|
-
const trackerResult = await announce(metadata).catch(() => null);
|
|
199
|
-
const peers = trackerResult?.peers ?? [];
|
|
200
|
-
|
|
201
231
|
console.log("");
|
|
202
232
|
const manager = new PeerManager(metadata);
|
|
203
233
|
await manager.start();
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
234
|
+
const uploadedAccumulator = createUploadedAccumulator();
|
|
235
|
+
const trackerCoordinator = new DiscoveryCoordinator(metadata, manager, {
|
|
236
|
+
getSnapshot: () => {
|
|
237
|
+
const downloaded = session.storage.downloadedBytes;
|
|
238
|
+
const uploaded = uploadedSnapshot(
|
|
239
|
+
uploadedAccumulator,
|
|
240
|
+
manager.connections.values(),
|
|
241
|
+
);
|
|
242
|
+
return {
|
|
243
|
+
downloaded,
|
|
244
|
+
uploaded,
|
|
245
|
+
left: Math.max(0, metadata.totalSize - downloaded),
|
|
246
|
+
};
|
|
247
|
+
},
|
|
248
|
+
onPeers: (peers) => {
|
|
249
|
+
void manager.connect(peers).then(() => {
|
|
250
|
+
const unchokedNow = manager.getUnchoked().length;
|
|
251
|
+
log(
|
|
252
|
+
"peers",
|
|
253
|
+
`${manager.connections.size} connected ${unchokedNow} unchoked`,
|
|
254
|
+
);
|
|
255
|
+
});
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
manager.on("peerRemoved", (conn: { uploadedTotal: number } & object) => {
|
|
259
|
+
recordRemovedPeerUpload(uploadedAccumulator, conn);
|
|
260
|
+
if (manager.connections.size === 0) trackerCoordinator.refreshNow();
|
|
261
|
+
});
|
|
262
|
+
await trackerCoordinator.start();
|
|
214
263
|
|
|
215
264
|
manager.startChoking();
|
|
216
265
|
const downloader = session.download(manager);
|
|
266
|
+
session.on("complete", () => trackerCoordinator.markCompleted());
|
|
217
267
|
|
|
218
268
|
await new Promise<void>((resolve) => {
|
|
219
269
|
session.on("complete", () => resolve());
|
|
@@ -253,6 +303,7 @@ async function runDownload(torrentPath: string): Promise<void> {
|
|
|
253
303
|
}
|
|
254
304
|
console.log(line);
|
|
255
305
|
|
|
306
|
+
await trackerCoordinator.stop();
|
|
256
307
|
manager.close();
|
|
257
308
|
}
|
|
258
309
|
|
|
@@ -275,25 +326,34 @@ async function main() {
|
|
|
275
326
|
const isDownload = args.includes("--download");
|
|
276
327
|
|
|
277
328
|
if (torrentArg) {
|
|
278
|
-
const
|
|
329
|
+
const { isMagnetUri } = await import("./torrent/magnet");
|
|
330
|
+
const torrentPath = isMagnetUri(torrentArg)
|
|
331
|
+
? torrentArg
|
|
332
|
+
: validateTorrentArg(torrentArg);
|
|
279
333
|
|
|
280
334
|
if (isVerify) {
|
|
281
|
-
await
|
|
282
|
-
|
|
335
|
+
const resolvedPath = await cachedTorrentArg(torrentPath);
|
|
336
|
+
await runVerify(resolvedPath).catch((e: unknown) =>
|
|
337
|
+
fail(`Error: ${e instanceof Error ? e.message : String(e)}`),
|
|
283
338
|
);
|
|
284
339
|
return;
|
|
285
340
|
}
|
|
286
341
|
|
|
287
342
|
if (isHandshake) {
|
|
288
|
-
await
|
|
289
|
-
|
|
343
|
+
const resolvedPath = await cachedTorrentArg(torrentPath);
|
|
344
|
+
await runHandshake(resolvedPath).catch((e: unknown) =>
|
|
345
|
+
fail(`Error: ${e instanceof Error ? e.message : String(e)}`),
|
|
290
346
|
);
|
|
291
347
|
return;
|
|
292
348
|
}
|
|
293
349
|
|
|
294
350
|
if (isDownload) {
|
|
295
|
-
await
|
|
296
|
-
|
|
351
|
+
const resolvedPath = await resolveTorrentArg(torrentPath).catch(
|
|
352
|
+
(e: unknown) =>
|
|
353
|
+
fail(`Error: ${e instanceof Error ? e.message : String(e)}`),
|
|
354
|
+
);
|
|
355
|
+
await runDownload(resolvedPath).catch((e: unknown) =>
|
|
356
|
+
fail(`Error: ${e instanceof Error ? e.message : String(e)}`),
|
|
297
357
|
);
|
|
298
358
|
return;
|
|
299
359
|
}
|