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,197 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ openSync,
5
+ closeSync,
6
+ ftruncateSync,
7
+ readSync,
8
+ writeSync,
9
+ } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+ import { SHA1 } from "bun";
12
+ import { log } from "./metadata.ts";
13
+ import type { TorrentMetadata } from "./metadata.ts";
14
+
15
+ function formatSize(bytes: number): string {
16
+ const gb = bytes / (1024 * 1024 * 1024);
17
+ if (gb >= 1) return `${gb.toFixed(1)} GB`;
18
+ const mb = bytes / (1024 * 1024);
19
+ if (mb >= 1) return `${mb.toFixed(1)} MB`;
20
+ return `${(bytes / 1024).toFixed(1)} KB`;
21
+ }
22
+
23
+ export class StorageManager {
24
+ private readonly downloadPath: string;
25
+ private readonly downloadedPieces = new Set<number>();
26
+
27
+ constructor(
28
+ private readonly metadata: TorrentMetadata,
29
+ basePath: string,
30
+ ) {
31
+ this.downloadPath = basePath;
32
+ }
33
+
34
+ async setup(): Promise<void> {
35
+ for (const file of this.metadata.files) {
36
+ const fullPath = join(this.downloadPath, file.path);
37
+ const dir = dirname(fullPath);
38
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
39
+
40
+ if (!existsSync(fullPath)) {
41
+ // Sparse file: tells the OS the file size with no RAM allocation.
42
+ // Unwritten regions read as zeros.
43
+ const fd = openSync(fullPath, "w");
44
+ ftruncateSync(fd, file.length);
45
+ closeSync(fd);
46
+ log("storage", `created ${fullPath} (${formatSize(file.length)})`);
47
+ } else {
48
+ log("storage", `exists ${fullPath} (${formatSize(file.length)})`);
49
+ }
50
+ }
51
+ }
52
+
53
+ readPieceSync(pieceIndex: number): Buffer {
54
+ const ranges = this.metadata.pieceToFileRanges(pieceIndex);
55
+ const isLastPiece = pieceIndex === this.metadata.pieceCount - 1;
56
+ const pieceLen = isLastPiece
57
+ ? this.metadata.totalSize - pieceIndex * this.metadata.pieceLength
58
+ : this.metadata.pieceLength;
59
+
60
+ const result = Buffer.allocUnsafe(pieceLen);
61
+ let written = 0;
62
+
63
+ for (const { file, fileOffset, length } of ranges) {
64
+ const fullPath = join(this.downloadPath, file.path);
65
+ const fd = openSync(fullPath, "r");
66
+ readSync(fd, result, written, length, fileOffset);
67
+ closeSync(fd);
68
+ written += length;
69
+ }
70
+
71
+ return result;
72
+ }
73
+
74
+ async readPiece(pieceIndex: number): Promise<Uint8Array> {
75
+ return this.readPieceSync(pieceIndex);
76
+ }
77
+
78
+ writePieceSync(pieceIndex: number, data: Uint8Array): void {
79
+ const ranges = this.metadata.pieceToFileRanges(pieceIndex);
80
+ let offset = 0;
81
+
82
+ for (const { file, fileOffset, length } of ranges) {
83
+ const fullPath = join(this.downloadPath, file.path);
84
+ const fd = openSync(fullPath, "r+");
85
+ writeSync(fd, data, offset, length, fileOffset);
86
+ closeSync(fd);
87
+ offset += length;
88
+ }
89
+
90
+ this.downloadedPieces.add(pieceIndex);
91
+ }
92
+
93
+ async writePiece(pieceIndex: number, data: Uint8Array): Promise<void> {
94
+ this.writePieceSync(pieceIndex, data);
95
+ }
96
+
97
+ async verifyPiece(pieceIndex: number): Promise<boolean> {
98
+ const expected = this.metadata.pieceHashes[pieceIndex];
99
+ if (!expected) return false;
100
+
101
+ const data = this.readPieceSync(pieceIndex);
102
+ if (isAllZero(data)) return false;
103
+
104
+ const actual = new SHA1().update(data).digest() as unknown as Uint8Array;
105
+ return bufEqual(actual, expected);
106
+ }
107
+
108
+ // Opens each file once and reads through it sequentially — no per-piece open/close.
109
+ async verifyAll(): Promise<{ valid: number; missing: number; corrupt: number }> {
110
+ let valid = 0;
111
+ let missing = 0;
112
+ let corrupt = 0;
113
+
114
+ const pieceBuf = Buffer.allocUnsafe(this.metadata.pieceLength);
115
+
116
+ for (let i = 0; i < this.metadata.pieceCount; i++) {
117
+ const isLastPiece = i === this.metadata.pieceCount - 1;
118
+ const pieceLen = isLastPiece
119
+ ? this.metadata.totalSize - i * this.metadata.pieceLength
120
+ : this.metadata.pieceLength;
121
+
122
+ const data = pieceBuf.subarray(0, pieceLen);
123
+ let written = 0;
124
+
125
+ for (const { file, fileOffset, length } of this.metadata.pieceToFileRanges(i)) {
126
+ const fullPath = join(this.downloadPath, file.path);
127
+ const fd = openSync(fullPath, "r");
128
+ readSync(fd, data, written, length, fileOffset);
129
+ closeSync(fd);
130
+ written += length;
131
+ }
132
+
133
+ if (isAllZero(data)) {
134
+ missing++;
135
+ continue;
136
+ }
137
+
138
+ const expected = this.metadata.pieceHashes[i];
139
+ if (!expected) { corrupt++; continue; }
140
+
141
+ const actual = new SHA1().update(data).digest() as unknown as Uint8Array;
142
+ if (bufEqual(actual, expected)) {
143
+ valid++;
144
+ this.downloadedPieces.add(i);
145
+ } else {
146
+ corrupt++;
147
+ }
148
+ }
149
+
150
+ log("verify", `${this.metadata.pieceCount} pieces checked ${valid} valid ${missing} missing ${corrupt} corrupt`);
151
+ return { valid, missing, corrupt };
152
+ }
153
+
154
+ markPiece(index: number): void {
155
+ this.downloadedPieces.add(index);
156
+ }
157
+
158
+ hasPiece(pieceIndex: number): boolean {
159
+ return this.downloadedPieces.has(pieceIndex);
160
+ }
161
+
162
+ get downloadedCount(): number {
163
+ return this.downloadedPieces.size;
164
+ }
165
+
166
+ getDownloadedPieces(): ReadonlySet<number> {
167
+ return this.downloadedPieces;
168
+ }
169
+
170
+ getBitfield(): Uint8Array {
171
+ const byteCount = Math.ceil(this.metadata.pieceCount / 8);
172
+ const bitfield = new Uint8Array(byteCount);
173
+ for (const i of this.downloadedPieces) {
174
+ const byte = Math.floor(i / 8);
175
+ const bit = 7 - (i % 8);
176
+ if (byte < byteCount) {
177
+ bitfield[byte] = (bitfield[byte] ?? 0) | (1 << bit);
178
+ }
179
+ }
180
+ return bitfield;
181
+ }
182
+ }
183
+
184
+ function isAllZero(buf: Uint8Array): boolean {
185
+ for (let i = 0; i < buf.length; i++) {
186
+ if (buf[i] !== 0) return false;
187
+ }
188
+ return true;
189
+ }
190
+
191
+ function bufEqual(a: Uint8Array, b: Uint8Array): boolean {
192
+ if (a.length !== b.length) return false;
193
+ for (let i = 0; i < a.length; i++) {
194
+ if (a[i] !== b[i]) return false;
195
+ }
196
+ return true;
197
+ }
@@ -0,0 +1,36 @@
1
+ import { log } from "../metadata.ts";
2
+ import { announceHTTP } from "./http-tracker.ts";
3
+ import { announceUDP } from "./udp-tracker.ts";
4
+ import type { TorrentMetadata } from "../metadata.ts";
5
+ import type { TrackerResponse } from "../types.ts";
6
+
7
+ export async function announce(
8
+ metadata: TorrentMetadata,
9
+ port = 6881,
10
+ numwant = 50,
11
+ ): Promise<TrackerResponse> {
12
+ const flat = metadata.announceList.flat();
13
+ const hasHTTP = flat.some((u) => u.startsWith("http"));
14
+ const udpUrls = [...new Set(flat.filter((u) => u.startsWith("udp://")))];
15
+
16
+ const [httpPeers, udpResults] = await Promise.all([
17
+ hasHTTP ? announceHTTP(metadata, port, numwant) : Promise.resolve([]),
18
+ Promise.allSettled(udpUrls.map((u) => announceUDP(u, metadata, port))),
19
+ ]);
20
+
21
+ const seen = new Set<string>(httpPeers.map((p) => `${p.ip}:${p.port}`));
22
+ const allPeers = [...httpPeers];
23
+
24
+ for (const r of udpResults) {
25
+ if (r?.status === "fulfilled") {
26
+ for (const p of r.value) {
27
+ const k = `${p.ip}:${p.port}`;
28
+ if (!seen.has(k)) { seen.add(k); allPeers.push(p); }
29
+ }
30
+ }
31
+ }
32
+
33
+ log("tracker", `${allPeers.length} unique peers`);
34
+
35
+ return { complete: 0, incomplete: 0, interval: 1800, peers: allPeers };
36
+ }
@@ -0,0 +1,143 @@
1
+ import { decode } from "../parser.ts";
2
+ import { log } from "../metadata.ts";
3
+ import { getPeerId } from "../peer/peer-id.ts";
4
+ import type { TorrentMetadata } from "../metadata.ts";
5
+ import type { PeerInfo, TrackerResponse } from "../types.ts";
6
+
7
+ const TEXT_DECODER = new TextDecoder();
8
+
9
+ // RFC 3986: unreserved characters must NOT be percent-encoded.
10
+ // Encoding them anyway causes tracker hash mismatches (Ubuntu's tracker enforces this).
11
+ function encodeBytes(buf: Uint8Array): string {
12
+ let result = "";
13
+ for (const byte of buf) {
14
+ if (
15
+ (byte >= 0x30 && byte <= 0x39) || // 0-9
16
+ (byte >= 0x41 && byte <= 0x5a) || // A-Z
17
+ (byte >= 0x61 && byte <= 0x7a) || // a-z
18
+ byte === 0x2d || // -
19
+ byte === 0x5f || // _
20
+ byte === 0x2e || // .
21
+ byte === 0x7e // ~
22
+ ) {
23
+ result += String.fromCharCode(byte);
24
+ } else {
25
+ result += `%${byte.toString(16).toUpperCase().padStart(2, "0")}`;
26
+ }
27
+ }
28
+ return result;
29
+ }
30
+
31
+ function parseDictPeers(list: import("../parser.ts").BencodeValue[]): PeerInfo[] {
32
+ const peers: PeerInfo[] = [];
33
+ for (const entry of list) {
34
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry) || entry instanceof Uint8Array) continue;
35
+ const ipRaw = entry["ip"];
36
+ const port = entry["port"];
37
+ if (typeof port !== "number") continue;
38
+ const ip = ipRaw instanceof Uint8Array ? TEXT_DECODER.decode(ipRaw) : typeof ipRaw === "string" ? ipRaw : null;
39
+ if (ip) peers.push({ ip, port });
40
+ }
41
+ return peers;
42
+ }
43
+
44
+ function parseCompactPeers(data: Uint8Array): PeerInfo[] {
45
+ const peers: PeerInfo[] = [];
46
+ if (data.length % 6 !== 0) return peers;
47
+ for (let i = 0; i < data.length; i += 6) {
48
+ const ip = `${data[i]}.${data[i + 1]}.${data[i + 2]}.${data[i + 3]}`;
49
+ const port = ((data[i + 4] ?? 0) << 8) | (data[i + 5] ?? 0);
50
+ peers.push({ ip, port });
51
+ }
52
+ return peers;
53
+ }
54
+
55
+ async function announceToTracker(
56
+ url: string,
57
+ metadata: TorrentMetadata,
58
+ peerId: Uint8Array,
59
+ port: number,
60
+ numwant: number,
61
+ ): Promise<PeerInfo[]> {
62
+ const params = [
63
+ `info_hash=${encodeBytes(metadata.infoHash)}`,
64
+ `peer_id=${encodeBytes(peerId)}`,
65
+ `port=${port}`,
66
+ `uploaded=0`,
67
+ `downloaded=0`,
68
+ `left=${metadata.totalSize}`,
69
+ `compact=1`,
70
+ `event=started`,
71
+ `numwant=${numwant}`,
72
+ ].join("&");
73
+
74
+ const fullUrl = url.includes("?") ? `${url}&${params}` : `${url}?${params}`;
75
+
76
+ // AbortSignal.timeout doesn't abort TCP-level hangs in Bun — use Promise.race
77
+ const res = await Promise.race([
78
+ fetch(fullUrl, { headers: { "User-Agent": "torrent-tui/0.1" } }),
79
+ new Promise<never>((_, rej) => setTimeout(() => rej(new Error("The operation timed out.")), 10_000)),
80
+ ]);
81
+
82
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
83
+
84
+ const buffer = new Uint8Array(await res.arrayBuffer());
85
+ const decoded = decode(buffer);
86
+
87
+ if (
88
+ typeof decoded !== "object" ||
89
+ decoded === null ||
90
+ Array.isArray(decoded) ||
91
+ decoded instanceof Uint8Array
92
+ ) {
93
+ throw new Error("Invalid tracker response");
94
+ }
95
+
96
+ if ("failure reason" in decoded) {
97
+ throw new Error(`Tracker failure: ${TEXT_DECODER.decode(decoded["failure reason"] as Uint8Array)}`);
98
+ }
99
+
100
+ const peersRaw = decoded["peers"];
101
+
102
+ // Compact format: binary blob, 6 bytes per IPv4 peer
103
+ if (peersRaw instanceof Uint8Array) {
104
+ return parseCompactPeers(peersRaw);
105
+ }
106
+
107
+ // Dictionary format: list of {ip, port, peer id} dicts (used for IPv6 or non-compact)
108
+ if (Array.isArray(peersRaw)) {
109
+ return parseDictPeers(peersRaw);
110
+ }
111
+
112
+ return [];
113
+ }
114
+
115
+ export async function announceHTTP(
116
+ metadata: TorrentMetadata,
117
+ port = 6881,
118
+ numwant = 50,
119
+ ): Promise<PeerInfo[]> {
120
+ const peerId = getPeerId();
121
+ const urls = [...new Set(
122
+ metadata.announceList.flat().filter((u) => u.startsWith("http://") || u.startsWith("https://")),
123
+ )];
124
+
125
+ const results = await Promise.allSettled(
126
+ urls.map((u) => announceToTracker(u, metadata, peerId, port, numwant)),
127
+ );
128
+
129
+ const peers: PeerInfo[] = [];
130
+ for (let i = 0; i < results.length; i++) {
131
+ const r = results[i];
132
+ const url = urls[i];
133
+ if (r === undefined || url === undefined) continue;
134
+ if (r.status === "fulfilled") {
135
+ peers.push(...r.value);
136
+ log("tracker", `${url} ${r.value.length} peers`);
137
+ } else {
138
+ const reason = r.reason instanceof Error ? r.reason.message : String(r.reason);
139
+ log("tracker", `${url} failed ${reason}`);
140
+ }
141
+ }
142
+ return peers;
143
+ }
@@ -0,0 +1,136 @@
1
+ import { createSocket } from "node:dgram";
2
+ import { randomBytes } from "node:crypto";
3
+ import { log } from "../metadata.ts";
4
+ import { getPeerId } from "../peer/peer-id.ts";
5
+ import type { TorrentMetadata } from "../metadata.ts";
6
+ import type { PeerInfo } from "../types.ts";
7
+
8
+ const CONNECT_MAGIC = 0x41727101980n; // BEP 15 magic connection ID
9
+ const CONNECT_ACTION = 0;
10
+ const ANNOUNCE_ACTION = 1;
11
+ const TIMEOUT_MS = 5_000;
12
+ const MAX_RETRIES = 1; // no retries on initial announce — re-announces can retry later
13
+
14
+ function randomTransactionId(): number {
15
+ return randomBytes(4).readUInt32BE(0);
16
+ }
17
+
18
+ function buildConnectRequest(): { buf: Buffer; txId: number } {
19
+ const buf = Buffer.alloc(16);
20
+ const view = new DataView(buf.buffer);
21
+ view.setBigInt64(0, CONNECT_MAGIC);
22
+ view.setInt32(8, CONNECT_ACTION);
23
+ const txId = randomTransactionId();
24
+ view.setUint32(12, txId);
25
+ return { buf, txId };
26
+ }
27
+
28
+ function buildAnnounceRequest(
29
+ connId: bigint, // treated as unsigned
30
+ metadata: TorrentMetadata,
31
+ listenPort: number,
32
+ ): { buf: Buffer; txId: number } {
33
+ const buf = Buffer.alloc(98);
34
+ const view = new DataView(buf.buffer);
35
+ let offset = 0;
36
+
37
+ view.setBigUint64(offset, connId); offset += 8;
38
+ view.setInt32(offset, ANNOUNCE_ACTION); offset += 4;
39
+ const txId = randomTransactionId();
40
+ view.setUint32(offset, txId); offset += 4;
41
+ buf.set(metadata.infoHash, offset); offset += 20;
42
+ buf.set(getPeerId(), offset); offset += 20;
43
+ view.setBigInt64(offset, 0n); offset += 8; // downloaded
44
+ view.setBigInt64(offset, BigInt(metadata.totalSize)); offset += 8; // left
45
+ view.setBigInt64(offset, 0n); offset += 8; // uploaded
46
+ view.setInt32(offset, 2); offset += 4; // event: started
47
+ view.setUint32(offset, 0); offset += 4; // ip: default
48
+ view.setUint32(offset, 0); offset += 4; // key
49
+ view.setInt32(offset, 50); offset += 4; // num_want
50
+ view.setUint16(offset, listenPort); // port
51
+
52
+ return { buf, txId };
53
+ }
54
+
55
+ function parseCompactPeers(buf: Buffer, offset: number): PeerInfo[] {
56
+ const peers: PeerInfo[] = [];
57
+ while (offset + 6 <= buf.length) {
58
+ const ip = `${buf[offset]}.${buf[offset + 1]}.${buf[offset + 2]}.${buf[offset + 3]}`;
59
+ const port = buf.readUInt16BE(offset + 4);
60
+ peers.push({ ip, port });
61
+ offset += 6;
62
+ }
63
+ return peers;
64
+ }
65
+
66
+ function sendAndReceive(
67
+ socket: ReturnType<typeof createSocket>,
68
+ buf: Buffer,
69
+ host: string,
70
+ port: number,
71
+ ): Promise<Buffer> {
72
+ return new Promise((resolve, reject) => {
73
+ const timer = setTimeout(() => {
74
+ reject(new Error("timeout"));
75
+ }, TIMEOUT_MS);
76
+
77
+ socket.once("message", (msg) => {
78
+ clearTimeout(timer);
79
+ resolve(msg as Buffer);
80
+ });
81
+
82
+ socket.send(buf, port, host, (err) => {
83
+ if (err) { clearTimeout(timer); reject(err); }
84
+ });
85
+ });
86
+ }
87
+
88
+ export async function announceUDP(
89
+ url: string,
90
+ metadata: TorrentMetadata,
91
+ listenPort = 6881,
92
+ ): Promise<PeerInfo[]> {
93
+ const parsed = new URL(url);
94
+ const host = parsed.hostname;
95
+ const port = Number(parsed.port) || 80;
96
+ const label = `${host}:${port}`;
97
+
98
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
99
+ const socket = createSocket("udp4");
100
+
101
+ try {
102
+ // Step 1: connect
103
+ const { buf: connectBuf, txId: connectTx } = buildConnectRequest();
104
+ const connectResp = await sendAndReceive(socket, connectBuf, host, port);
105
+
106
+ const view = new DataView(connectResp.buffer, connectResp.byteOffset);
107
+ if (view.getInt32(0) !== CONNECT_ACTION) throw new Error("bad connect action");
108
+ if (view.getUint32(4) !== connectTx) throw new Error("connect txId mismatch");
109
+ const connId = view.getBigUint64(8);
110
+
111
+ // Step 2: announce
112
+ const { buf: annBuf, txId: annTx } = buildAnnounceRequest(connId, metadata, listenPort);
113
+ const annResp = await sendAndReceive(socket, annBuf, host, port);
114
+
115
+ const annView = new DataView(annResp.buffer, annResp.byteOffset);
116
+ if (annView.getInt32(0) !== ANNOUNCE_ACTION) throw new Error("bad announce action");
117
+ if (annView.getUint32(4) !== annTx) throw new Error("announce txId mismatch");
118
+
119
+ const peers = parseCompactPeers(annResp, 20);
120
+ log("tracker", `udp://${label} ${peers.length} peers`);
121
+ return peers;
122
+ } catch (err) {
123
+ const msg = err instanceof Error ? err.message : String(err);
124
+ if (attempt < MAX_RETRIES) {
125
+ // silent retry
126
+ } else {
127
+ log("tracker", `udp://${label} failed ${msg}`);
128
+ throw err;
129
+ }
130
+ } finally {
131
+ socket.close();
132
+ }
133
+ }
134
+
135
+ throw new Error("max retries exceeded");
136
+ }
@@ -0,0 +1,25 @@
1
+ export interface PeerInfo {
2
+ ip: string;
3
+ port: number;
4
+ }
5
+
6
+ export interface TrackerResponse {
7
+ complete: number;
8
+ incomplete: number;
9
+ interval: number;
10
+ peers: PeerInfo[];
11
+ }
12
+
13
+ export interface FileInfo {
14
+ path: string;
15
+ length: number;
16
+ offset: number; // byte offset in the flat concatenated stream
17
+ }
18
+
19
+ export type TorrentStatus =
20
+ | "created"
21
+ | "verifying"
22
+ | "ready"
23
+ | "downloading"
24
+ | "seeding"
25
+ | "stopped";
@@ -0,0 +1,6 @@
1
+ export interface LayoutDimensions {
2
+ terminal: { width: number; height: number };
3
+ sidebar: { x: number; y: number; width: number; height: number };
4
+ content: { x: number; y: number; width: number; height: number };
5
+ statusBar: { x: number; y: number; width: number; height: number };
6
+ }
@@ -0,0 +1,8 @@
1
+ // Typed environment variable accessors
2
+
3
+ export const env = {
4
+ OTUI_SHOW_STATS: process.env.OTUI_SHOW_STATS === "true",
5
+ SHOW_CONSOLE: process.env.SHOW_CONSOLE === "true",
6
+ CONFIG_PATH: process.env.CONFIG_PATH,
7
+ DEV: process.env.DEV === "true",
8
+ } as const;
@@ -0,0 +1,12 @@
1
+ import type { TorrentState } from "../store";
2
+
3
+ export function filterTorrents(torrents: TorrentState[], view: string): TorrentState[] {
4
+ switch (view) {
5
+ case "Downloading": return torrents.filter((t) => t.status === "downloading");
6
+ case "Seeding":
7
+ case "Completed": return torrents.filter((t) => t.status === "seeding");
8
+ case "Paused": return torrents.filter((t) => t.status === "paused");
9
+ case "Stopped": return torrents.filter((t) => t.status === "stopped" || t.status === "error");
10
+ default: return torrents;
11
+ }
12
+ }
@@ -0,0 +1,32 @@
1
+ import { SIDEBAR_WIDTH } from "../constants";
2
+ import type { LayoutDimensions } from "../types/layout";
3
+
4
+ const STATUS_BAR_HEIGHT = 1;
5
+
6
+ export function calculateLayout(
7
+ terminalWidth: number,
8
+ terminalHeight: number,
9
+ ): LayoutDimensions {
10
+ const contentHeight = terminalHeight - STATUS_BAR_HEIGHT;
11
+ return {
12
+ terminal: { width: terminalWidth, height: terminalHeight },
13
+ sidebar: {
14
+ x: 0,
15
+ y: 0,
16
+ width: SIDEBAR_WIDTH,
17
+ height: contentHeight,
18
+ },
19
+ content: {
20
+ x: SIDEBAR_WIDTH,
21
+ y: 0,
22
+ width: terminalWidth - SIDEBAR_WIDTH,
23
+ height: contentHeight,
24
+ },
25
+ statusBar: {
26
+ x: 0,
27
+ y: contentHeight,
28
+ width: terminalWidth,
29
+ height: STATUS_BAR_HEIGHT,
30
+ },
31
+ };
32
+ }
@@ -0,0 +1,17 @@
1
+ import { APP_NAME } from "../constants";
2
+
3
+ export function getConfigDir(): string {
4
+ return `${process.env.XDG_CONFIG_HOME ?? `${process.env.HOME}/.config`}/${APP_NAME}`;
5
+ }
6
+
7
+ export function getConfigPath(filename: string): string {
8
+ return `${getConfigDir()}/${filename}`;
9
+ }
10
+
11
+ export function getDataDir(): string {
12
+ return `${process.env.XDG_DATA_HOME ?? `${process.env.HOME}/.local/share`}/${APP_NAME}`;
13
+ }
14
+
15
+ export function resolvePath(p: string): string {
16
+ return p.replace(/^~/, process.env.HOME ?? ".");
17
+ }