torrent-tui 0.0.1
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/LICENSE +21 -0
- package/README.md +156 -0
- package/bin/torrent-tui +26 -0
- package/package.json +58 -0
- package/src/app.ts +157 -0
- package/src/config/index.ts +41 -0
- package/src/config/settings.ts +15 -0
- package/src/constants/index.ts +16 -0
- package/src/controllers/app-controller.ts +180 -0
- package/src/index.ts +320 -0
- package/src/layout/add-torrent-dialog.ts +170 -0
- package/src/layout/confirm-dialog.ts +141 -0
- package/src/layout/content-window.ts +80 -0
- package/src/layout/sidebar.ts +121 -0
- package/src/layout/status-bar.ts +79 -0
- package/src/layout/toast-manager.ts +109 -0
- package/src/layout/toast.ts +257 -0
- package/src/layout/torrent-view.ts +250 -0
- package/src/store/index.ts +51 -0
- package/src/theme/default.ts +22 -0
- package/src/theme/index.ts +19 -0
- package/src/theme/types.ts +26 -0
- package/src/torrent/bridge.ts +301 -0
- package/src/torrent/downloader.ts +415 -0
- package/src/torrent/get_peers.ts +212 -0
- package/src/torrent/metadata.ts +190 -0
- package/src/torrent/parser.ts +216 -0
- package/src/torrent/peer/connection.ts +278 -0
- package/src/torrent/peer/handshake.ts +48 -0
- package/src/torrent/peer/listener.ts +52 -0
- package/src/torrent/peer/manager.ts +233 -0
- package/src/torrent/peer/message-buffer.ts +31 -0
- package/src/torrent/peer/peer-id.ts +21 -0
- package/src/torrent/peer/protocol.ts +123 -0
- package/src/torrent/piece-picker.ts +58 -0
- package/src/torrent/session.ts +56 -0
- package/src/torrent/storage.ts +197 -0
- package/src/torrent/tracker/announce.ts +36 -0
- package/src/torrent/tracker/http-tracker.ts +143 -0
- package/src/torrent/tracker/udp-tracker.ts +136 -0
- package/src/torrent/types.ts +25 -0
- package/src/types/layout.ts +6 -0
- package/src/utils/env.ts +8 -0
- package/src/utils/filter.ts +12 -0
- package/src/utils/layout.ts +32 -0
- package/src/utils/paths.ts +17 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { BoxRenderable, type CliRenderer, TextRenderable } from "@opentui/core";
|
|
2
|
+
import type { TorrentState } from "../store";
|
|
3
|
+
import { getTheme } from "../theme";
|
|
4
|
+
import type { LayoutDimensions } from "../types/layout";
|
|
5
|
+
|
|
6
|
+
const PREFIX_W = 2;
|
|
7
|
+
const SUFFIX_W = 3;
|
|
8
|
+
const SIZE_W = 9;
|
|
9
|
+
const SEP_W = 2;
|
|
10
|
+
const STATUS_W = 13;
|
|
11
|
+
const DL_W = 12;
|
|
12
|
+
const UL_W = 12;
|
|
13
|
+
const ETA_W = 9;
|
|
14
|
+
const PCT_W = 5;
|
|
15
|
+
|
|
16
|
+
const RIGHT_W = SIZE_W + SEP_W + STATUS_W + DL_W + UL_W + ETA_W + PCT_W + SUFFIX_W;
|
|
17
|
+
|
|
18
|
+
function calcWidths(cw: number) {
|
|
19
|
+
const available = cw - PREFIX_W - RIGHT_W;
|
|
20
|
+
const nameWidth = Math.max(12, Math.min(Math.floor(cw * 0.4), available));
|
|
21
|
+
const gap = Math.max(0, available - nameWidth);
|
|
22
|
+
return { nameWidth, gap };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function rightGroup(size: string, status: string, dl: string, ul: string, eta: string, pct: string): string {
|
|
26
|
+
return (
|
|
27
|
+
size.padStart(SIZE_W) +
|
|
28
|
+
" ".repeat(SEP_W) +
|
|
29
|
+
status.padEnd(STATUS_W) +
|
|
30
|
+
dl.padEnd(DL_W) +
|
|
31
|
+
ul.padEnd(UL_W) +
|
|
32
|
+
eta.padEnd(ETA_W) +
|
|
33
|
+
pct.padStart(PCT_W) +
|
|
34
|
+
" ".repeat(SUFFIX_W)
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function buildRow(left: string, nameWidth: number, gap: number, right: string, prefix = " "): string {
|
|
39
|
+
return prefix + left.padEnd(nameWidth) + " ".repeat(gap) + right;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function truncate(s: string, max: number): string {
|
|
43
|
+
return s.length > max ? s.slice(0, max - 1) + "…" : s;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function formatBytes(bytes: number): string {
|
|
47
|
+
if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)} GB`;
|
|
48
|
+
if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(1)} MB`;
|
|
49
|
+
if (bytes >= 1e3) return `${Math.round(bytes / 1e3)} KB`;
|
|
50
|
+
return `${bytes} B`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function formatSpeed(bps: number): string {
|
|
54
|
+
if (bps >= 1_000_000) return `${(bps / 1_000_000).toFixed(1)} MB/s`;
|
|
55
|
+
if (bps >= 1_000) return `${Math.round(bps / 1_000)} KB/s`;
|
|
56
|
+
return "";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function formatEta(secs: number | null): string {
|
|
60
|
+
if (!secs || secs <= 0) return "";
|
|
61
|
+
const h = Math.floor(secs / 3600);
|
|
62
|
+
const m = Math.floor((secs % 3600) / 60);
|
|
63
|
+
const s = Math.floor(secs % 60);
|
|
64
|
+
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
|
65
|
+
return `${m}:${String(s).padStart(2, "0")}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function setText(node: TextRenderable, content: string): void {
|
|
69
|
+
(node as unknown as { content: string }).content = content;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function setFg(node: TextRenderable, fg: string): void {
|
|
73
|
+
(node as unknown as { fg: string }).fg = fg;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function setBg(node: BoxRenderable, bg: string | undefined): void {
|
|
77
|
+
(node as unknown as { backgroundColor: string | undefined }).backgroundColor = bg;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface TorrentRow {
|
|
81
|
+
metaRow: BoxRenderable;
|
|
82
|
+
metaText: TextRenderable;
|
|
83
|
+
barRow: BoxRenderable;
|
|
84
|
+
filledText: TextRenderable;
|
|
85
|
+
emptyText: TextRenderable;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export class TorrentTable {
|
|
89
|
+
private renderer: CliRenderer;
|
|
90
|
+
private layout: LayoutDimensions;
|
|
91
|
+
private widths: ReturnType<typeof calcWidths>;
|
|
92
|
+
private container: BoxRenderable;
|
|
93
|
+
private headerText: TextRenderable;
|
|
94
|
+
private rows: TorrentRow[] = [];
|
|
95
|
+
|
|
96
|
+
private lastTorrents: TorrentState[] = [];
|
|
97
|
+
private lastSelectedIndex = 0;
|
|
98
|
+
private lastFocusArea: "sidebar" | "table" = "sidebar";
|
|
99
|
+
|
|
100
|
+
constructor(renderer: CliRenderer, layout: LayoutDimensions) {
|
|
101
|
+
this.renderer = renderer;
|
|
102
|
+
this.layout = layout;
|
|
103
|
+
this.widths = calcWidths(layout.content.width);
|
|
104
|
+
const built = this.buildShell(layout.content.width);
|
|
105
|
+
this.container = built.container;
|
|
106
|
+
this.headerText = built.headerText;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
getContainer(): BoxRenderable {
|
|
110
|
+
return this.container;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
update(torrents: TorrentState[], selectedIndex: number, focusArea: "sidebar" | "table"): void {
|
|
114
|
+
this.lastTorrents = torrents;
|
|
115
|
+
this.lastSelectedIndex = selectedIndex;
|
|
116
|
+
this.lastFocusArea = focusArea;
|
|
117
|
+
|
|
118
|
+
if (torrents.length !== this.rows.length) {
|
|
119
|
+
this.rebuildRows(torrents.length);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
for (let i = 0; i < torrents.length; i++) {
|
|
123
|
+
this.updateRow(this.rows[i]!, torrents[i]!, i === selectedIndex && focusArea === "table");
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
updateLayout(layout: LayoutDimensions): void {
|
|
128
|
+
this.layout = layout;
|
|
129
|
+
this.widths = calcWidths(layout.content.width);
|
|
130
|
+
(this.container as unknown as { width: number }).width = layout.content.width;
|
|
131
|
+
const { nameWidth, gap } = this.widths;
|
|
132
|
+
setText(
|
|
133
|
+
this.headerText,
|
|
134
|
+
buildRow("Name", nameWidth, gap, rightGroup("Size", "Status", "↓ Speed", "↑ Speed", "ETA", "%")),
|
|
135
|
+
);
|
|
136
|
+
this.update(this.lastTorrents, this.lastSelectedIndex, this.lastFocusArea);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private rebuildRows(count: number): void {
|
|
140
|
+
for (const row of this.rows) {
|
|
141
|
+
row.metaRow.destroy();
|
|
142
|
+
row.barRow.destroy();
|
|
143
|
+
}
|
|
144
|
+
this.rows = [];
|
|
145
|
+
for (let i = 0; i < count; i++) {
|
|
146
|
+
const row = this.createRow();
|
|
147
|
+
this.container.add(row.metaRow);
|
|
148
|
+
this.container.add(row.barRow);
|
|
149
|
+
this.rows.push(row);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private createRow(): TorrentRow {
|
|
154
|
+
const theme = getTheme();
|
|
155
|
+
const { nameWidth, gap } = this.widths;
|
|
156
|
+
const trailingW = gap + RIGHT_W;
|
|
157
|
+
const cw = this.layout.content.width;
|
|
158
|
+
|
|
159
|
+
const metaRow = new BoxRenderable(this.renderer, { width: cw, height: 1 });
|
|
160
|
+
const metaText = new TextRenderable(this.renderer, {
|
|
161
|
+
content: buildRow(" ".repeat(nameWidth), nameWidth, gap, " ".repeat(RIGHT_W)),
|
|
162
|
+
fg: theme.fgPrimary,
|
|
163
|
+
});
|
|
164
|
+
metaRow.add(metaText);
|
|
165
|
+
|
|
166
|
+
const barRow = new BoxRenderable(this.renderer, {
|
|
167
|
+
width: cw,
|
|
168
|
+
height: 1,
|
|
169
|
+
flexDirection: "row",
|
|
170
|
+
});
|
|
171
|
+
const filledText = new TextRenderable(this.renderer, {
|
|
172
|
+
content: " ",
|
|
173
|
+
fg: theme.accent,
|
|
174
|
+
});
|
|
175
|
+
const emptyText = new TextRenderable(this.renderer, {
|
|
176
|
+
content: " ".repeat(nameWidth + trailingW),
|
|
177
|
+
fg: theme.fgMuted,
|
|
178
|
+
});
|
|
179
|
+
barRow.add(filledText);
|
|
180
|
+
barRow.add(emptyText);
|
|
181
|
+
|
|
182
|
+
return { metaRow, metaText, barRow, filledText, emptyText };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private updateRow(row: TorrentRow, torrent: TorrentState, isSelected: boolean): void {
|
|
186
|
+
const theme = getTheme();
|
|
187
|
+
const { nameWidth, gap } = this.widths;
|
|
188
|
+
const trailingW = gap + RIGHT_W;
|
|
189
|
+
|
|
190
|
+
const pct = torrent.totalPieces > 0
|
|
191
|
+
? Math.floor((torrent.downloadedPieces / torrent.totalPieces) * 100)
|
|
192
|
+
: 0;
|
|
193
|
+
const filledLen = Math.round((pct / 100) * nameWidth);
|
|
194
|
+
const emptyLen = nameWidth - filledLen;
|
|
195
|
+
|
|
196
|
+
const barColor = (() => {
|
|
197
|
+
switch (torrent.status) {
|
|
198
|
+
case "seeding": return theme.success;
|
|
199
|
+
case "paused": return theme.warning;
|
|
200
|
+
case "stopped": return theme.fgSecondary;
|
|
201
|
+
case "error": return theme.error;
|
|
202
|
+
default: return theme.accent;
|
|
203
|
+
}
|
|
204
|
+
})();
|
|
205
|
+
|
|
206
|
+
// No background color — use text prefix + color to match sidebar pattern
|
|
207
|
+
setBg(row.metaRow, undefined);
|
|
208
|
+
setBg(row.barRow, undefined);
|
|
209
|
+
|
|
210
|
+
const prefix = isSelected ? "> " : " ";
|
|
211
|
+
const name = truncate(torrent.name, nameWidth);
|
|
212
|
+
const status = torrent.status.charAt(0).toUpperCase() + torrent.status.slice(1);
|
|
213
|
+
const dl = torrent.downloadBps > 0 ? `↓ ${formatSpeed(torrent.downloadBps)}` : "";
|
|
214
|
+
const ul = torrent.uploadBps > 0 ? `↑ ${formatSpeed(torrent.uploadBps)}` : "";
|
|
215
|
+
|
|
216
|
+
setText(row.metaText, buildRow(name, nameWidth, gap, rightGroup(
|
|
217
|
+
formatBytes(torrent.totalSize), status, dl, ul, formatEta(torrent.etaSeconds), `${pct}%`,
|
|
218
|
+
), prefix));
|
|
219
|
+
setFg(row.metaText, isSelected ? theme.accent : theme.fgPrimary);
|
|
220
|
+
|
|
221
|
+
setText(row.filledText, " " + "━".repeat(Math.max(0, filledLen)));
|
|
222
|
+
setFg(row.filledText, barColor);
|
|
223
|
+
setText(row.emptyText, "━".repeat(Math.max(0, emptyLen)) + " ".repeat(trailingW));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private buildShell(cw: number): { container: BoxRenderable; headerText: TextRenderable } {
|
|
227
|
+
const theme = getTheme();
|
|
228
|
+
const { nameWidth, gap } = this.widths;
|
|
229
|
+
|
|
230
|
+
const container = new BoxRenderable(this.renderer, {
|
|
231
|
+
position: "absolute",
|
|
232
|
+
left: 0,
|
|
233
|
+
top: 0,
|
|
234
|
+
width: cw,
|
|
235
|
+
flexDirection: "column",
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const headerText = new TextRenderable(this.renderer, {
|
|
239
|
+
content: buildRow("Name", nameWidth, gap, rightGroup("Size", "Status", "↓ Speed", "↑ Speed", "ETA", "%")),
|
|
240
|
+
fg: theme.fgPrimary,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const spacer = new TextRenderable(this.renderer, { content: "" });
|
|
244
|
+
|
|
245
|
+
container.add(headerText);
|
|
246
|
+
container.add(spacer);
|
|
247
|
+
|
|
248
|
+
return { container, headerText };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export interface TorrentState {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
totalSize: number;
|
|
5
|
+
downloadedPieces: number;
|
|
6
|
+
totalPieces: number;
|
|
7
|
+
status: "verifying" | "downloading" | "paused" | "seeding" | "stopped" | "error";
|
|
8
|
+
downloadBps: number;
|
|
9
|
+
uploadBps: number;
|
|
10
|
+
peers: number;
|
|
11
|
+
etaSeconds: number | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface AppState {
|
|
15
|
+
selectedIndex: number;
|
|
16
|
+
selectedView: string;
|
|
17
|
+
torrents: TorrentState[];
|
|
18
|
+
totalDownloadBps: number;
|
|
19
|
+
totalUploadBps: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type Listener = (state: AppState) => void;
|
|
23
|
+
|
|
24
|
+
export class Store {
|
|
25
|
+
private state: AppState;
|
|
26
|
+
private listeners: Set<Listener> = new Set();
|
|
27
|
+
|
|
28
|
+
constructor(initial: AppState) {
|
|
29
|
+
this.state = { ...initial };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
getState(): AppState {
|
|
33
|
+
return { ...this.state };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
setState(partial: Partial<AppState>): void {
|
|
37
|
+
this.state = { ...this.state, ...partial };
|
|
38
|
+
this.notify();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
subscribe(listener: Listener): () => void {
|
|
42
|
+
this.listeners.add(listener);
|
|
43
|
+
return () => this.listeners.delete(listener);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private notify(): void {
|
|
47
|
+
for (const listener of this.listeners) {
|
|
48
|
+
listener(this.getState());
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ColorScheme } from "./types";
|
|
2
|
+
|
|
3
|
+
// Default dark theme color scheme (Tokyo Night inspired)
|
|
4
|
+
export const DEFAULT_COLORSCHEME: ColorScheme = {
|
|
5
|
+
bgPrimary: "#1a1a2e",
|
|
6
|
+
bgSecondary: "#16213e",
|
|
7
|
+
bgTertiary: "#0f3460",
|
|
8
|
+
|
|
9
|
+
fgPrimary: "#eaeaea",
|
|
10
|
+
fgSecondary: "#a0a0a0",
|
|
11
|
+
fgMuted: "#606060",
|
|
12
|
+
|
|
13
|
+
accent: "#7aa2f7",
|
|
14
|
+
accentHover: "#9dcfed",
|
|
15
|
+
|
|
16
|
+
success: "#9ece6a",
|
|
17
|
+
warning: "#e0af68",
|
|
18
|
+
error: "#f7768e",
|
|
19
|
+
|
|
20
|
+
border: "#414868",
|
|
21
|
+
borderFocused: "#7aa2f7",
|
|
22
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { DEFAULT_COLORSCHEME } from "./default";
|
|
2
|
+
import type { ColorScheme } from "./types";
|
|
3
|
+
|
|
4
|
+
let currentTheme: ColorScheme = { ...DEFAULT_COLORSCHEME };
|
|
5
|
+
|
|
6
|
+
export function getTheme(): ColorScheme {
|
|
7
|
+
return currentTheme;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function setTheme(theme: ColorScheme): void {
|
|
11
|
+
currentTheme = { ...theme };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function resetTheme(): void {
|
|
15
|
+
currentTheme = { ...DEFAULT_COLORSCHEME };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type { ColorScheme } from "./types";
|
|
19
|
+
export { DEFAULT_COLORSCHEME };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Color scheme type definition
|
|
2
|
+
|
|
3
|
+
export interface ColorScheme {
|
|
4
|
+
// Backgrounds
|
|
5
|
+
bgPrimary: string;
|
|
6
|
+
bgSecondary: string;
|
|
7
|
+
bgTertiary: string;
|
|
8
|
+
|
|
9
|
+
// Foregrounds
|
|
10
|
+
fgPrimary: string;
|
|
11
|
+
fgSecondary: string;
|
|
12
|
+
fgMuted: string;
|
|
13
|
+
|
|
14
|
+
// Accents
|
|
15
|
+
accent: string;
|
|
16
|
+
accentHover: string;
|
|
17
|
+
|
|
18
|
+
// Status
|
|
19
|
+
success: string;
|
|
20
|
+
warning: string;
|
|
21
|
+
error: string;
|
|
22
|
+
|
|
23
|
+
// Borders
|
|
24
|
+
border: string;
|
|
25
|
+
borderFocused: string;
|
|
26
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { AppSettings } from "../config/settings";
|
|
4
|
+
import type { Store, TorrentState } from "../store";
|
|
5
|
+
import { getDataDir, resolvePath } from "../utils/paths";
|
|
6
|
+
import { TorrentMetadata } from "./metadata";
|
|
7
|
+
import { decode } from "./parser";
|
|
8
|
+
import type { BencodeValue } from "./parser";
|
|
9
|
+
import { TorrentSession } from "./session";
|
|
10
|
+
import { announce } from "./tracker/announce";
|
|
11
|
+
import { PeerManager } from "./peer/manager";
|
|
12
|
+
import type { Downloader } from "./downloader";
|
|
13
|
+
|
|
14
|
+
interface TorrentEntry {
|
|
15
|
+
torrentPath: string;
|
|
16
|
+
session: TorrentSession | null;
|
|
17
|
+
manager: PeerManager | null;
|
|
18
|
+
downloader: Downloader | null;
|
|
19
|
+
state: TorrentState;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface SessionRegistry {
|
|
23
|
+
torrents: Array<{ infoHash: string; torrentPath: string }>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class TorrentBridge {
|
|
27
|
+
private store: Store;
|
|
28
|
+
private config: AppSettings;
|
|
29
|
+
private torrents: Map<string, TorrentEntry> = new Map();
|
|
30
|
+
private pendingFlush = false;
|
|
31
|
+
private downloadPath: string;
|
|
32
|
+
|
|
33
|
+
constructor(store: Store, config: AppSettings) {
|
|
34
|
+
this.store = store;
|
|
35
|
+
this.config = config;
|
|
36
|
+
this.downloadPath = resolvePath(config.downloadPath);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async restoreSession(): Promise<void> {
|
|
40
|
+
const registryPath = this.registryPath();
|
|
41
|
+
if (!existsSync(registryPath)) return;
|
|
42
|
+
|
|
43
|
+
let registry: SessionRegistry;
|
|
44
|
+
try {
|
|
45
|
+
registry = JSON.parse(readFileSync(registryPath, "utf-8")) as SessionRegistry;
|
|
46
|
+
} catch {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
for (const { infoHash, torrentPath } of registry.torrents) {
|
|
51
|
+
if (!existsSync(torrentPath)) continue;
|
|
52
|
+
try {
|
|
53
|
+
const metadata = this.parseTorrent(torrentPath);
|
|
54
|
+
const actualId = Buffer.from(metadata.infoHash).toString("hex");
|
|
55
|
+
if (actualId !== infoHash) continue;
|
|
56
|
+
|
|
57
|
+
const downloadedPieces = this.loadResumeCount(infoHash);
|
|
58
|
+
const entry: TorrentEntry = {
|
|
59
|
+
torrentPath,
|
|
60
|
+
session: null,
|
|
61
|
+
manager: null,
|
|
62
|
+
downloader: null,
|
|
63
|
+
state: {
|
|
64
|
+
id: infoHash,
|
|
65
|
+
name: metadata.name,
|
|
66
|
+
totalSize: metadata.totalSize,
|
|
67
|
+
downloadedPieces,
|
|
68
|
+
totalPieces: metadata.pieceCount,
|
|
69
|
+
status: "stopped",
|
|
70
|
+
downloadBps: 0,
|
|
71
|
+
uploadBps: 0,
|
|
72
|
+
peers: 0,
|
|
73
|
+
etaSeconds: null,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
this.torrents.set(infoHash, entry);
|
|
77
|
+
} catch {
|
|
78
|
+
// skip invalid/unreadable torrent files
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
this.flushAll();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async addTorrent(torrentPath: string): Promise<void> {
|
|
86
|
+
const metadata = this.parseTorrent(torrentPath);
|
|
87
|
+
const id = Buffer.from(metadata.infoHash).toString("hex");
|
|
88
|
+
|
|
89
|
+
if (this.torrents.has(id)) return;
|
|
90
|
+
|
|
91
|
+
const entry: TorrentEntry = {
|
|
92
|
+
torrentPath,
|
|
93
|
+
session: null,
|
|
94
|
+
manager: null,
|
|
95
|
+
downloader: null,
|
|
96
|
+
state: {
|
|
97
|
+
id,
|
|
98
|
+
name: metadata.name,
|
|
99
|
+
totalSize: metadata.totalSize,
|
|
100
|
+
downloadedPieces: 0,
|
|
101
|
+
totalPieces: metadata.pieceCount,
|
|
102
|
+
status: "stopped",
|
|
103
|
+
downloadBps: 0,
|
|
104
|
+
uploadBps: 0,
|
|
105
|
+
peers: 0,
|
|
106
|
+
etaSeconds: null,
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
this.torrents.set(id, entry);
|
|
110
|
+
this.saveRegistry();
|
|
111
|
+
this.flushAll();
|
|
112
|
+
|
|
113
|
+
await this.runDownload(id, metadata);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async startTorrent(id: string): Promise<void> {
|
|
117
|
+
const entry = this.torrents.get(id);
|
|
118
|
+
if (!entry || entry.session !== null) return;
|
|
119
|
+
const metadata = this.parseTorrent(entry.torrentPath);
|
|
120
|
+
await this.runDownload(id, metadata);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async pauseTorrent(id: string): Promise<void> {
|
|
124
|
+
const entry = this.torrents.get(id);
|
|
125
|
+
if (!entry?.downloader || entry.state.status !== "downloading") return;
|
|
126
|
+
entry.downloader.pause();
|
|
127
|
+
this.updateEntry(id, { status: "paused", downloadBps: 0, etaSeconds: null });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async resumeTorrent(id: string): Promise<void> {
|
|
131
|
+
const entry = this.torrents.get(id);
|
|
132
|
+
if (!entry?.downloader || entry.state.status !== "paused") return;
|
|
133
|
+
entry.downloader.resume();
|
|
134
|
+
this.updateEntry(id, { status: "downloading" });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async removeTorrent(id: string, deleteFiles: boolean): Promise<void> {
|
|
138
|
+
const entry = this.torrents.get(id);
|
|
139
|
+
if (!entry) return;
|
|
140
|
+
|
|
141
|
+
entry.downloader?.stop();
|
|
142
|
+
entry.manager?.close();
|
|
143
|
+
|
|
144
|
+
if (deleteFiles) {
|
|
145
|
+
try {
|
|
146
|
+
const metadata = this.parseTorrent(entry.torrentPath);
|
|
147
|
+
if (metadata.files.length === 1 && metadata.files[0]) {
|
|
148
|
+
rmSync(join(this.downloadPath, metadata.files[0].path), { force: true });
|
|
149
|
+
} else {
|
|
150
|
+
rmSync(join(this.downloadPath, metadata.name), { recursive: true, force: true });
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
// ignore deletion errors
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
this.torrents.delete(id);
|
|
158
|
+
this.saveRegistry();
|
|
159
|
+
this.flushAll();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async stopAll(): Promise<void> {
|
|
163
|
+
for (const entry of this.torrents.values()) {
|
|
164
|
+
entry.downloader?.stop();
|
|
165
|
+
entry.manager?.close();
|
|
166
|
+
}
|
|
167
|
+
this.torrents.clear();
|
|
168
|
+
this.store.setState({ torrents: [], totalDownloadBps: 0, totalUploadBps: 0 });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private async runDownload(id: string, metadata: TorrentMetadata): Promise<void> {
|
|
172
|
+
const entry = this.torrents.get(id);
|
|
173
|
+
if (!entry) return;
|
|
174
|
+
|
|
175
|
+
const session = new TorrentSession(metadata, this.downloadPath);
|
|
176
|
+
entry.session = session;
|
|
177
|
+
|
|
178
|
+
this.updateEntry(id, { status: "verifying" });
|
|
179
|
+
|
|
180
|
+
await session.start();
|
|
181
|
+
this.updateEntry(id, { downloadedPieces: session.storage.downloadedCount, status: "downloading" });
|
|
182
|
+
|
|
183
|
+
const trackerResult = await announce(metadata).catch(() => null);
|
|
184
|
+
const peers = trackerResult?.peers ?? [];
|
|
185
|
+
|
|
186
|
+
const manager = new PeerManager(metadata, this.config.maxConnections);
|
|
187
|
+
entry.manager = manager;
|
|
188
|
+
|
|
189
|
+
await manager.start();
|
|
190
|
+
await manager.connect(peers);
|
|
191
|
+
|
|
192
|
+
if (manager.connections.size === 0) {
|
|
193
|
+
this.updateEntry(id, { status: "error" });
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
this.updateEntry(id, { peers: manager.connections.size });
|
|
198
|
+
|
|
199
|
+
manager.on("peerAdded", () => {
|
|
200
|
+
this.updateEntry(id, { peers: manager.connections.size });
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// Attach listeners BEFORE session.download() — for already-complete torrents
|
|
204
|
+
session.on("progress", (dl: number, _total: number, speed: number) => {
|
|
205
|
+
if (!this.torrents.has(id)) return;
|
|
206
|
+
const uploadBps = [...manager.connections.values()].reduce(
|
|
207
|
+
(sum, c) => sum + c.uploadBytesPerSec,
|
|
208
|
+
0,
|
|
209
|
+
);
|
|
210
|
+
const remaining = metadata.pieceCount - dl;
|
|
211
|
+
const etaSeconds = speed > 0 ? Math.round((remaining * metadata.pieceLength) / speed) : null;
|
|
212
|
+
this.updateEntry(id, {
|
|
213
|
+
downloadedPieces: dl,
|
|
214
|
+
downloadBps: Math.round(speed),
|
|
215
|
+
uploadBps,
|
|
216
|
+
etaSeconds,
|
|
217
|
+
peers: manager.connections.size,
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
session.on("status", (next: string) => {
|
|
222
|
+
if (!this.torrents.has(id)) return;
|
|
223
|
+
this.updateEntry(id, { status: next as TorrentState["status"] });
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
session.on("complete", () => {
|
|
227
|
+
if (!this.torrents.has(id)) return;
|
|
228
|
+
this.updateEntry(id, { status: "seeding", downloadBps: 0, etaSeconds: null });
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
manager.startChoking();
|
|
232
|
+
const downloader = session.download(manager);
|
|
233
|
+
entry.downloader = downloader;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private parseTorrent(torrentPath: string): TorrentMetadata {
|
|
237
|
+
const raw = new Uint8Array(readFileSync(torrentPath));
|
|
238
|
+
const decoded = decode(raw);
|
|
239
|
+
if (
|
|
240
|
+
typeof decoded !== "object" ||
|
|
241
|
+
decoded === null ||
|
|
242
|
+
Array.isArray(decoded) ||
|
|
243
|
+
decoded instanceof Uint8Array
|
|
244
|
+
) {
|
|
245
|
+
throw new Error("Invalid torrent file");
|
|
246
|
+
}
|
|
247
|
+
return new TorrentMetadata(decoded as { [key: string]: BencodeValue }, raw);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private loadResumeCount(infoHash: string): number {
|
|
251
|
+
const path = join(getDataDir(), "resume", `${infoHash}.json`);
|
|
252
|
+
if (!existsSync(path)) return 0;
|
|
253
|
+
try {
|
|
254
|
+
const data = JSON.parse(readFileSync(path, "utf-8")) as { downloadedPieces?: number[] };
|
|
255
|
+
return data.downloadedPieces?.length ?? 0;
|
|
256
|
+
} catch {
|
|
257
|
+
return 0;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private registryPath(): string {
|
|
262
|
+
return join(getDataDir(), "session.json");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private saveRegistry(): void {
|
|
266
|
+
const dir = getDataDir();
|
|
267
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
268
|
+
const entries = [...this.torrents.entries()].map(([infoHash, entry]) => ({
|
|
269
|
+
infoHash,
|
|
270
|
+
torrentPath: entry.torrentPath,
|
|
271
|
+
}));
|
|
272
|
+
try {
|
|
273
|
+
writeFileSync(this.registryPath(), JSON.stringify({ torrents: entries }, null, 2), "utf-8");
|
|
274
|
+
} catch {
|
|
275
|
+
// non-fatal
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private updateEntry(id: string, partial: Partial<TorrentState>): void {
|
|
280
|
+
const entry = this.torrents.get(id);
|
|
281
|
+
if (!entry) return;
|
|
282
|
+
entry.state = { ...entry.state, ...partial };
|
|
283
|
+
this.scheduleFlush();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private scheduleFlush(): void {
|
|
287
|
+
if (this.pendingFlush) return;
|
|
288
|
+
this.pendingFlush = true;
|
|
289
|
+
setTimeout(() => {
|
|
290
|
+
this.pendingFlush = false;
|
|
291
|
+
this.flushAll();
|
|
292
|
+
}, 100);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private flushAll(): void {
|
|
296
|
+
const states = [...this.torrents.values()].map((e) => e.state);
|
|
297
|
+
const totalDownloadBps = states.reduce((s, t) => s + t.downloadBps, 0);
|
|
298
|
+
const totalUploadBps = states.reduce((s, t) => s + t.uploadBps, 0);
|
|
299
|
+
this.store.setState({ torrents: states, totalDownloadBps, totalUploadBps });
|
|
300
|
+
}
|
|
301
|
+
}
|