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.
Files changed (46) hide show
  1. package/README.md +61 -4
  2. package/package.json +6 -2
  3. package/src/app.ts +67 -30
  4. package/src/config/index.ts +7 -9
  5. package/src/constants/index.ts +1 -1
  6. package/src/controllers/app-controller.ts +239 -26
  7. package/src/index.ts +87 -36
  8. package/src/layout/add-torrent-dialog.ts +316 -51
  9. package/src/layout/confirm-dialog.ts +37 -15
  10. package/src/layout/content-window.ts +130 -25
  11. package/src/layout/detail-panel.ts +458 -0
  12. package/src/layout/sidebar.ts +5 -2
  13. package/src/layout/status-bar.ts +23 -5
  14. package/src/layout/toast.ts +1 -2
  15. package/src/layout/torrent-view.ts +167 -50
  16. package/src/store/index.ts +32 -1
  17. package/src/torrent/bridge.ts +699 -79
  18. package/src/torrent/dht/node.ts +380 -0
  19. package/src/torrent/dht/protocol.ts +225 -0
  20. package/src/torrent/dht/routing.ts +98 -0
  21. package/src/torrent/discovery/coordinator.ts +231 -0
  22. package/src/torrent/downloader.ts +177 -119
  23. package/src/torrent/magnet-resolver.ts +245 -0
  24. package/src/torrent/magnet.ts +103 -0
  25. package/src/torrent/metadata-cache.ts +152 -0
  26. package/src/torrent/metadata.ts +62 -21
  27. package/src/torrent/parser.ts +8 -4
  28. package/src/torrent/peer/connection.ts +209 -13
  29. package/src/torrent/peer/extension.ts +270 -0
  30. package/src/torrent/peer/handshake.ts +2 -0
  31. package/src/torrent/peer/listener.ts +1 -1
  32. package/src/torrent/peer/manager.ts +80 -29
  33. package/src/torrent/peer/protocol.ts +13 -0
  34. package/src/torrent/piece-picker.ts +4 -1
  35. package/src/torrent/resume.ts +147 -0
  36. package/src/torrent/session.ts +53 -8
  37. package/src/torrent/storage.ts +309 -48
  38. package/src/torrent/tracker/announce.ts +42 -15
  39. package/src/torrent/tracker/coordinator.ts +254 -0
  40. package/src/torrent/tracker/http-tracker.ts +113 -59
  41. package/src/torrent/tracker/udp-tracker.ts +91 -34
  42. package/src/torrent/types.ts +25 -2
  43. package/src/torrent/upload-accounting.ts +35 -0
  44. package/src/utils/filter.ts +28 -6
  45. package/src/utils/json.ts +23 -0
  46. 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
  [![npm version](https://img.shields.io/npm/v/torrent-tui?style=for-the-badge&logo=npm)](https://www.npmjs.com/package/torrent-tui)
6
- [![CI](https://img.shields.io/github/actions/workflow/status/ryadios/torrent-tui/ci.yml?branch=main&style=for-the-badge&logo=github)](https://github.com/ryadios/torrent-tui/actions/workflows/ci.yml)
6
+ [![gzipped](https://img.shields.io/badge/gzipped-39%20KB-2563eb?style=for-the-badge)](https://www.npmjs.com/package/torrent-tui)
7
+ [![npm unpacked size](https://img.shields.io/npm/unpacked-size/torrent-tui?style=for-the-badge)](https://www.npmjs.com/package/torrent-tui)
8
+ [![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)
7
9
  [![license](https://img.shields.io/npm/l/torrent-tui?style=for-the-badge)](./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>` | Announce to trackers and print peers. |
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 | Not included yet |
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.2",
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 { 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 = {
@@ -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({ 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);
@@ -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 = async (filePath) => {
119
+ this.addDialog.onSelect = (filePath) => {
105
120
  this.controller.focusMode = "global";
106
121
  const filename = filePath.split("/").pop() ?? filePath;
107
- this.toastManager.show({
108
- id: `add-${Date.now()}`,
109
- type: "info",
110
- title: "Adding torrent",
111
- message: filename,
112
- });
122
+ this.renderer.requestRender();
113
123
 
114
- try {
115
- await this.bridge.addTorrent(filePath);
116
- this.toastManager.show({
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
  }
@@ -1,7 +1,9 @@
1
1
  // Config loader - reads from ~/.config/torrent-tui/settings.json
2
2
 
3
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
- import { getConfigDir, getConfigPath } from "../utils/paths";
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
- try {
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
  }
@@ -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;