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,415 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { SHA1 } from "bun";
|
|
5
|
+
import { log } from "./metadata.ts";
|
|
6
|
+
import { PiecePicker } from "./piece-picker.ts";
|
|
7
|
+
import { getDataDir } from "../utils/paths.ts";
|
|
8
|
+
import type { TorrentMetadata } from "./metadata.ts";
|
|
9
|
+
import type { StorageManager } from "./storage.ts";
|
|
10
|
+
import type { PeerManager } from "./peer/manager.ts";
|
|
11
|
+
import type { PeerConnection } from "./peer/connection.ts";
|
|
12
|
+
|
|
13
|
+
const BLOCK_SIZE = 16_384;
|
|
14
|
+
const PIPELINE_DEPTH = 15;
|
|
15
|
+
const CORRUPT_STRIKE_LIMIT = 2;
|
|
16
|
+
|
|
17
|
+
interface InProgressPiece {
|
|
18
|
+
blocks: (Buffer | null)[];
|
|
19
|
+
received: number;
|
|
20
|
+
total: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class Downloader extends EventEmitter {
|
|
24
|
+
private stopped = false;
|
|
25
|
+
private paused = false;
|
|
26
|
+
private inProgress = new Map<number, InProgressPiece>();
|
|
27
|
+
private pendingRequests = new Map<string, Set<string>>(); // peerKey → "idx:begin"
|
|
28
|
+
private corruptStrikes = new Map<string, number>();
|
|
29
|
+
private bannedPeers = new Set<string>();
|
|
30
|
+
private nextPieceIndex = 0;
|
|
31
|
+
private bytesThisSecond = 0;
|
|
32
|
+
private lastSpeedReset = Date.now();
|
|
33
|
+
private speedBytesPerSec = 0;
|
|
34
|
+
private uploadBytesPerSec = 0;
|
|
35
|
+
private progressInterval: ReturnType<typeof setInterval> | null = null;
|
|
36
|
+
private logFilePath: string;
|
|
37
|
+
private progressActive = false;
|
|
38
|
+
private picker!: PiecePicker;
|
|
39
|
+
|
|
40
|
+
constructor(
|
|
41
|
+
private metadata: TorrentMetadata,
|
|
42
|
+
private storage: StorageManager,
|
|
43
|
+
private manager: PeerManager,
|
|
44
|
+
private downloadPath: string,
|
|
45
|
+
) {
|
|
46
|
+
super();
|
|
47
|
+
const logDir = join(getDataDir(), "logs");
|
|
48
|
+
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
|
|
49
|
+
const hex = Buffer.from(metadata.infoHash).toString("hex");
|
|
50
|
+
this.logFilePath = join(logDir, `${hex}.log`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
getLogFilePath(): string { return this.logFilePath; }
|
|
54
|
+
|
|
55
|
+
start(): void {
|
|
56
|
+
this.picker = new PiecePicker(
|
|
57
|
+
this.metadata.pieceCount,
|
|
58
|
+
(i) => this.storage.hasPiece(i),
|
|
59
|
+
(i) => this.inProgress.has(i),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
this.loadResume();
|
|
63
|
+
this.advanceNextPiece();
|
|
64
|
+
|
|
65
|
+
if (this.nextPieceIndex >= this.metadata.pieceCount) {
|
|
66
|
+
this.emit("complete");
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Wire up existing peers and seed their availability into the picker
|
|
71
|
+
for (const conn of this.manager.connections.values()) {
|
|
72
|
+
this.picker.addPeer(conn);
|
|
73
|
+
this.wirePeer(conn);
|
|
74
|
+
if (!conn.amChoked) this.fillPipeline(conn);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Wire up peers that connect after start()
|
|
78
|
+
this.manager.on("peerAdded", (conn: PeerConnection) => {
|
|
79
|
+
this.picker.addPeer(conn);
|
|
80
|
+
this.wirePeer(conn);
|
|
81
|
+
if (!conn.amChoked) this.fillPipeline(conn);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
this.progressInterval = setInterval(() => this.logProgress(), 2_000);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
stop(): void {
|
|
88
|
+
this.stopped = true;
|
|
89
|
+
if (this.progressInterval) clearInterval(this.progressInterval);
|
|
90
|
+
if (this.progressActive) {
|
|
91
|
+
process.stdout.write("\n");
|
|
92
|
+
this.progressActive = false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
pause(): void {
|
|
97
|
+
if (this.stopped) return;
|
|
98
|
+
this.paused = true;
|
|
99
|
+
if (this.progressInterval) clearInterval(this.progressInterval);
|
|
100
|
+
this.progressInterval = null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
resume(): void {
|
|
104
|
+
if (this.stopped || !this.paused) return;
|
|
105
|
+
this.paused = false;
|
|
106
|
+
this.progressInterval = setInterval(() => this.logProgress(), 2_000);
|
|
107
|
+
for (const conn of this.manager.connections.values()) {
|
|
108
|
+
if (!conn.amChoked) this.fillPipeline(conn);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private wirePeer(conn: PeerConnection): void {
|
|
113
|
+
const key = `${conn.address}:${conn.port}`;
|
|
114
|
+
if (this.bannedPeers.has(key)) return;
|
|
115
|
+
if (!this.pendingRequests.has(key)) this.pendingRequests.set(key, new Set());
|
|
116
|
+
|
|
117
|
+
// Tell the peer what pieces we already have
|
|
118
|
+
if (this.storage.downloadedCount > 0) {
|
|
119
|
+
conn.sendBitfield(this.storage.getBitfield());
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
conn.on("unchoke", () => {
|
|
123
|
+
if (!this.stopped && !this.bannedPeers.has(key)) this.fillPipeline(conn);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
conn.on("choke", () => {
|
|
127
|
+
this.clearPending(key);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
conn.on("have", (index: number) => {
|
|
131
|
+
this.picker.onHave(index);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
conn.on("piece", (index: number, begin: number, block: Uint8Array) => {
|
|
135
|
+
this.onBlock(conn, index, begin, block);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Seeding: serve blocks to unchoked peers that request pieces we have
|
|
139
|
+
conn.on("request", (index: number, begin: number, length: number) => {
|
|
140
|
+
if (conn.peerChoked) return; // we choked this peer — don't serve
|
|
141
|
+
if (!this.storage.hasPiece(index)) return;
|
|
142
|
+
const piece = this.storage.readPieceSync(index);
|
|
143
|
+
const block = Buffer.from(piece).subarray(begin, begin + length);
|
|
144
|
+
conn.sendPiece(index, begin, block);
|
|
145
|
+
this.uploadBytesPerSec = (this.uploadBytesPerSec + block.length) / 2;
|
|
146
|
+
log("upload", `piece=${index} block=${begin / 16384} ${conn.address}:${conn.port}`);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
conn.on("disconnect", () => {
|
|
150
|
+
this.picker.removePeer(conn);
|
|
151
|
+
this.clearPending(key);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private fillPipeline(conn: PeerConnection): void {
|
|
156
|
+
if (this.stopped || this.paused) return;
|
|
157
|
+
const key = `${conn.address}:${conn.port}`;
|
|
158
|
+
if (this.bannedPeers.has(key)) return;
|
|
159
|
+
|
|
160
|
+
const pending = this.pendingRequests.get(key) ?? new Set<string>();
|
|
161
|
+
this.pendingRequests.set(key, pending);
|
|
162
|
+
|
|
163
|
+
while (pending.size < PIPELINE_DEPTH) {
|
|
164
|
+
const next = this.nextBlock(conn);
|
|
165
|
+
if (!next) break;
|
|
166
|
+
const { pieceIndex, begin, length } = next;
|
|
167
|
+
const reqKey = `${pieceIndex}:${begin}`;
|
|
168
|
+
pending.add(reqKey);
|
|
169
|
+
conn.sendRequest(pieceIndex, begin, length);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private nextBlock(conn: PeerConnection): { pieceIndex: number; begin: number; length: number } | null {
|
|
174
|
+
// Tier 1: finish any in-progress piece this peer has (avoids partial waste)
|
|
175
|
+
for (const [pieceIndex, piece] of this.inProgress) {
|
|
176
|
+
if (!conn.hasPiece(pieceIndex)) continue;
|
|
177
|
+
for (let b = 0; b < piece.total; b++) {
|
|
178
|
+
if (piece.blocks[b] !== null) continue;
|
|
179
|
+
const begin = b * BLOCK_SIZE;
|
|
180
|
+
const reqKey = `${pieceIndex}:${begin}`;
|
|
181
|
+
if (this.isRequested(reqKey)) continue;
|
|
182
|
+
return { pieceIndex, begin, length: this.blockLength(pieceIndex, b) };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Tier 2: rarest-first via PiecePicker
|
|
187
|
+
const pieceIndex = this.picker.pick(conn);
|
|
188
|
+
if (pieceIndex === null) return null;
|
|
189
|
+
|
|
190
|
+
const total = this.pieceBlockCount(pieceIndex);
|
|
191
|
+
this.inProgress.set(pieceIndex, { blocks: new Array(total).fill(null), received: 0, total });
|
|
192
|
+
this.fileLog(`piece ${pieceIndex} started ${total} blocks (avail ${this.picker.availabilityOf(pieceIndex)})`);
|
|
193
|
+
return { pieceIndex, begin: 0, length: this.blockLength(pieceIndex, 0) };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private isRequested(reqKey: string): boolean {
|
|
197
|
+
for (const pending of this.pendingRequests.values()) {
|
|
198
|
+
if (pending.has(reqKey)) return true;
|
|
199
|
+
}
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private onBlock(conn: PeerConnection, index: number, begin: number, block: Uint8Array): void {
|
|
204
|
+
const key = `${conn.address}:${conn.port}`;
|
|
205
|
+
const reqKey = `${index}:${begin}`;
|
|
206
|
+
this.pendingRequests.get(key)?.delete(reqKey);
|
|
207
|
+
|
|
208
|
+
const piece = this.inProgress.get(index);
|
|
209
|
+
if (!piece) { this.fillPipeline(conn); return; }
|
|
210
|
+
|
|
211
|
+
const blockIdx = begin / BLOCK_SIZE;
|
|
212
|
+
if (piece.blocks[blockIdx] !== null) { this.fillPipeline(conn); return; }
|
|
213
|
+
|
|
214
|
+
piece.blocks[blockIdx] = Buffer.from(block);
|
|
215
|
+
piece.received++;
|
|
216
|
+
this.bytesThisSecond += block.length;
|
|
217
|
+
|
|
218
|
+
if (piece.received === piece.total) {
|
|
219
|
+
this.finishPiece(index, piece, conn);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (!this.stopped) this.fillPipeline(conn);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private finishPiece(index: number, piece: InProgressPiece, conn: PeerConnection): void {
|
|
226
|
+
const key = `${conn.address}:${conn.port}`;
|
|
227
|
+
this.inProgress.delete(index);
|
|
228
|
+
|
|
229
|
+
// Assemble blocks
|
|
230
|
+
const assembled = Buffer.concat(piece.blocks.filter((b): b is Buffer => b !== null));
|
|
231
|
+
|
|
232
|
+
// SHA-1 verify in memory
|
|
233
|
+
const actual = new SHA1().update(assembled).digest() as unknown as Uint8Array;
|
|
234
|
+
const expected = this.metadata.pieceHashes[index];
|
|
235
|
+
|
|
236
|
+
if (!expected || !bufEqual(actual, expected)) {
|
|
237
|
+
const strikes = (this.corruptStrikes.get(key) ?? 0) + 1;
|
|
238
|
+
this.corruptStrikes.set(key, strikes);
|
|
239
|
+
this.fileLog(`piece ${index} FAIL peer ${conn.peerId.slice(0, 8)} strike ${strikes}`);
|
|
240
|
+
this.consolePrint(` piece ${index} FAIL (${conn.peerId.slice(0, 8)}, strike ${strikes})`);
|
|
241
|
+
|
|
242
|
+
if (strikes >= CORRUPT_STRIKE_LIMIT) {
|
|
243
|
+
this.bannedPeers.add(key);
|
|
244
|
+
conn.suppressDisconnect = true;
|
|
245
|
+
conn.destroy();
|
|
246
|
+
this.consolePrint(` banned ${conn.peerId.slice(0, 8)} (${strikes} corrupt pieces)`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
this.emit("piece:failed", index, key);
|
|
250
|
+
this.advanceNextPiece();
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Write to disk
|
|
255
|
+
this.storage.writePieceSync(index, assembled);
|
|
256
|
+
this.saveResume();
|
|
257
|
+
this.fileLog(`piece ${index} ok`);
|
|
258
|
+
|
|
259
|
+
// Broadcast HAVE to all connected peers
|
|
260
|
+
for (const peer of this.manager.connections.values()) {
|
|
261
|
+
if (`${peer.address}:${peer.port}` !== key) peer.sendHave(index);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
this.advanceNextPiece();
|
|
265
|
+
this.emit("piece:verified", index);
|
|
266
|
+
|
|
267
|
+
// Speed tracking
|
|
268
|
+
const now = Date.now();
|
|
269
|
+
if (now - this.lastSpeedReset >= 1_000) {
|
|
270
|
+
this.speedBytesPerSec = this.bytesThisSecond;
|
|
271
|
+
this.bytesThisSecond = 0;
|
|
272
|
+
this.lastSpeedReset = now;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
this.emit(
|
|
276
|
+
"progress",
|
|
277
|
+
this.storage.downloadedCount,
|
|
278
|
+
this.metadata.pieceCount,
|
|
279
|
+
this.speedBytesPerSec,
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
if (this.storage.downloadedCount === this.metadata.pieceCount) {
|
|
283
|
+
this.stop();
|
|
284
|
+
this.consolePrint(` complete ${this.metadata.pieceCount} / ${this.metadata.pieceCount} pieces ${this.metadata.name}`);
|
|
285
|
+
this.emit("complete");
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private clearPending(key: string): void {
|
|
290
|
+
this.pendingRequests.get(key)?.clear();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private advanceNextPiece(): void {
|
|
294
|
+
while (
|
|
295
|
+
this.nextPieceIndex < this.metadata.pieceCount &&
|
|
296
|
+
this.storage.hasPiece(this.nextPieceIndex)
|
|
297
|
+
) {
|
|
298
|
+
this.nextPieceIndex++;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private pieceBlockCount(pieceIndex: number): number {
|
|
303
|
+
const isLast = pieceIndex === this.metadata.pieceCount - 1;
|
|
304
|
+
const len = isLast
|
|
305
|
+
? this.metadata.totalSize - pieceIndex * this.metadata.pieceLength
|
|
306
|
+
: this.metadata.pieceLength;
|
|
307
|
+
return Math.ceil(len / BLOCK_SIZE);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private blockLength(pieceIndex: number, blockIdx: number): number {
|
|
311
|
+
const isLastPiece = pieceIndex === this.metadata.pieceCount - 1;
|
|
312
|
+
const pieceLen = isLastPiece
|
|
313
|
+
? this.metadata.totalSize - pieceIndex * this.metadata.pieceLength
|
|
314
|
+
: this.metadata.pieceLength;
|
|
315
|
+
const isLastBlock = blockIdx === Math.ceil(pieceLen / BLOCK_SIZE) - 1;
|
|
316
|
+
return isLastBlock ? pieceLen - blockIdx * BLOCK_SIZE : BLOCK_SIZE;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private logProgress(): void {
|
|
320
|
+
const downloaded = this.storage.downloadedCount;
|
|
321
|
+
const total = this.metadata.pieceCount;
|
|
322
|
+
const pct = total > 0 ? downloaded / total : 0;
|
|
323
|
+
const dlMbps = (this.speedBytesPerSec / (1024 * 1024)).toFixed(1);
|
|
324
|
+
const ulKbps = (this.uploadBytesPerSec / 1024).toFixed(0);
|
|
325
|
+
const remaining = total - downloaded;
|
|
326
|
+
const etaSecs = this.speedBytesPerSec > 0
|
|
327
|
+
? Math.ceil((remaining * this.metadata.pieceLength) / this.speedBytesPerSec)
|
|
328
|
+
: 0;
|
|
329
|
+
const eta = etaSecs > 0
|
|
330
|
+
? `${Math.floor(etaSecs / 60)}:${String(etaSecs % 60).padStart(2, "0")}`
|
|
331
|
+
: "--:--";
|
|
332
|
+
|
|
333
|
+
const cols = process.stdout.columns ?? 80;
|
|
334
|
+
const statsStr = ` ${downloaded}/${total} (${(pct * 100).toFixed(1)}%) ↓${dlMbps}MB/s ↑${ulKbps}KB/s ETA ${eta}`;
|
|
335
|
+
const barWidth = Math.max(8, cols - statsStr.length - 4);
|
|
336
|
+
const filled = Math.floor(pct * barWidth);
|
|
337
|
+
const bar = "█".repeat(filled) + "░".repeat(barWidth - filled);
|
|
338
|
+
const line = ` [${bar}]${statsStr}`;
|
|
339
|
+
|
|
340
|
+
if (process.stdout.isTTY) {
|
|
341
|
+
process.stdout.write(`\r${line.padEnd(cols - 1)}`);
|
|
342
|
+
this.progressActive = true;
|
|
343
|
+
} else {
|
|
344
|
+
log("progress", `${downloaded} / ${total} ↓${dlMbps}MB/s ↑${ulKbps}KB/s ETA ${eta}`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private consolePrint(line: string): void {
|
|
349
|
+
if (this.progressActive) {
|
|
350
|
+
const cols = process.stdout.columns ?? 80;
|
|
351
|
+
process.stdout.write(`\r${" ".repeat(cols - 1)}\r`); // clear progress line
|
|
352
|
+
}
|
|
353
|
+
console.log(line);
|
|
354
|
+
if (this.progressActive) this.logProgress(); // redraw bar
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private fileLog(message: string): void {
|
|
358
|
+
const time = new Date().toISOString().slice(11, 19);
|
|
359
|
+
appendFileSync(this.logFilePath, `[${time}] ${message}\n`);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Resume
|
|
363
|
+
|
|
364
|
+
private resumePath(): string {
|
|
365
|
+
const hex = Buffer.from(this.metadata.infoHash).toString("hex");
|
|
366
|
+
return join(getDataDir(), "resume", `${hex}.json`);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
private loadResume(): void {
|
|
370
|
+
const path = this.resumePath();
|
|
371
|
+
if (!existsSync(path)) {
|
|
372
|
+
log("resume", "no saved state — starting fresh");
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
try {
|
|
376
|
+
const data = JSON.parse(readFileSync(path, "utf-8")) as {
|
|
377
|
+
infoHash: string;
|
|
378
|
+
downloadPath: string;
|
|
379
|
+
downloadedPieces: number[];
|
|
380
|
+
};
|
|
381
|
+
if (data.downloadPath !== this.downloadPath) {
|
|
382
|
+
log("resume", "download path changed — starting fresh");
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
// Only count pieces that verifyAll() already confirmed are on disk.
|
|
386
|
+
// Do not call markPiece() here — verifyAll() is the authoritative source.
|
|
387
|
+
// Marking stale resume entries would corrupt the download when files were deleted.
|
|
388
|
+
const confirmed = data.downloadedPieces.filter((i) => this.storage.hasPiece(i)).length;
|
|
389
|
+
log("resume", `${confirmed} / ${data.downloadedPieces.length} resume pieces confirmed on disk`);
|
|
390
|
+
this.fileLog(`resume: ${confirmed} / ${data.downloadedPieces.length} pieces on disk`);
|
|
391
|
+
} catch {
|
|
392
|
+
log("resume", "could not read save file — starting fresh");
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
private saveResume(): void {
|
|
397
|
+
const path = this.resumePath();
|
|
398
|
+
const dir = join(getDataDir(), "resume");
|
|
399
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
400
|
+
|
|
401
|
+
const data = {
|
|
402
|
+
infoHash: Buffer.from(this.metadata.infoHash).toString("hex"),
|
|
403
|
+
downloadPath: this.downloadPath,
|
|
404
|
+
downloadedPieces: [...this.storage.getDownloadedPieces()],
|
|
405
|
+
savedAt: Math.floor(Date.now() / 1000),
|
|
406
|
+
};
|
|
407
|
+
writeFileSync(path, JSON.stringify(data), "utf-8");
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function bufEqual(a: Uint8Array, b: Uint8Array): boolean {
|
|
412
|
+
if (a.length !== b.length) return false;
|
|
413
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
414
|
+
return true;
|
|
415
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { SHA1 } from "bun";
|
|
4
|
+
import { decode, encode } from "./parser";
|
|
5
|
+
|
|
6
|
+
const TEXT_DECODER = new TextDecoder();
|
|
7
|
+
|
|
8
|
+
export interface PeerInfo {
|
|
9
|
+
ip: string;
|
|
10
|
+
port: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface TrackerResponse {
|
|
14
|
+
complete: number;
|
|
15
|
+
incomplete: number;
|
|
16
|
+
interval: number;
|
|
17
|
+
peers: PeerInfo[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type TorrentFile = {
|
|
21
|
+
announce: Uint8Array;
|
|
22
|
+
info: {
|
|
23
|
+
length?: number;
|
|
24
|
+
files?: Array<{
|
|
25
|
+
length: number;
|
|
26
|
+
}>;
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function encodeBytes(buf: Uint8Array): string {
|
|
31
|
+
let result = "";
|
|
32
|
+
for (const byte of buf) {
|
|
33
|
+
result += `%${byte.toString(16).padStart(2, "0")}`;
|
|
34
|
+
}
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseCompactPeers(data: Uint8Array): PeerInfo[] {
|
|
39
|
+
const peers: PeerInfo[] = [];
|
|
40
|
+
const peerSize = 6;
|
|
41
|
+
|
|
42
|
+
if (data.length % peerSize !== 0) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`Invalid compact peer data: length ${data.length} is not a multiple of 6`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (let i = 0; i < data.length; i += peerSize) {
|
|
49
|
+
const first = data[i];
|
|
50
|
+
const second = data[i + 1];
|
|
51
|
+
const third = data[i + 2];
|
|
52
|
+
const fourth = data[i + 3];
|
|
53
|
+
const portHigh = data[i + 4];
|
|
54
|
+
const portLow = data[i + 5];
|
|
55
|
+
|
|
56
|
+
if (
|
|
57
|
+
first === undefined ||
|
|
58
|
+
second === undefined ||
|
|
59
|
+
third === undefined ||
|
|
60
|
+
fourth === undefined ||
|
|
61
|
+
portHigh === undefined ||
|
|
62
|
+
portLow === undefined
|
|
63
|
+
) {
|
|
64
|
+
throw new Error("Invalid compact peer data: truncated peer entry");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const ip = `${first}.${second}.${third}.${fourth}`;
|
|
68
|
+
const port = (portHigh << 8) | portLow;
|
|
69
|
+
peers.push({ ip, port });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return peers;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function getPeers(
|
|
76
|
+
filePath: string,
|
|
77
|
+
port = 6881,
|
|
78
|
+
numwant = 50,
|
|
79
|
+
): Promise<TrackerResponse> {
|
|
80
|
+
const fileContent = readFileSync(filePath);
|
|
81
|
+
const decoded = decode(fileContent);
|
|
82
|
+
|
|
83
|
+
if (
|
|
84
|
+
typeof decoded !== "object" ||
|
|
85
|
+
decoded === null ||
|
|
86
|
+
Array.isArray(decoded)
|
|
87
|
+
) {
|
|
88
|
+
throw new Error("Invalid torrent file");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!("announce" in decoded) || !("info" in decoded)) {
|
|
92
|
+
throw new Error("Missing announce or info");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const torrent = decoded as TorrentFile;
|
|
96
|
+
const { announce, info } = torrent;
|
|
97
|
+
|
|
98
|
+
const announceUrl = TEXT_DECODER.decode(announce);
|
|
99
|
+
|
|
100
|
+
if (announceUrl.startsWith("udp://")) {
|
|
101
|
+
throw new Error(`UDP tracker not supported yet: ${announceUrl}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const infoEncoded = encode(info);
|
|
105
|
+
const infoHashBytes = Uint8Array.from(SHA1.hash(infoEncoded) as Uint8Array);
|
|
106
|
+
|
|
107
|
+
let left: number;
|
|
108
|
+
|
|
109
|
+
if (typeof info.length === "number") {
|
|
110
|
+
left = info.length;
|
|
111
|
+
} else if (Array.isArray(info.files)) {
|
|
112
|
+
left = 0;
|
|
113
|
+
|
|
114
|
+
for (const file of info.files) {
|
|
115
|
+
if (typeof file !== "object" || file === null) {
|
|
116
|
+
throw new Error("Invalid file entry");
|
|
117
|
+
}
|
|
118
|
+
if (typeof file.length !== "number") {
|
|
119
|
+
throw new Error("Invalid file length");
|
|
120
|
+
}
|
|
121
|
+
left += file.length;
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
throw new Error("Invalid info: missing length/files");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const peerIdPrefix = "-UT2210-";
|
|
128
|
+
const randomPart = randomBytes(20 - peerIdPrefix.length);
|
|
129
|
+
const peerId = Buffer.concat([Buffer.from(peerIdPrefix), randomPart]);
|
|
130
|
+
|
|
131
|
+
const params = {
|
|
132
|
+
info_hash: encodeBytes(infoHashBytes),
|
|
133
|
+
peer_id: encodeBytes(peerId),
|
|
134
|
+
port: port,
|
|
135
|
+
uploaded: 0,
|
|
136
|
+
downloaded: 0,
|
|
137
|
+
left: left,
|
|
138
|
+
compact: 1,
|
|
139
|
+
event: "started",
|
|
140
|
+
numwant: numwant,
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const queryParts: string[] = [];
|
|
144
|
+
|
|
145
|
+
for (const [key, val] of Object.entries(params)) {
|
|
146
|
+
queryParts.push(`${key}=${val}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const queryString = queryParts.join("&");
|
|
150
|
+
const url = announceUrl.includes("?")
|
|
151
|
+
? `${announceUrl}&${queryString}`
|
|
152
|
+
: `${announceUrl}?${queryString}`;
|
|
153
|
+
|
|
154
|
+
console.log(`Tracker URL: ${url}`);
|
|
155
|
+
|
|
156
|
+
const res = await fetch(url, {
|
|
157
|
+
headers: {
|
|
158
|
+
"User-Agent": "Python-BitTorrent-Client/1.0",
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
if (!res.ok) {
|
|
163
|
+
const text = await res.text();
|
|
164
|
+
throw new Error(`Tracker error: ${res.status} - ${text}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const buffer = new Uint8Array(await res.arrayBuffer());
|
|
168
|
+
const trackerDecoded = decode(buffer);
|
|
169
|
+
|
|
170
|
+
if (
|
|
171
|
+
typeof trackerDecoded !== "object" ||
|
|
172
|
+
trackerDecoded === null ||
|
|
173
|
+
Array.isArray(trackerDecoded)
|
|
174
|
+
) {
|
|
175
|
+
throw new Error("Invalid tracker response");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if ("failure reason" in trackerDecoded) {
|
|
179
|
+
throw new Error(`Tracker failure: ${trackerDecoded["failure reason"]}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!("peers" in trackerDecoded)) {
|
|
183
|
+
throw new Error("No peers in response");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const peersRaw = trackerDecoded.peers;
|
|
187
|
+
if (!(peersRaw instanceof Uint8Array)) {
|
|
188
|
+
throw new Error(
|
|
189
|
+
"Expected compact peer data (Uint8Array), got non-binary response",
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const peers = parseCompactPeers(peersRaw);
|
|
194
|
+
|
|
195
|
+
const interval =
|
|
196
|
+
typeof trackerDecoded.interval === "number"
|
|
197
|
+
? trackerDecoded.interval
|
|
198
|
+
: 1800;
|
|
199
|
+
const complete =
|
|
200
|
+
typeof trackerDecoded.complete === "number" ? trackerDecoded.complete : 0;
|
|
201
|
+
const incomplete =
|
|
202
|
+
typeof trackerDecoded.incomplete === "number"
|
|
203
|
+
? trackerDecoded.incomplete
|
|
204
|
+
: 0;
|
|
205
|
+
|
|
206
|
+
console.log(`Found ${peers.length} peers:`);
|
|
207
|
+
for (const peer of peers) {
|
|
208
|
+
console.log(` ${peer.ip}:${peer.port}`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return { complete, incomplete, interval, peers };
|
|
212
|
+
}
|