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
@@ -1,43 +1,60 @@
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
- import type { ContentWindow } from "../layout/content-window";
4
+ import type { ContentWindow, FocusArea } from "../layout/content-window";
5
+ import {
6
+ type DetailTab,
7
+ getDetailMaxScrollOffset,
8
+ } from "../layout/detail-panel";
5
9
  import type { Sidebar } from "../layout/sidebar";
10
+ import type { StatusBar } from "../layout/status-bar";
6
11
  import type { ToastManager } from "../layout/toast-manager";
7
12
  import type { Store } from "../store";
8
13
  import { filterTorrents } from "../utils/filter";
9
14
 
10
15
  type FocusMode = "global" | "dialog";
11
- type FocusArea = "sidebar" | "table";
16
+ const DETAIL_TABS: DetailTab[] = ["Pieces", "Peers", "Files"];
12
17
 
13
18
  export class AppController {
14
19
  private renderer: CliRenderer;
15
20
  private store: Store;
16
21
  private sidebar: Sidebar;
17
22
  private contentWindow: ContentWindow;
23
+ private statusBar: StatusBar;
18
24
  private toastManager: ToastManager;
19
25
  focusMode: FocusMode = "global";
20
26
  focusArea: FocusArea = "sidebar";
21
27
  private tableSelectedIndex = 0;
28
+ private detailTabIndex = 0;
29
+ private detailScrollOffsets: Record<DetailTab, number> = {
30
+ Pieces: 0,
31
+ Peers: 0,
32
+ Files: 0,
33
+ };
34
+ private lastDetailTorrentId: string | null = null;
22
35
  private pendingDeleteId: string | null = null;
23
36
 
24
37
  // Injected by App after bridge/dialog are created
25
38
  onAddTorrent?: () => void;
26
39
  onQuit?: () => void;
27
40
  onDialogClose?: () => void;
28
- onDialogInput?: (key: string) => boolean;
29
- onPauseTorrent?: (id: string) => void;
30
- onResumeTorrent?: (id: string) => void;
31
- onStartTorrent?: (id: string) => void;
32
- onRemoveTorrent?: (id: string, deleteFiles: boolean) => void;
41
+ onDialogInput?: (key: KeyEvent) => boolean;
42
+ onDialogPaste?: (event: PasteEvent) => boolean;
43
+ onPauseTorrent?: (id: string) => Promise<void> | void;
44
+ onResumeTorrent?: (id: string) => Promise<void> | void;
45
+ onStartTorrent?: (id: string) => Promise<void> | void;
46
+ onRemoveTorrent?: (id: string, deleteFiles: boolean) => Promise<void> | void;
33
47
 
34
48
  private _confirmDialog: ConfirmDialog | null = null;
35
49
  set confirmDialog(dialog: ConfirmDialog) {
36
50
  this._confirmDialog = dialog;
37
51
  dialog.onConfirm = () => {
38
52
  this.focusMode = "global";
39
- if (this.pendingDeleteId) {
40
- this.onRemoveTorrent?.(this.pendingDeleteId, true);
53
+ const pendingDeleteId = this.pendingDeleteId;
54
+ if (pendingDeleteId) {
55
+ this.runTorrentAction("remove torrent", () =>
56
+ this.onRemoveTorrent?.(pendingDeleteId, true),
57
+ );
41
58
  this.pendingDeleteId = null;
42
59
  }
43
60
  };
@@ -52,12 +69,14 @@ export class AppController {
52
69
  store: Store,
53
70
  sidebar: Sidebar,
54
71
  contentWindow: ContentWindow,
72
+ statusBar: StatusBar,
55
73
  toastManager: ToastManager,
56
74
  ) {
57
75
  this.renderer = renderer;
58
76
  this.store = store;
59
77
  this.sidebar = sidebar;
60
78
  this.contentWindow = contentWindow;
79
+ this.statusBar = statusBar;
61
80
  this.toastManager = toastManager;
62
81
  }
63
82
 
@@ -69,13 +88,23 @@ export class AppController {
69
88
  } else if (len === 0) {
70
89
  this.tableSelectedIndex = 0;
71
90
  }
91
+ this.syncDetailState();
72
92
  this.sidebar.update(state, this.focusArea);
73
- this.contentWindow.update(this.focusArea, this.tableSelectedIndex);
93
+ this.contentWindow.update(
94
+ this.focusArea,
95
+ this.tableSelectedIndex,
96
+ this.getDetailTab(),
97
+ this.getDetailScrollOffset(),
98
+ );
99
+ this.statusBar.update(state, this.focusArea);
74
100
  });
75
101
 
76
102
  this.renderer.keyInput.on("keypress", (key) => {
77
103
  this.handleKeyPress(key);
78
104
  });
105
+ this.renderer.keyInput.on("paste", (event: PasteEvent) => {
106
+ this.handlePaste(event);
107
+ });
79
108
 
80
109
  this.refreshView();
81
110
  }
@@ -83,12 +112,104 @@ export class AppController {
83
112
  private refreshView(): void {
84
113
  const state = this.store.getState();
85
114
  this.sidebar.update(state, this.focusArea);
86
- this.contentWindow.update(this.focusArea, this.tableSelectedIndex);
115
+ this.contentWindow.update(
116
+ this.focusArea,
117
+ this.tableSelectedIndex,
118
+ this.getDetailTab(),
119
+ this.getDetailScrollOffset(),
120
+ );
121
+ this.statusBar.update(state, this.focusArea);
122
+ }
123
+
124
+ private runTorrentAction(
125
+ label: string,
126
+ action: () => Promise<void> | void,
127
+ ): void {
128
+ Promise.resolve()
129
+ .then(action)
130
+ .catch((err: unknown) => {
131
+ this.toastManager.show({
132
+ id: `action-${Date.now()}`,
133
+ type: "error",
134
+ title: `Failed to ${label}`,
135
+ message: err instanceof Error ? err.message : String(err),
136
+ });
137
+ });
87
138
  }
88
139
 
89
140
  private getSelectedId(): string | null {
90
141
  const state = this.store.getState();
91
- return filterTorrents(state.torrents, state.selectedView)[this.tableSelectedIndex]?.id ?? null;
142
+ return (
143
+ filterTorrents(state.torrents, state.selectedView)[
144
+ this.tableSelectedIndex
145
+ ]?.id ?? null
146
+ );
147
+ }
148
+
149
+ private getDetailTab(): DetailTab {
150
+ return DETAIL_TABS[this.detailTabIndex] ?? "Pieces";
151
+ }
152
+
153
+ private moveDetailTab(delta: number): void {
154
+ this.detailTabIndex =
155
+ (this.detailTabIndex + delta + DETAIL_TABS.length) % DETAIL_TABS.length;
156
+ this.refreshView();
157
+ }
158
+
159
+ private getDetailScrollOffset(): number {
160
+ return this.detailScrollOffsets[this.getDetailTab()] ?? 0;
161
+ }
162
+
163
+ private setDetailScrollOffset(offset: number): void {
164
+ this.detailScrollOffsets[this.getDetailTab()] = offset;
165
+ }
166
+
167
+ private resetDetailScrollOffsets(): void {
168
+ this.detailScrollOffsets = {
169
+ Pieces: 0,
170
+ Peers: 0,
171
+ Files: 0,
172
+ };
173
+ }
174
+
175
+ private getSelectedTorrent() {
176
+ const state = this.store.getState();
177
+ return (
178
+ filterTorrents(state.torrents, state.selectedView)[
179
+ this.tableSelectedIndex
180
+ ] ?? null
181
+ );
182
+ }
183
+
184
+ private syncDetailState(): void {
185
+ const torrentId = this.getSelectedTorrent()?.id ?? null;
186
+ if (torrentId !== this.lastDetailTorrentId) {
187
+ this.lastDetailTorrentId = torrentId;
188
+ this.resetDetailScrollOffsets();
189
+ }
190
+ const maxOffset = getDetailMaxScrollOffset(
191
+ this.getSelectedTorrent(),
192
+ this.getDetailTab(),
193
+ this.contentWindow.getDetailBodyRowCount(),
194
+ );
195
+ this.setDetailScrollOffset(
196
+ Math.max(0, Math.min(this.getDetailScrollOffset(), maxOffset)),
197
+ );
198
+ }
199
+
200
+ private moveDetailScroll(delta: number): void {
201
+ const maxOffset = getDetailMaxScrollOffset(
202
+ this.getSelectedTorrent(),
203
+ this.getDetailTab(),
204
+ this.contentWindow.getDetailBodyRowCount(),
205
+ );
206
+ const nextOffset = Math.max(
207
+ 0,
208
+ Math.min(this.getDetailScrollOffset() + delta, maxOffset),
209
+ );
210
+ if (nextOffset === this.getDetailScrollOffset()) return;
211
+ this.setDetailScrollOffset(nextOffset);
212
+ this.refreshView();
92
213
  }
93
214
 
94
215
  private handleKeyPress(key: KeyEvent): void {
@@ -99,8 +220,13 @@ export class AppController {
99
220
  if (key.name === "escape") {
100
221
  this.focusMode = "global";
101
222
  this.onDialogClose?.();
223
+ key.preventDefault();
224
+ key.stopPropagation();
102
225
  } else {
103
- this.onDialogInput?.(key.name);
226
+ if (this.onDialogInput?.(key)) {
227
+ key.preventDefault();
228
+ key.stopPropagation();
229
+ }
104
230
  }
105
231
  }
106
232
  return;
@@ -110,57 +236,113 @@ export class AppController {
110
236
  return;
111
237
  }
112
238
 
239
+ if (key.shift && key.name === "tab") {
240
+ this.focusArea = this.previousFocusArea();
241
+ this.refreshView();
242
+ return;
243
+ }
244
+
113
245
  if (key.name === "tab") {
114
- this.focusArea = this.focusArea === "sidebar" ? "table" : "sidebar";
246
+ this.focusArea = this.nextFocusArea();
115
247
  this.refreshView();
116
248
  return;
117
249
  }
118
250
 
251
+ if (this.focusArea === "details") {
252
+ if (
253
+ key.name === "h" ||
254
+ key.name === "[" ||
255
+ key.name === "leftbracket" ||
256
+ key.name === "left"
257
+ ) {
258
+ this.moveDetailTab(-1);
259
+ return;
260
+ }
261
+ if (
262
+ key.name === "l" ||
263
+ key.name === "]" ||
264
+ key.name === "rightbracket" ||
265
+ key.name === "right"
266
+ ) {
267
+ this.moveDetailTab(1);
268
+ return;
269
+ }
270
+ }
271
+
119
272
  if (key.name === "j" || key.name === "down") {
120
273
  if (this.focusArea === "sidebar") {
121
274
  const total = SIDEBAR_ITEMS.status.length;
122
275
  const state = this.store.getState();
123
276
  const next = (state.selectedIndex + 1) % total;
124
- this.store.setState({ selectedIndex: next, selectedView: SIDEBAR_ITEMS.status[next] ?? "All" });
125
- } else {
277
+ this.store.setState({
278
+ selectedIndex: next,
279
+ selectedView: SIDEBAR_ITEMS.status[next] ?? "All",
280
+ });
281
+ } else if (this.focusArea === "table") {
126
282
  const state = this.store.getState();
127
283
  const len = filterTorrents(state.torrents, state.selectedView).length;
128
284
  if (len > 0) {
129
- this.tableSelectedIndex = Math.min(this.tableSelectedIndex + 1, len - 1);
285
+ this.tableSelectedIndex = Math.min(
286
+ this.tableSelectedIndex + 1,
287
+ len - 1,
288
+ );
130
289
  this.refreshView();
131
290
  }
291
+ } else if (this.focusArea === "details") {
292
+ this.moveDetailScroll(1);
132
293
  }
133
294
  } else if (key.name === "k" || key.name === "up") {
134
295
  if (this.focusArea === "sidebar") {
135
296
  const total = SIDEBAR_ITEMS.status.length;
136
297
  const state = this.store.getState();
137
298
  const prev = (state.selectedIndex - 1 + total) % total;
138
- this.store.setState({ selectedIndex: prev, selectedView: SIDEBAR_ITEMS.status[prev] ?? "All" });
139
- } else {
299
+ this.store.setState({
300
+ selectedIndex: prev,
301
+ selectedView: SIDEBAR_ITEMS.status[prev] ?? "All",
302
+ });
303
+ } else if (this.focusArea === "table") {
140
304
  if (this.tableSelectedIndex > 0) {
141
305
  this.tableSelectedIndex--;
142
306
  this.refreshView();
143
307
  }
308
+ } else if (this.focusArea === "details") {
309
+ this.moveDetailScroll(-1);
144
310
  }
145
311
  } else if (key.name === "space") {
146
312
  if (this.focusArea === "table") {
147
313
  const id = this.getSelectedId();
148
314
  if (!id) return;
149
315
  const state = this.store.getState();
150
- const torrent = filterTorrents(state.torrents, state.selectedView)[this.tableSelectedIndex];
316
+ const torrent = filterTorrents(state.torrents, state.selectedView)[
317
+ this.tableSelectedIndex
318
+ ];
151
319
  if (!torrent) return;
152
320
  if (torrent.status === "downloading") {
153
- this.onPauseTorrent?.(id);
321
+ this.runTorrentAction("pause torrent", () =>
322
+ this.onPauseTorrent?.(id),
323
+ );
154
324
  } else if (torrent.status === "paused") {
155
- this.onResumeTorrent?.(id);
156
- } else if (torrent.status === "stopped") {
157
- this.onStartTorrent?.(id);
325
+ this.runTorrentAction("resume torrent", () =>
326
+ this.onResumeTorrent?.(id),
327
+ );
328
+ } else if (
329
+ torrent.status === "stopped" ||
330
+ torrent.status === "stalled" ||
331
+ torrent.status === "error" ||
332
+ torrent.status === "missing"
333
+ ) {
334
+ this.runTorrentAction("start torrent", () =>
335
+ this.onStartTorrent?.(id),
336
+ );
158
337
  }
159
338
  }
160
339
  } else if (key.name === "d" && !key.shift) {
161
340
  if (this.focusArea === "table") {
162
341
  const id = this.getSelectedId();
163
- if (id) this.onRemoveTorrent?.(id, false);
342
+ if (id)
343
+ this.runTorrentAction("remove torrent", () =>
344
+ this.onRemoveTorrent?.(id, false),
345
+ );
164
346
  }
165
347
  } else if (key.name === "d" && key.shift) {
166
348
  if (this.focusArea === "table") {
@@ -173,8 +355,39 @@ export class AppController {
173
355
  } else if (key.name === "a") {
174
356
  this.focusMode = "dialog";
175
357
  this.onAddTorrent?.();
358
+ key.preventDefault();
359
+ key.stopPropagation();
176
360
  } else if (key.name === "q") {
177
361
  this.onQuit?.();
178
362
  }
179
363
  }
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
+
372
+ private nextFocusArea(): FocusArea {
373
+ switch (this.focusArea) {
374
+ case "sidebar":
375
+ return "table";
376
+ case "table":
377
+ return "details";
378
+ case "details":
379
+ return "sidebar";
380
+ }
381
+ }
382
+
383
+ private previousFocusArea(): FocusArea {
384
+ switch (this.focusArea) {
385
+ case "sidebar":
386
+ return "details";
387
+ case "table":
388
+ return "sidebar";
389
+ case "details":
390
+ return "table";
391
+ }
392
+ }
180
393
  }
package/src/index.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { getPeers } from "./torrent/get_peers";
4
3
 
5
4
  class CliExit extends Error {}
6
5
 
@@ -21,13 +20,18 @@ async function printHelp(): Promise<void> {
21
20
 
22
21
  Usage:
23
22
  torrent-tui Start the terminal UI
24
- torrent-tui <file.torrent> Announce and print peers
23
+ torrent-tui <file.torrent> Start the TUI and add the torrent
24
+ torrent-tui <magnet-uri> Start the TUI and fetch magnet metadata
25
25
  torrent-tui <file.torrent> --verify Verify local pieces and trackers
26
26
  torrent-tui <file.torrent> --handshake
27
27
  Connect to peers and print handshake summary
28
- torrent-tui <file.torrent> --download
28
+ torrent-tui <file.torrent|magnet> --download
29
29
  Download from the command line
30
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
+
31
35
  Options:
32
36
  --help, -h Show this help
33
37
  --version, -v Print the version`);
@@ -43,6 +47,27 @@ function validateTorrentArg(arg: string): string {
43
47
  return arg;
44
48
  }
45
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
+
46
71
  function sep(): void {
47
72
  console.log("-".repeat(44));
48
73
  }
@@ -185,9 +210,16 @@ async function runHandshake(torrentPath: string): Promise<void> {
185
210
  }
186
211
 
187
212
  async function runDownload(torrentPath: string): Promise<void> {
188
- const { announce } = await import("./torrent/tracker/announce");
189
213
  const { PeerManager } = await import("./torrent/peer/manager");
190
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");
191
223
  const { metadata, session } = await loadTorrent(torrentPath);
192
224
  const { log } = await import("./torrent/metadata");
193
225
 
@@ -196,30 +228,42 @@ async function runDownload(torrentPath: string): Promise<void> {
196
228
 
197
229
  await session.start();
198
230
 
199
- const trackerResult = await announce(metadata).catch(() => null);
200
- const peers = trackerResult?.peers ?? [];
201
-
202
231
  console.log("");
203
232
  const manager = new PeerManager(metadata);
204
233
  await manager.start();
205
-
206
- // connect() now resolves only after each handshake completes —
207
- // so all handshake logs finish before the progress bar starts
208
- await manager.connect(peers);
209
-
210
- if (manager.connections.size === 0) {
211
- log("error", "no peers connected — cannot download");
212
- manager.close();
213
- return;
214
- }
215
-
216
- const unchoked = manager.getUnchoked().length;
217
- 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();
218
263
 
219
264
  manager.startChoking();
220
265
  const downloader = session.download(manager);
221
- log("log file", downloader.getLogFilePath());
222
- console.log("");
266
+ session.on("complete", () => trackerCoordinator.markCompleted());
223
267
 
224
268
  await new Promise<void>((resolve) => {
225
269
  session.on("complete", () => resolve());
@@ -259,6 +303,7 @@ async function runDownload(torrentPath: string): Promise<void> {
259
303
  }
260
304
  console.log(line);
261
305
 
306
+ await trackerCoordinator.stop();
262
307
  manager.close();
263
308
  }
264
309
 
@@ -281,41 +326,47 @@ async function main() {
281
326
  const isDownload = args.includes("--download");
282
327
 
283
328
  if (torrentArg) {
284
- const torrentPath = validateTorrentArg(torrentArg);
329
+ const { isMagnetUri } = await import("./torrent/magnet");
330
+ const torrentPath = isMagnetUri(torrentArg)
331
+ ? torrentArg
332
+ : validateTorrentArg(torrentArg);
285
333
 
286
334
  if (isVerify) {
287
- await runVerify(torrentPath).catch((e) =>
288
- 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)}`),
289
338
  );
290
339
  return;
291
340
  }
292
341
 
293
342
  if (isHandshake) {
294
- await runHandshake(torrentPath).catch((e) =>
295
- 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)}`),
296
346
  );
297
347
  return;
298
348
  }
299
349
 
300
350
  if (isDownload) {
301
- await runDownload(torrentPath).catch((e) =>
302
- 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)}`),
303
357
  );
304
358
  return;
305
359
  }
306
360
 
307
- try {
308
- await getPeers(torrentPath, 6881, 50);
309
- } catch (e) {
310
- fail(`Error: ${e instanceof Error ? e.message : e}`);
311
- }
312
-
361
+ const { App } = await import("./app");
362
+ const app = new App();
363
+ await app.start(torrentPath);
313
364
  return;
314
365
  }
315
366
 
316
367
  const { App } = await import("./app");
317
368
  const app = new App();
318
- app.start();
369
+ await app.start();
319
370
  }
320
371
 
321
372
  main().catch((err: unknown) => {