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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +156 -0
  3. package/bin/torrent-tui +26 -0
  4. package/package.json +58 -0
  5. package/src/app.ts +157 -0
  6. package/src/config/index.ts +41 -0
  7. package/src/config/settings.ts +15 -0
  8. package/src/constants/index.ts +16 -0
  9. package/src/controllers/app-controller.ts +180 -0
  10. package/src/index.ts +320 -0
  11. package/src/layout/add-torrent-dialog.ts +170 -0
  12. package/src/layout/confirm-dialog.ts +141 -0
  13. package/src/layout/content-window.ts +80 -0
  14. package/src/layout/sidebar.ts +121 -0
  15. package/src/layout/status-bar.ts +79 -0
  16. package/src/layout/toast-manager.ts +109 -0
  17. package/src/layout/toast.ts +257 -0
  18. package/src/layout/torrent-view.ts +250 -0
  19. package/src/store/index.ts +51 -0
  20. package/src/theme/default.ts +22 -0
  21. package/src/theme/index.ts +19 -0
  22. package/src/theme/types.ts +26 -0
  23. package/src/torrent/bridge.ts +301 -0
  24. package/src/torrent/downloader.ts +415 -0
  25. package/src/torrent/get_peers.ts +212 -0
  26. package/src/torrent/metadata.ts +190 -0
  27. package/src/torrent/parser.ts +216 -0
  28. package/src/torrent/peer/connection.ts +278 -0
  29. package/src/torrent/peer/handshake.ts +48 -0
  30. package/src/torrent/peer/listener.ts +52 -0
  31. package/src/torrent/peer/manager.ts +233 -0
  32. package/src/torrent/peer/message-buffer.ts +31 -0
  33. package/src/torrent/peer/peer-id.ts +21 -0
  34. package/src/torrent/peer/protocol.ts +123 -0
  35. package/src/torrent/piece-picker.ts +58 -0
  36. package/src/torrent/session.ts +56 -0
  37. package/src/torrent/storage.ts +197 -0
  38. package/src/torrent/tracker/announce.ts +36 -0
  39. package/src/torrent/tracker/http-tracker.ts +143 -0
  40. package/src/torrent/tracker/udp-tracker.ts +136 -0
  41. package/src/torrent/types.ts +25 -0
  42. package/src/types/layout.ts +6 -0
  43. package/src/utils/env.ts +8 -0
  44. package/src/utils/filter.ts +12 -0
  45. package/src/utils/layout.ts +32 -0
  46. package/src/utils/paths.ts +17 -0
@@ -0,0 +1,48 @@
1
+ const PROTOCOL = "BitTorrent protocol";
2
+ const PROTOCOL_BYTES = new TextEncoder().encode(PROTOCOL);
3
+ const HANDSHAKE_LEN = 68;
4
+
5
+ export function buildHandshake(
6
+ infoHash: Uint8Array,
7
+ peerId: Uint8Array,
8
+ ): Uint8Array {
9
+ const buf = new Uint8Array(HANDSHAKE_LEN);
10
+ buf[0] = 19; // pstrlen
11
+ buf.set(PROTOCOL_BYTES, 1); // pstr (19 bytes)
12
+ // bytes 20–27: reserved (zeros, already set)
13
+ buf.set(infoHash, 28); // info_hash
14
+ buf.set(peerId, 48); // peer_id
15
+ return buf;
16
+ }
17
+
18
+ export function parseHandshake(
19
+ data: Uint8Array,
20
+ expectedInfoHash: Uint8Array,
21
+ ): { peerId: string; reserved: Uint8Array } {
22
+ if (data.length < HANDSHAKE_LEN) {
23
+ throw new Error(`Handshake too short: ${data.length} bytes`);
24
+ }
25
+ if (data[0] !== 19) {
26
+ throw new Error(`Invalid pstrlen: ${data[0]}`);
27
+ }
28
+ const pstr = new TextDecoder().decode(data.slice(1, 20));
29
+ if (pstr !== PROTOCOL) {
30
+ throw new Error(`Unknown protocol: ${pstr}`);
31
+ }
32
+ const reserved = data.slice(20, 28);
33
+ const infoHash = data.slice(28, 48);
34
+ const peerId = data.slice(48, 68);
35
+
36
+ for (let i = 0; i < 20; i++) {
37
+ if (infoHash[i] !== expectedInfoHash[i]) {
38
+ throw new Error("info_hash mismatch");
39
+ }
40
+ }
41
+
42
+ return {
43
+ peerId: Buffer.from(peerId).toString("latin1"),
44
+ reserved,
45
+ };
46
+ }
47
+
48
+ export { HANDSHAKE_LEN };
@@ -0,0 +1,52 @@
1
+ import { createServer } from "node:net";
2
+ import type { Server, Socket } from "node:net";
3
+ import { log } from "../metadata.ts";
4
+
5
+ const PORT_RANGE = [6881, 6882, 6883, 6884, 6885, 6886, 6887, 6888, 6889];
6
+
7
+ export class PeerListener {
8
+ port: number = 0;
9
+ private server: Server | null = null;
10
+ private connectionCb: ((socket: Socket) => void) | null = null;
11
+
12
+ async listen(): Promise<void> {
13
+ for (const p of PORT_RANGE) {
14
+ const success = await this.tryListen(p);
15
+ if (success) {
16
+ this.port = p;
17
+ log("listener", `port ${p}`);
18
+ return;
19
+ }
20
+ }
21
+ throw new Error("No available port in range 6881–6889");
22
+ }
23
+
24
+ private tryListen(port: number): Promise<boolean> {
25
+ return new Promise((resolve) => {
26
+ const server = createServer((socket) => {
27
+ const remote = `${socket.remoteAddress}:${socket.remotePort}`;
28
+ log("inbound", remote);
29
+ this.connectionCb?.(socket);
30
+ });
31
+
32
+ server.once("listening", () => {
33
+ this.server = server;
34
+ resolve(true);
35
+ });
36
+
37
+ server.once("error", () => {
38
+ resolve(false);
39
+ });
40
+
41
+ server.listen(port);
42
+ });
43
+ }
44
+
45
+ onConnection(cb: (socket: Socket) => void): void {
46
+ this.connectionCb = cb;
47
+ }
48
+
49
+ close(): void {
50
+ this.server?.close();
51
+ }
52
+ }
@@ -0,0 +1,233 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { log } from "../metadata.ts";
3
+ import { PeerConnection } from "./connection.ts";
4
+ import { PeerListener } from "./listener.ts";
5
+ import { buildHandshake, parseHandshake, HANDSHAKE_LEN } from "./handshake.ts";
6
+ import { getPeerId } from "./peer-id.ts";
7
+ import type { TorrentMetadata } from "../metadata.ts";
8
+ import type { PeerInfo } from "../types.ts";
9
+
10
+ export class PeerManager extends EventEmitter {
11
+ readonly connections: Map<string, PeerConnection> = new Map();
12
+ private listener: PeerListener;
13
+ private maxConnections: number;
14
+ private infoHash: Uint8Array;
15
+ private pieceCount: number;
16
+ private chokeTimer: ReturnType<typeof setInterval> | null = null;
17
+ private optimisticTimer: ReturnType<typeof setInterval> | null = null;
18
+ private optimisticKey: string | null = null;
19
+ private unchokedKeys = new Set<string>();
20
+
21
+ constructor(metadata: TorrentMetadata, maxConnections = 50) {
22
+ super();
23
+ this.infoHash = metadata.infoHash;
24
+ this.pieceCount = metadata.pieceCount;
25
+ this.maxConnections = maxConnections;
26
+ this.listener = new PeerListener();
27
+ }
28
+
29
+ async start(): Promise<void> {
30
+ await this.listener.listen();
31
+ this.listener.onConnection((socket) => this.handleInbound(socket));
32
+ }
33
+
34
+ async connect(peers: PeerInfo[]): Promise<void> {
35
+ const unique = this.dedup(peers);
36
+ const toConnect = unique.slice(
37
+ 0,
38
+ Math.max(0, this.maxConnections - this.connections.size),
39
+ );
40
+
41
+ await Promise.allSettled(
42
+ toConnect.map((p) => this.connectOne(p.ip, p.port)),
43
+ );
44
+ }
45
+
46
+ private async connectOne(ip: string, port: number): Promise<void> {
47
+ const key = `${ip}:${port}`;
48
+ if (this.connections.has(key)) return;
49
+
50
+ const conn = new PeerConnection(ip, port, this.infoHash);
51
+
52
+ conn.on("bitfield", () => {
53
+ if (conn.countPiecesPublic() > 0 && !conn.amInterested) conn.sendInterested();
54
+ });
55
+
56
+ conn.on("disconnect", () => {
57
+ this.connections.delete(key);
58
+ });
59
+
60
+ try {
61
+ await conn.connect();
62
+ this.connections.set(key, conn);
63
+ this.emit("peerAdded", conn);
64
+ } catch {
65
+ // timeout/error already logged by connection.ts
66
+ }
67
+ }
68
+
69
+ private handleInbound(socket: import("node:net").Socket): void {
70
+ let buf = new Uint8Array(0);
71
+
72
+ const onData = (chunk: Buffer) => {
73
+ const merged = new Uint8Array(buf.length + chunk.length);
74
+ merged.set(buf);
75
+ merged.set(new Uint8Array(chunk), buf.length);
76
+ buf = merged;
77
+
78
+ if (buf.length < HANDSHAKE_LEN) return;
79
+
80
+ socket.removeListener("data", onData);
81
+
82
+ try {
83
+ const result = parseHandshake(buf, this.infoHash);
84
+ socket.write(buildHandshake(this.infoHash, getPeerId()));
85
+
86
+ const ip = socket.remoteAddress ?? "unknown";
87
+ const port = socket.remotePort ?? 0;
88
+ const key = `${ip}:${port}`;
89
+ log("peer", `${key.padEnd(50)} ok ${result.peerId.slice(0, 8)} (inbound)`);
90
+
91
+ // Wrap the existing socket in a PeerConnection
92
+ const conn = new PeerConnection(ip, port, this.infoHash);
93
+ // Hand off the already-connected socket by replaying the remainder
94
+ (conn as unknown as { socket: typeof socket }).socket = socket;
95
+ (conn as unknown as { handshakeDone: boolean }).handshakeDone = true;
96
+ (conn as unknown as { peerId: string }).peerId = result.peerId;
97
+
98
+ const remainder = buf.slice(HANDSHAKE_LEN);
99
+ if (remainder.length > 0) {
100
+ socket.emit("data", Buffer.from(remainder));
101
+ }
102
+
103
+ socket.on("data", (c: Buffer) => socket.emit("data", c));
104
+ this.connections.set(key, conn);
105
+ } catch (err) {
106
+ const msg = err instanceof Error ? err.message : String(err);
107
+ log("handshake", `FAIL (inbound) ${msg}`);
108
+ socket.destroy();
109
+ }
110
+ };
111
+
112
+ socket.on("data", onData);
113
+ }
114
+
115
+ getUnchoked(): PeerConnection[] {
116
+ return [...this.connections.values()].filter((c) => !c.amChoked);
117
+ }
118
+
119
+ startChoking(): void {
120
+ // BEP 3: recalculate every 10s, optimistic rotates every 30s
121
+ this.chokeTimer = setInterval(() => this.recalculateChokes(), 10_000);
122
+ this.optimisticTimer = setInterval(() => this.rotateOptimisticUnchoke(), 30_000);
123
+ // Run immediately so peers are unchoked on download start
124
+ this.recalculateChokes();
125
+ }
126
+
127
+ stopChoking(): void {
128
+ if (this.chokeTimer) clearInterval(this.chokeTimer);
129
+ if (this.optimisticTimer) clearInterval(this.optimisticTimer);
130
+ this.chokeTimer = null;
131
+ this.optimisticTimer = null;
132
+ }
133
+
134
+ private recalculateChokes(): void {
135
+ const peers = [...this.connections.values()];
136
+
137
+ // Snapshot rates and reset interval counters
138
+ for (const p of peers) {
139
+ p.downloadBytesPerSec = p.downloadedThisInterval;
140
+ p.uploadBytesPerSec = p.uploadedThisInterval;
141
+ p.downloadedThisInterval = 0;
142
+ p.uploadedThisInterval = 0;
143
+ }
144
+
145
+ // BEP 3: unchoke top 4 by download rate from them (reciprocation)
146
+ const interested = peers.filter((p) => p.peerInterested);
147
+ const sorted = [...interested].sort((a, b) => b.downloadBytesPerSec - a.downloadBytesPerSec);
148
+ const toUnchoke = new Set<string>();
149
+
150
+ for (let i = 0; i < Math.min(4, sorted.length); i++) {
151
+ const p = sorted[i];
152
+ if (p) toUnchoke.add(`${p.address}:${p.port}`);
153
+ }
154
+ if (this.optimisticKey) toUnchoke.add(this.optimisticKey);
155
+
156
+ // Apply choke/unchoke changes
157
+ for (const p of peers) {
158
+ const key = `${p.address}:${p.port}`;
159
+ const shouldUnchoke = toUnchoke.has(key);
160
+ const wasUnchoked = this.unchokedKeys.has(key);
161
+ if (shouldUnchoke && !wasUnchoked) p.sendUnchoke();
162
+ else if (!shouldUnchoke && wasUnchoked) p.sendChoke();
163
+ }
164
+ this.unchokedKeys = toUnchoke;
165
+
166
+ if (toUnchoke.size > 0) {
167
+ const labels = [...toUnchoke]
168
+ .slice(0, 4)
169
+ .map((k) => {
170
+ const c = this.connections.get(k);
171
+ const mbps = ((c?.downloadBytesPerSec ?? 0) / (1024 * 1024)).toFixed(1);
172
+ return `${c?.peerId.slice(0, 8) ?? k}(${mbps})`;
173
+ })
174
+ .join(" ");
175
+ const optLabel = this.optimisticKey
176
+ ? ` opt: ${this.connections.get(this.optimisticKey)?.peerId.slice(0, 8) ?? this.optimisticKey}`
177
+ : "";
178
+ log("unchoke", `${labels}${optLabel}`);
179
+ }
180
+ }
181
+
182
+ private rotateOptimisticUnchoke(): void {
183
+ const choked = [...this.connections.values()].filter(
184
+ (p) => !this.unchokedKeys.has(`${p.address}:${p.port}`) && p.peerInterested,
185
+ );
186
+ if (choked.length === 0) return;
187
+
188
+ // BEP 3: new connections are 3× as likely — simplified: just pick random
189
+ const pick = choked[Math.floor(Math.random() * choked.length)];
190
+ if (!pick) return;
191
+
192
+ const key = `${pick.address}:${pick.port}`;
193
+ if (this.optimisticKey && this.optimisticKey !== key) {
194
+ // Choke the previous optimistic peer if not in regular slots
195
+ if (!this.unchokedKeys.has(this.optimisticKey)) {
196
+ this.connections.get(this.optimisticKey)?.sendChoke();
197
+ }
198
+ }
199
+ this.optimisticKey = key;
200
+ pick.sendUnchoke();
201
+ this.unchokedKeys.add(key);
202
+ log("unchoke-opt", `${pick.peerId.slice(0, 8)} (optimistic slot)`);
203
+ }
204
+
205
+ private dedup(peers: PeerInfo[]): PeerInfo[] {
206
+ const seen = new Set<string>();
207
+ return peers.filter((p) => {
208
+ const k = `${p.ip}:${p.port}`;
209
+ if (seen.has(k)) return false;
210
+ seen.add(k);
211
+ return true;
212
+ });
213
+ }
214
+
215
+ private countBits(buf: Uint8Array): number {
216
+ let n = 0;
217
+ for (const byte of buf) {
218
+ let b = byte;
219
+ while (b) { n += b & 1; b >>>= 1; }
220
+ }
221
+ return n;
222
+ }
223
+
224
+ close(): void {
225
+ this.stopChoking();
226
+ for (const conn of this.connections.values()) {
227
+ conn.suppressDisconnect = true;
228
+ conn.destroy();
229
+ }
230
+ this.connections.clear();
231
+ this.listener.close();
232
+ }
233
+ }
@@ -0,0 +1,31 @@
1
+ export class MessageBuffer {
2
+ private buf = new Uint8Array(0);
3
+
4
+ push(chunk: Uint8Array): Uint8Array[] {
5
+ // Append chunk to internal buffer
6
+ const merged = new Uint8Array(this.buf.length + chunk.length);
7
+ merged.set(this.buf);
8
+ merged.set(chunk, this.buf.length);
9
+ this.buf = merged;
10
+
11
+ const messages: Uint8Array[] = [];
12
+
13
+ while (this.buf.length >= 4) {
14
+ const length =
15
+ ((this.buf[0] ?? 0) << 24) |
16
+ ((this.buf[1] ?? 0) << 16) |
17
+ ((this.buf[2] ?? 0) << 8) |
18
+ (this.buf[3] ?? 0);
19
+
20
+ // length=0 is a keepalive — complete message is just the 4 length bytes
21
+ const totalLen = 4 + length;
22
+
23
+ if (this.buf.length < totalLen) break;
24
+
25
+ messages.push(this.buf.slice(0, totalLen));
26
+ this.buf = this.buf.slice(totalLen);
27
+ }
28
+
29
+ return messages;
30
+ }
31
+ }
@@ -0,0 +1,21 @@
1
+ import { randomBytes } from "node:crypto";
2
+
3
+ const PEER_ID_PREFIX = "-TT0001-";
4
+
5
+ let _peerId: Uint8Array | null = null;
6
+
7
+ export function getPeerId(): Uint8Array {
8
+ if (!_peerId) {
9
+ const prefix = Buffer.from(PEER_ID_PREFIX);
10
+ const random = randomBytes(20 - prefix.length);
11
+ _peerId = new Uint8Array(Buffer.concat([prefix, random]));
12
+ }
13
+ return _peerId;
14
+ }
15
+
16
+ export function peerIdToString(id: Uint8Array): string {
17
+ // Show ASCII prefix + hex for the random portion
18
+ const prefix = Buffer.from(id.slice(0, 8)).toString("ascii");
19
+ const hex = Buffer.from(id.slice(8)).toString("hex").slice(0, 12);
20
+ return `${prefix}${hex}...`;
21
+ }
@@ -0,0 +1,123 @@
1
+ export const MSG = {
2
+ KEEPALIVE: -1,
3
+ CHOKE: 0,
4
+ UNCHOKE: 1,
5
+ INTERESTED: 2,
6
+ NOT_INTERESTED: 3,
7
+ HAVE: 4,
8
+ BITFIELD: 5,
9
+ REQUEST: 6,
10
+ PIECE: 7,
11
+ CANCEL: 8,
12
+ } as const;
13
+
14
+ export type MessageType = (typeof MSG)[keyof typeof MSG];
15
+
16
+ export interface PeerMessage {
17
+ type: MessageType;
18
+ payload?: Uint8Array;
19
+ }
20
+
21
+ export function encode(msg: PeerMessage): Uint8Array {
22
+ if (msg.type === MSG.KEEPALIVE) {
23
+ return new Uint8Array(4); // 4 zero bytes
24
+ }
25
+ const payload = msg.payload ?? new Uint8Array(0);
26
+ const buf = new Uint8Array(4 + 1 + payload.length);
27
+ const len = 1 + payload.length;
28
+ buf[0] = (len >>> 24) & 0xff;
29
+ buf[1] = (len >>> 16) & 0xff;
30
+ buf[2] = (len >>> 8) & 0xff;
31
+ buf[3] = len & 0xff;
32
+ buf[4] = msg.type as number;
33
+ buf.set(payload, 5);
34
+ return buf;
35
+ }
36
+
37
+ export function decode(raw: Uint8Array): PeerMessage {
38
+ const length =
39
+ ((raw[0] ?? 0) << 24) |
40
+ ((raw[1] ?? 0) << 16) |
41
+ ((raw[2] ?? 0) << 8) |
42
+ (raw[3] ?? 0);
43
+
44
+ if (length === 0) return { type: MSG.KEEPALIVE };
45
+
46
+ const id = raw[4] as MessageType;
47
+ const payload = raw.length > 5 ? raw.slice(5) : undefined;
48
+ return { type: id, payload };
49
+ }
50
+
51
+ // Helpers to build specific message payloads
52
+
53
+ export function encodeHave(pieceIndex: number): Uint8Array {
54
+ const payload = new Uint8Array(4);
55
+ payload[0] = (pieceIndex >>> 24) & 0xff;
56
+ payload[1] = (pieceIndex >>> 16) & 0xff;
57
+ payload[2] = (pieceIndex >>> 8) & 0xff;
58
+ payload[3] = pieceIndex & 0xff;
59
+ return encode({ type: MSG.HAVE, payload });
60
+ }
61
+
62
+ export function encodeRequest(
63
+ index: number,
64
+ begin: number,
65
+ length: number,
66
+ ): Uint8Array {
67
+ const payload = new Uint8Array(12);
68
+ const view = new DataView(payload.buffer);
69
+ view.setUint32(0, index);
70
+ view.setUint32(4, begin);
71
+ view.setUint32(8, length);
72
+ return encode({ type: MSG.REQUEST, payload });
73
+ }
74
+
75
+ export function encodeCancel(
76
+ index: number,
77
+ begin: number,
78
+ length: number,
79
+ ): Uint8Array {
80
+ const payload = new Uint8Array(12);
81
+ const view = new DataView(payload.buffer);
82
+ view.setUint32(0, index);
83
+ view.setUint32(4, begin);
84
+ view.setUint32(8, length);
85
+ return encode({ type: MSG.CANCEL, payload });
86
+ }
87
+
88
+ export function decodeHave(payload: Uint8Array): number {
89
+ return new DataView(payload.buffer, payload.byteOffset).getUint32(0);
90
+ }
91
+
92
+ export function decodeRequest(payload: Uint8Array): {
93
+ index: number;
94
+ begin: number;
95
+ length: number;
96
+ } {
97
+ const view = new DataView(payload.buffer, payload.byteOffset);
98
+ return {
99
+ index: view.getUint32(0),
100
+ begin: view.getUint32(4),
101
+ length: view.getUint32(8),
102
+ };
103
+ }
104
+
105
+ export function decodePiece(payload: Uint8Array): {
106
+ index: number;
107
+ begin: number;
108
+ block: Uint8Array;
109
+ } {
110
+ const view = new DataView(payload.buffer, payload.byteOffset);
111
+ return {
112
+ index: view.getUint32(0),
113
+ begin: view.getUint32(4),
114
+ block: payload.slice(8),
115
+ };
116
+ }
117
+
118
+ export function msgName(type: MessageType): string {
119
+ for (const [k, v] of Object.entries(MSG)) {
120
+ if (v === type) return k;
121
+ }
122
+ return `UNKNOWN(${type})`;
123
+ }
@@ -0,0 +1,58 @@
1
+ import type { PeerConnection } from "./peer/connection.ts";
2
+
3
+ export class PiecePicker {
4
+ private availability = new Map<number, number>(); // pieceIndex → peer count
5
+
6
+ constructor(
7
+ private pieceCount: number,
8
+ private hasPiece: (i: number) => boolean,
9
+ private isInProgress: (i: number) => boolean,
10
+ ) {}
11
+
12
+ addPeer(conn: PeerConnection): void {
13
+ for (let i = 0; i < this.pieceCount; i++) {
14
+ if (conn.hasPiece(i)) {
15
+ this.availability.set(i, (this.availability.get(i) ?? 0) + 1);
16
+ }
17
+ }
18
+ }
19
+
20
+ removePeer(conn: PeerConnection): void {
21
+ for (let i = 0; i < this.pieceCount; i++) {
22
+ if (conn.hasPiece(i)) {
23
+ const n = (this.availability.get(i) ?? 1) - 1;
24
+ if (n <= 0) this.availability.delete(i);
25
+ else this.availability.set(i, n);
26
+ }
27
+ }
28
+ }
29
+
30
+ onHave(pieceIndex: number): void {
31
+ this.availability.set(pieceIndex, (this.availability.get(pieceIndex) ?? 0) + 1);
32
+ }
33
+
34
+ // Returns the lowest-availability unstarted piece this peer has that we need.
35
+ // In-progress pieces are handled by Tier 1 in nextBlock() — skip them here.
36
+ pick(conn: PeerConnection): number | null {
37
+ let best = -1;
38
+ let bestAvail = Infinity;
39
+
40
+ for (let i = 0; i < this.pieceCount; i++) {
41
+ if (this.hasPiece(i)) continue;
42
+ if (this.isInProgress(i)) continue; // Tier 1 handles these
43
+ if (!conn.hasPiece(i)) continue;
44
+
45
+ const avail = this.availability.get(i) ?? 1;
46
+ if (avail < bestAvail) {
47
+ bestAvail = avail;
48
+ best = i;
49
+ }
50
+ }
51
+
52
+ return best === -1 ? null : best;
53
+ }
54
+
55
+ availabilityOf(pieceIndex: number): number {
56
+ return this.availability.get(pieceIndex) ?? 0;
57
+ }
58
+ }
@@ -0,0 +1,56 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { TorrentMetadata, log } from "./metadata.ts";
3
+ import { StorageManager } from "./storage.ts";
4
+ import { Downloader } from "./downloader.ts";
5
+ import type { TorrentStatus } from "./types.ts";
6
+ import type { PeerManager } from "./peer/manager.ts";
7
+
8
+ export class TorrentSession extends EventEmitter {
9
+ readonly metadata: TorrentMetadata;
10
+ readonly storage: StorageManager;
11
+ readonly downloadPath: string;
12
+ status: TorrentStatus = "created";
13
+
14
+ constructor(metadata: TorrentMetadata, downloadPath: string) {
15
+ super();
16
+ this.metadata = metadata;
17
+ this.downloadPath = downloadPath;
18
+ this.storage = new StorageManager(metadata, downloadPath);
19
+ }
20
+
21
+ private transition(next: TorrentStatus): void {
22
+ const prev = this.status;
23
+ this.status = next;
24
+ this.emit("status", next, prev);
25
+ }
26
+
27
+ async start(): Promise<void> {
28
+ this.transition("verifying");
29
+ await this.storage.setup();
30
+ await this.storage.verifyAll();
31
+ this.transition("ready");
32
+ }
33
+
34
+ download(manager: PeerManager): Downloader {
35
+ this.transition("downloading");
36
+ const downloader = new Downloader(
37
+ this.metadata,
38
+ this.storage,
39
+ manager,
40
+ this.downloadPath,
41
+ );
42
+
43
+ downloader.on("piece:verified", (i: number) => this.emit("piece:verified", i));
44
+ downloader.on("piece:failed", (i: number, peer: string) => this.emit("piece:failed", i, peer));
45
+ downloader.on("progress", (dl: number, total: number, speed: number) =>
46
+ this.emit("progress", dl, total, speed),
47
+ );
48
+ downloader.on("complete", () => {
49
+ this.transition("seeding");
50
+ this.emit("complete");
51
+ });
52
+
53
+ downloader.start();
54
+ return downloader;
55
+ }
56
+ }