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
|
@@ -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
|
-
|
|
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:
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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.
|
|
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({
|
|
125
|
-
|
|
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(
|
|
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({
|
|
139
|
-
|
|
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)[
|
|
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.
|
|
321
|
+
this.runTorrentAction("pause torrent", () =>
|
|
322
|
+
this.onPauseTorrent?.(id),
|
|
323
|
+
);
|
|
154
324
|
} else if (torrent.status === "paused") {
|
|
155
|
-
this.
|
|
156
|
-
|
|
157
|
-
|
|
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)
|
|
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>
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
288
|
-
|
|
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
|
|
295
|
-
|
|
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
|
|
302
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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) => {
|