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 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 | Not included yet |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "torrent-tui",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "description": "A Bun-powered terminal BitTorrent client.",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
package/src/app.ts CHANGED
@@ -1,4 +1,8 @@
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";
@@ -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({ exitOnCtrlC: true });
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
- filePath: string,
160
+ input: string,
147
161
  filename: string,
148
162
  ): Promise<void> {
149
163
  try {
150
- const result = await this.bridge.addTorrent(filePath);
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",
@@ -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: string) => boolean;
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.name);
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
- await manager.connect(peers);
205
-
206
- if (manager.connections.size === 0) {
207
- log("error", "no peers connected — cannot download");
208
- manager.close();
209
- return;
210
- }
211
-
212
- const unchoked = manager.getUnchoked().length;
213
- log("peers", `${manager.connections.size} connected ${unchoked} unchoked`);
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 torrentPath = validateTorrentArg(torrentArg);
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 runVerify(torrentPath).catch((e) =>
282
- fail(`Error: ${e instanceof Error ? e.message : e}`),
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 runHandshake(torrentPath).catch((e) =>
289
- fail(`Error: ${e instanceof Error ? e.message : e}`),
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 runDownload(torrentPath).catch((e) =>
296
- fail(`Error: ${e instanceof Error ? e.message : e}`),
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
  }