feat: Initial commit

This commit is contained in:
Julien Quiaios 2026-03-29 20:24:32 -04:00
commit c131b84819
Signed by: palozob
GPG Key ID: 10F46E45A96EDCDA
45 changed files with 3471 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
node_modules/
ui/node_modules/
ui/dist/
dist/
.env
*.log
torrents/*

152
CLAUDE.md Normal file
View File

@ -0,0 +1,152 @@
# CLAUDE.md — Torrent Faker
## Project Purpose
Torrent Faker is a fake BitTorrent seeder that announces upload stats to real trackers without transferring any actual data. It simulates realistic seeding behavior to satisfy tracker requirements.
## Tech Stack
| Layer | Technology |
|-----------|-------------------------------------|
| Runtime | Bun |
| Language | TypeScript (strict, ESNext) |
| HTTP | Hono 4.x |
| Config | YAML (`js-yaml`) + Zod validation |
| Linting | Biome |
| Frontend | Svelte 5 + Vite (in `ui/`) |
## Project Structure
```
src/
index.ts # Entry point: loads config, starts registry + HTTP server
cli/index.ts # CLI client (talks to running API)
config/Config.ts # Zod schema + config loader
config/config.default.yml # Default config values
api/
server.ts # Hono app, mounts routes, serves UI
SeederRegistry.ts # Central in-memory store for all FakeSeeder instances
response.ts # HTTP response helpers
routes/
torrents.ts # GET/POST/DELETE /api/torrents
status.ts # GET /api/status, GET /api/status/stream (SSE)
config.ts # GET/PATCH /api/config
core/
bencode/ # Bencode decoder + encoder
client/
ClientProfile.ts # Profile interface + peer ID generation
profiles/ # qbittorrent.ts, transmission.ts
seeder/
FakeSeeder.ts # Announce loop, lifecycle (started → running → stopped)
SpeedSimulator.ts # Gaussian noise + burst/stall events
torrent/TorrentFile.ts # Parses .torrent, extracts info hash + tracker list
tracker/
ITracker.ts # Tracker interface
HttpTracker.ts # HTTP/HTTPS tracker (BEP 3)
UdpTracker.ts # UDP tracker (BEP 15)
TrackerResponse.ts # Response types
config/config.yml # Runtime config (gitignored defaults)
torrents/ # Drop .torrent files here
ui/ # Svelte frontend (built to ui/dist/)
```
## TypeScript Path Aliases
```
@core/* → src/core/*
@api/* → src/api/*
@config/* → src/config/*
```
## Dev Commands
```bash
bun run dev # Start server (hot-reload)
bun run start # Start server (production)
bun run cli <cmd> # Run CLI against running server
bun test # Run tests
bun run lint # Biome check
bun run format # Biome format --write
bun run build:ui # Build Svelte UI to ui/dist/
bun run build # Full build (UI only for now)
```
## Architecture
```
SeederRegistry
└── FakeSeeder (one per torrent)
├── SpeedSimulator — random base rate, ±15% jitter, burst/stall
└── Tracker[]
├── HttpTracker (BEP 3)
└── UdpTracker (BEP 15)
```
- `SeederRegistry` is the single source of truth; routes call it, not FakeSeeder directly.
- `FakeSeeder` extends `EventEmitter`; emits `announce`, `stopped`, `stateChange`.
- `SeederRegistry` subscribes to seeder events and pushes SSE updates to connected clients.
- Announce lifecycle: first call sends `event=started`, subsequent calls send `event=` (empty), final call on removal sends `event=stopped`.
## Configuration
Runtime config file: `config/config.yml` (copied from `src/config/config.default.yml`).
| Field | Default | Description |
|---------------------------|-----------|------------------------------------------|
| `port` | 3000 | HTTP server port (UI + API) |
| `announcePort` | 6881 | Port reported to trackers |
| `minUploadRateBytesPerSec`| 524288 | Min simulated speed (512 KB/s) |
| `maxUploadRateBytesPerSec`| 2097152 | Max simulated speed (2 MB/s) |
| `clientProfile` | qbittorrent | Client to impersonate |
| `torrentsDir` | ./torrents | Directory for .torrent files |
| `autoLoad` | true | Load all torrents in dir on startup |
Config is Zod-validated at startup. `PATCH /api/config` persists changes back to `config.yml`.
## API Endpoints
| Method | Path | Description |
|--------|------------------------------|---------------------------------------|
| GET | `/api/torrents` | List all active torrents |
| POST | `/api/torrents` | Upload `.torrent` file (multipart) |
| GET | `/api/torrents/:hash` | Stats + announce history for one |
| DELETE | `/api/torrents/:hash` | Stop seeding and remove |
| GET | `/api/status` | Global stats snapshot |
| GET | `/api/status/stream` | SSE stream (stats, torrent, ping) |
| GET | `/api/config` | Current config |
| PATCH | `/api/config` | Update mutable config fields |
| GET | `/*` | Serve Svelte SPA from `ui/dist/` |
### SSE Event Types
- `stats` — global stats on every change
- `torrent` — per-torrent state update on announce/change
- `torrents` — full list on initial connection
- `ping` — keep-alive every 30s
## CLI
Requires a running server. Reads `TORRENT_FAKER_API` env var (default: `http://localhost:3000`).
```bash
bun run cli add <file.torrent> # Start fake-seeding
bun run cli list # List active torrents
bun run cli remove <hash> # Stop and remove (hash prefix OK)
bun run cli status # Global stats
```
## Client Profiles
| Profile | Peer ID prefix | User-Agent |
|---------------|----------------|-----------------------|
| `qbittorrent` | `-qB4520-` | `qBittorrent/4.5.2` |
| `transmission`| `-TR3000-` | `Transmission/3.00` |
Each seeder generates a unique 20-byte peer ID per session.
## Code Conventions
- Biome enforces formatting; run `bun run format` before committing.
- Zod schemas for all external input (config file, API request bodies).
- No ORM — binary protocols implemented manually (bencode, UDP packets).
- EventEmitter pattern for seeder → registry communication.
- SSE for live UI updates (no WebSockets, no polling).

33
Dockerfile Normal file
View File

@ -0,0 +1,33 @@
# ─── Stage 1: Build the Svelte UI ────────────────────────────────────────────
FROM node:20-alpine AS ui-builder
WORKDIR /app/ui
COPY ui/package.json ui/package-lock.json* ./
RUN npm install
COPY ui/ ./
RUN npm run build
# ─── Stage 2: Bun runtime ────────────────────────────────────────────────────
FROM oven/bun:1-alpine AS runtime
WORKDIR /app
# Install production dependencies
COPY package.json bun.lockb* ./
RUN bun install --production --frozen-lockfile || bun install --production
# Copy source
COPY src/ ./src/
COPY tsconfig.json ./
# Copy built UI from stage 1
COPY --from=ui-builder /app/ui/dist ./ui/dist
# Volumes for user data
VOLUME ["/app/torrents", "/app/config"]
EXPOSE 3000
CMD ["bun", "run", "src/index.ts"]

162
README.md Normal file
View File

@ -0,0 +1,162 @@
# Torrent Faker
Fake BitTorrent seeder — announces upload statistics to real trackers without transferring any actual data.
Useful for keeping a torrent "alive" on a tracker, testing tracker behavior, or load-testing tracker infrastructure without consuming bandwidth.
## Features
- Announces to HTTP/HTTPS trackers (BEP 3) and UDP trackers (BEP 15)
- Simulates realistic upload speeds with jitter, burst, and stall events
- Impersonates real BitTorrent clients (qBittorrent, Transmission)
- Web UI for managing torrents and viewing live stats
- REST API for programmatic control
- CLI for quick command-line use
- Docker support
## Prerequisites
**Option A — Bun (native):** [Bun](https://bun.sh) ≥ 1.0
**Option B — Docker:** Docker + Docker Compose
## Quick Start
### Docker (recommended)
```bash
docker compose up -d
```
Drop `.torrent` files into the `torrents/` directory — they are loaded automatically.
Access the web UI at <http://localhost:3000>.
### Native (Bun)
```bash
# Install dependencies
bun install
# Build the UI
bun run build:ui
# Start the server
bun run start
```
The server starts on port 3000 by default.
## Configuration
Copy the default config and edit as needed:
```bash
cp src/config/config.default.yml config/config.yml
```
| Field | Default | Description |
|-----------------------------|--------------|-----------------------------------------------|
| `port` | `3000` | HTTP port for the web UI and REST API |
| `announcePort` | `6881` | Port reported to trackers in announce requests |
| `minUploadRateBytesPerSec` | `524288` | Minimum simulated upload speed (512 KB/s) |
| `maxUploadRateBytesPerSec` | `2097152` | Maximum simulated upload speed (2 MB/s) |
| `clientProfile` | `qbittorrent`| Client to impersonate: `qbittorrent` or `transmission` |
| `torrentsDir` | `./torrents` | Directory scanned for `.torrent` files |
| `autoLoad` | `true` | Auto-load all torrents in `torrentsDir` on startup |
Configuration can also be updated at runtime via the web UI or `PATCH /api/config`.
## Web UI
The web UI is served at the root URL and provides:
- Live upload stats per torrent (speed, total uploaded, seeders/leechers)
- Global stats panel (active seeders, total uploaded, uptime)
- Add torrents by file upload
- Remove torrents
- Edit configuration settings
Stats update in real time via Server-Sent Events.
## CLI
The CLI requires a running server instance.
```bash
# Add and start seeding a torrent
bun run cli add ./torrents/example.torrent
# List all active torrents
bun run cli list
# Remove a torrent (info hash or prefix)
bun run cli remove a1b2c3d4
# Show global stats
bun run cli status
```
Set `TORRENT_FAKER_API` to point to a non-local server:
```bash
TORRENT_FAKER_API=http://my-server:3000 bun run cli list
```
## REST API
| Method | Path | Description |
|----------|---------------------------|--------------------------------------------|
| `GET` | `/api/torrents` | List all active torrents |
| `POST` | `/api/torrents` | Upload a `.torrent` file (`multipart/form-data`, field: `torrent`) |
| `GET` | `/api/torrents/:hash` | Stats and announce history for one torrent |
| `DELETE` | `/api/torrents/:hash` | Stop seeding and remove a torrent |
| `GET` | `/api/status` | Global stats snapshot |
| `GET` | `/api/status/stream` | SSE stream for live updates |
| `GET` | `/api/config` | Current configuration |
| `PATCH` | `/api/config` | Update mutable configuration fields |
### Add a torrent via curl
```bash
curl -X POST http://localhost:3000/api/torrents \
-F "torrent=@./example.torrent"
```
### SSE stream events
Connect to `/api/status/stream` to receive:
- `stats` — global stats on every change
- `torrent` — per-torrent state on each announce
- `torrents` — full torrent list on initial connection
- `ping` — keep-alive every 30 seconds
## Client Profiles
| Profile | Peer ID prefix | User-Agent |
|----------------|----------------|-----------------------|
| `qbittorrent` | `-qB4520-` | `qBittorrent/4.5.2` |
| `transmission` | `-TR3000-` | `Transmission/3.00` |
Each seeder session uses a unique randomly-generated peer ID.
## Development
```bash
bun run dev # Start server with hot-reload
bun test # Run tests
bun run lint # Lint with Biome
bun run format # Auto-format with Biome
bun run build:ui # Build Svelte UI to ui/dist/
```
## Speed Simulation
Each torrent picks a random base upload rate within the configured min/max range. Per announce interval (every ~5 seconds):
- Normal: ±15% Gaussian-like noise around the base rate
- 5% chance: burst to 150200% of base rate
- 3% chance: stall to 010% of base rate
This produces upload curves that resemble real client behavior.

28
biome.json Normal file
View File

@ -0,0 +1,28 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.9/schema.json",
"assist": {
"actions": {
"source": {
"organizeImports": "on"
}
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"trailingCommas": "es5"
}
}
}

59
bun.lock Normal file
View File

@ -0,0 +1,59 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "torrent-faker",
"dependencies": {
"hono": "^4.12.9",
"js-yaml": "^4.1.1",
"zod": "^4.3.6",
},
"devDependencies": {
"@biomejs/biome": "^2.4.9",
"@types/js-yaml": "^4.0.9",
"@types/node": "^25.5.0",
"bun-types": "latest",
},
},
},
"packages": {
"@biomejs/biome": ["@biomejs/biome@2.4.9", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.9", "@biomejs/cli-darwin-x64": "2.4.9", "@biomejs/cli-linux-arm64": "2.4.9", "@biomejs/cli-linux-arm64-musl": "2.4.9", "@biomejs/cli-linux-x64": "2.4.9", "@biomejs/cli-linux-x64-musl": "2.4.9", "@biomejs/cli-win32-arm64": "2.4.9", "@biomejs/cli-win32-x64": "2.4.9" }, "bin": { "biome": "bin/biome" } }, "sha512-wvZW92FrwitTcacvCBT8xdAbfbxWfDLwjYMmU3djjqQTh7Ni4ZdiWIT/x5VcZ+RQuxiKzIOzi5D+dcyJDFZMsA=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-d5G8Gf2RpH5pYwiHLPA+UpG3G9TLQu4WM+VK6sfL7K68AmhcEQ9r+nkj/DvR/GYhYox6twsHUtmWWWIKfcfQQA=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-LNCLNgqDMG7BLdc3a8aY/dwKPK7+R8/JXJoXjCvZh2gx8KseqBdFDKbhrr7HCWF8SzNhbTaALhTBoh/I6rf9lA=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-4adnkAUi6K4C/emPRgYznMOcLlUqZdXWM6aIui4VP4LraE764g6Q4YguygnAUoxKjKIXIWPteKMgRbN0wsgwcg=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-8RCww5xnPn2wpK4L/QDGDOW0dq80uVWfppPxHIUg6mOs9B6gRmqPp32h1Ls3T8GnW8Wo5A8u7vpTwz4fExN+sw=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.9", "", { "os": "linux", "cpu": "x64" }, "sha512-L10na7POF0Ks/cgLFNF1ZvIe+X4onLkTi5oP9hY+Rh60Q+7fWzKDDCeGyiHUFf1nGIa9dQOOUPGe2MyYg8nMSQ=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.9", "", { "os": "linux", "cpu": "x64" }, "sha512-5TD+WS9v5vzXKzjetF0hgoaNFHMcpQeBUwKKVi3JbG1e9UCrFuUK3Gt185fyTzvRdwYkJJEMqglRPjmesmVv4A=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-aDZr0RBC3sMGJOU10BvG7eZIlWLK/i51HRIfScE2lVhfts2dQTreowLiJJd+UYg/tHKxS470IbzpuKmd0MiD6g=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.9", "", { "os": "win32", "cpu": "x64" }, "sha512-NS4g/2G9SoQ4ktKtz31pvyc/rmgzlcIDCGU/zWbmHJAqx6gcRj2gj5Q/guXhoWTzCUaQZDIqiCQXHS7BcGYc0w=="],
"@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="],
"@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"hono": ["hono@4.12.9", "", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="],
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"bun-types/@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
"bun-types/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
}
}

7
config/config.yml Normal file
View File

@ -0,0 +1,7 @@
port: 3000
announcePort: 6881
minUploadRateBytesPerSec: 2048000
maxUploadRateBytesPerSec: 6144000
clientProfile: qbittorrent
torrentsDir: ./torrents
autoLoad: true

16
docker-compose.yml Normal file
View File

@ -0,0 +1,16 @@
services:
torrent-faker:
build: .
ports:
- "3000:3000" # Web UI + REST API
volumes:
- ./torrents:/app/torrents # drop .torrent files here
- ./config:/app/config # config.yml goes here
environment:
- NODE_ENV=production
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/status"]
interval: 30s
timeout: 5s
retries: 3

27
package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "torrent-faker",
"version": "0.1.0",
"description": "Fake torrent seeder — reports upload stats to trackers without sharing data",
"type": "module",
"scripts": {
"dev": "bun run src/index.ts",
"start": "bun run src/index.ts",
"cli": "bun run src/cli/index.ts",
"test": "bun test",
"build:ui": "cd ui && bun run build",
"build": "bun run build:ui",
"lint": "bunx biome check src",
"format": "bunx biome format --write src"
},
"dependencies": {
"hono": "^4.12.9",
"js-yaml": "^4.1.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@biomejs/biome": "^2.4.9",
"@types/js-yaml": "^4.0.9",
"@types/node": "^25.5.0",
"bun-types": "latest"
}
}

102
src/api/SeederRegistry.ts Normal file
View File

@ -0,0 +1,102 @@
/**
* In-memory registry of active FakeSeeders.
* Shared singleton between the API routes and the main entry point.
*/
import type { EventEmitter } from "node:events";
import type { Config } from "@config/Config";
import { createQbittorrentProfile } from "@core/client/profiles/qbittorrent";
import { createTransmissionProfile } from "@core/client/profiles/transmission";
import { type AnnounceRecord, FakeSeeder, type SeederState } from "@core/seeder/FakeSeeder";
import { parseTorrentBuffer } from "@core/torrent/TorrentFile";
export interface SeederEntry {
seeder: FakeSeeder;
addedAt: Date;
history: AnnounceRecord[];
}
type ChangeListener = (state: SeederState) => void;
export class SeederRegistry {
private readonly seeders = new Map<string, SeederEntry>();
private readonly config: Config;
private readonly changeListeners = new Set<ChangeListener>();
private startedAt = new Date();
constructor(config: Config) {
this.config = config;
}
onStateChange(listener: ChangeListener): () => void {
this.changeListeners.add(listener);
return () => this.changeListeners.delete(listener);
}
async addTorrent(buf: Buffer): Promise<SeederState> {
const torrent = parseTorrentBuffer(buf);
const existing = this.seeders.get(torrent.infoHashHex);
if (existing) {
return existing.seeder.getState();
}
const profile =
this.config.clientProfile === "transmission"
? createTransmissionProfile()
: createQbittorrentProfile();
const seeder = new FakeSeeder(torrent, profile, this.config);
const entry: SeederEntry = { seeder, addedAt: new Date(), history: [] };
seeder.on("announce", (record) => {
entry.history.push(record);
if (entry.history.length > 100) entry.history.shift();
});
seeder.on("stateChange", (state) => {
for (const listener of this.changeListeners) {
listener(state);
}
});
this.seeders.set(torrent.infoHashHex, entry);
await seeder.start();
return seeder.getState();
}
async removeTorrent(infoHashHex: string): Promise<boolean> {
const entry = this.seeders.get(infoHashHex);
if (!entry) return false;
await entry.seeder.stop();
this.seeders.delete(infoHashHex);
return true;
}
listTorrents(): Array<SeederState & { addedAt: Date }> {
return Array.from(this.seeders.values()).map((e) => ({
...e.seeder.getState(),
addedAt: e.addedAt,
}));
}
getHistory(infoHashHex: string): AnnounceRecord[] {
return this.seeders.get(infoHashHex)?.history ?? [];
}
globalStats() {
const list = this.listTorrents();
return {
activeSeeders: list.filter((t) => t.status === "running").length,
totalTorrents: list.length,
totalUploaded: list.reduce((s, t) => s + t.uploaded, 0),
uptimeSeconds: Math.floor((Date.now() - this.startedAt.getTime()) / 1000),
startedAt: this.startedAt,
};
}
async stopAll(): Promise<void> {
await Promise.allSettled(Array.from(this.seeders.values()).map((e) => e.seeder.stop()));
this.seeders.clear();
}
}

35
src/api/response.ts Normal file
View File

@ -0,0 +1,35 @@
import type { Context } from "hono";
/**
* Named HTTP status codes typed as literals via `as const`.
* Literal types (200, 201 ) are what Hono's TypedResponse conditional needs
* to resolve correctly; plain `number` makes the inference fail.
*/
export const Status = {
OK: 200,
CREATED: 201,
BAD_REQUEST: 400,
NOT_FOUND: 404,
UNPROCESSABLE_ENTITY: 422,
} as const;
/**
* Semantic response helpers.
* Routes call `res.created(c, data)` instead of `c.json<T>(data, 201 as const)`.
* No inline type parameters, no magic numbers.
*/
export const res = {
ok: <T>(c: Context, data: T) => c.json(data, Status.OK),
created: <T>(c: Context, data: T) => c.json(data, Status.CREATED),
badRequest: (c: Context, message: string) => c.json({ error: message }, Status.BAD_REQUEST),
/** For structured validation errors (e.g. Zod's formatted output) */
validationError: (c: Context, details: unknown) => c.json({ error: details }, Status.BAD_REQUEST),
notFound: (c: Context, message = "Not found") => c.json({ error: message }, Status.NOT_FOUND),
unprocessable: (c: Context, message: string) =>
c.json({ error: message }, Status.UNPROCESSABLE_ENTITY),
};

65
src/api/routes/config.ts Normal file
View File

@ -0,0 +1,65 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import type { Config } from "@config/Config";
import { Hono } from "hono";
import { dump } from "js-yaml";
import { z } from "zod";
import { res } from "../response.js";
/** Fields the UI/CLI are allowed to patch at runtime */
const PatchSchema = z.object({
minUploadRateBytesPerSec: z.number().int().positive().optional(),
maxUploadRateBytesPerSec: z.number().int().positive().optional(),
clientProfile: z.enum(["qbittorrent", "transmission"]).optional(),
announcePort: z.number().int().min(1).max(65535).optional(),
});
const CONFIG_PATH = "./config/config.yml";
export function configRouter(config: Config) {
const app = new Hono();
/** Return the current running config */
app.get("/", (c) => {
return c.json(config);
});
/** Patch mutable settings — persists to config/config.yml */
app.patch("/", async (c) => {
let body: unknown;
try {
body = await c.req.json();
} catch {
return res.badRequest(c, "Invalid JSON body");
}
const parsed = PatchSchema.safeParse(body);
if (!parsed.success) {
return res.validationError(c, parsed.error.format());
}
const patch = parsed.data;
// Validate min ≤ max after merging
const newMin = patch.minUploadRateBytesPerSec ?? config.minUploadRateBytesPerSec;
const newMax = patch.maxUploadRateBytesPerSec ?? config.maxUploadRateBytesPerSec;
if (newMin > newMax) {
return res.badRequest(c, "minUploadRateBytesPerSec must be ≤ maxUploadRateBytesPerSec");
}
// Apply patch to the live config object (affects all new seeders)
Object.assign(config, patch);
// Persist to disk so the next container start picks it up
try {
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
writeFileSync(CONFIG_PATH, dump(config), "utf8");
} catch (err) {
console.warn("Could not persist config to disk:", err);
}
return c.json(config);
});
return app;
}

74
src/api/routes/status.ts Normal file
View File

@ -0,0 +1,74 @@
import { Hono } from "hono";
import { streamSSE } from "hono/streaming";
import type { SeederRegistry } from "../SeederRegistry.js";
export function statusRouter(registry: SeederRegistry) {
const app = new Hono();
/** Global stats snapshot */
app.get("/", (c) => {
return c.json(registry.globalStats());
});
/**
* SSE stream pushes a state update whenever any seeder changes.
* The UI subscribes to this to get live updates without polling.
*
* Events:
* - "stats" with global stats (every change)
* - "torrent" with per-torrent state (on each seeder stateChange)
*/
app.get("/stream", (c) => {
return streamSSE(c, async (stream) => {
// Send initial snapshot
await stream.writeSSE({
event: "stats",
data: JSON.stringify(registry.globalStats()),
});
await stream.writeSSE({
event: "torrents",
data: JSON.stringify(registry.listTorrents()),
});
// Subscribe to changes
const unsubscribe = registry.onStateChange(async (torrentState) => {
try {
await stream.writeSSE({
event: "torrent",
data: JSON.stringify(torrentState),
});
await stream.writeSSE({
event: "stats",
data: JSON.stringify(registry.globalStats()),
});
} catch {
// client disconnected
}
});
// Keep alive with a comment every 30s
const keepAlive = setInterval(async () => {
try {
await stream.writeSSE({ event: "ping", data: "" });
} catch {
clearInterval(keepAlive);
unsubscribe();
}
}, 30_000);
// Clean up when client disconnects
stream.onAbort(() => {
clearInterval(keepAlive);
unsubscribe();
});
// Hold the connection open
await new Promise<void>((resolve) => {
stream.onAbort(() => resolve());
});
});
});
return app;
}

View File

@ -0,0 +1,62 @@
import { Hono } from "hono";
import { res } from "../response.js";
import type { SeederRegistry } from "../SeederRegistry.js";
export function torrentsRouter(registry: SeederRegistry) {
const app = new Hono();
/** List all active torrents with their current stats */
app.get("/", (c) => {
return c.json(registry.listTorrents());
});
/** Add a torrent — accepts multipart/form-data with a 'torrent' file field */
app.post("/", async (c) => {
const body = await c.req.parseBody();
const file = body.torrent;
if (!file || typeof file === "string") {
return res.badRequest(c, "Missing 'torrent' file field");
}
const buf = Buffer.from(await (file as File).arrayBuffer());
try {
const state = await registry.addTorrent(buf);
return res.created(c, state);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return res.unprocessable(c, msg);
}
});
/** Get stats + announce history for a specific torrent */
app.get("/:hash", (c) => {
const { hash } = c.req.param();
const list = registry.listTorrents();
const torrent = list.find((t) => t.infoHashHex === hash);
if (!torrent) {
return res.notFound(c);
}
return c.json({
...torrent,
history: registry.getHistory(hash),
});
});
/** Remove a torrent — sends 'stopped' to trackers then removes it */
app.delete("/:hash", async (c) => {
const { hash } = c.req.param();
const removed = await registry.removeTorrent(hash);
if (!removed) {
return res.notFound(c);
}
return c.json({ ok: true });
});
return app;
}

46
src/api/server.ts Normal file
View File

@ -0,0 +1,46 @@
import type { Config } from "@config/Config";
import { Hono } from "hono";
import { serveStatic } from "hono/bun";
import { cors } from "hono/cors";
import { logger } from "hono/logger";
import { configRouter } from "./routes/config.js";
import { statusRouter } from "./routes/status.js";
import { torrentsRouter } from "./routes/torrents.js";
import type { SeederRegistry } from "./SeederRegistry.js";
export function createApp(registry: SeederRegistry, config: Config): Hono {
const app = new Hono();
app.use("*", logger());
app.use(
"/api/*",
cors({
origin: "*",
allowMethods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
})
);
// API routes
app.route("/api/torrents", torrentsRouter(registry));
app.route("/api/status", statusRouter(registry));
app.route("/api/config", configRouter(config));
// Serve built UI static files (ui/dist)
app.use("/*", serveStatic({ root: "./ui/dist" }));
// SPA fallback — serve index.html for any unmatched route
app.get("*", serveStatic({ path: "./ui/dist/index.html" }));
return app;
}
export function startServer(registry: SeederRegistry, config: Config, port: number): void {
const app = createApp(registry, config);
Bun.serve({
fetch: app.fetch,
port,
});
console.log(`✓ Torrent Faker running on http://localhost:${port}`);
}

159
src/cli/index.ts Normal file
View File

@ -0,0 +1,159 @@
#!/usr/bin/env bun
/**
* CLI for Torrent Faker
*
* Commands:
* add <file.torrent> Add and start seeding a torrent
* list List all active torrents
* remove <hash> Stop seeding and remove a torrent
* status Show global stats
*
* The CLI talks to the running API server (default http://localhost:3000).
* Set TORRENT_FAKER_API env var to override the base URL.
*/
const BASE_URL = process.env.TORRENT_FAKER_API ?? "http://localhost:3000";
const [, , command, ...args] = process.argv;
async function main() {
switch (command) {
case "add":
await cmdAdd(args[0]);
break;
case "list":
await cmdList();
break;
case "remove":
case "rm":
await cmdRemove(args[0]);
break;
case "status":
await cmdStatus();
break;
default:
printUsage();
process.exit(1);
}
}
async function cmdAdd(filePath: string | undefined) {
if (!filePath) {
console.error("Usage: torrent-faker add <file.torrent>");
process.exit(1);
}
const file = Bun.file(filePath);
if (!(await file.exists())) {
console.error(`File not found: ${filePath}`);
process.exit(1);
}
const form = new FormData();
form.append("torrent", new Blob([await file.arrayBuffer()]), file.name);
const res = await fetch(`${BASE_URL}/api/torrents`, {
method: "POST",
body: form,
});
const data = await res.json();
if (!res.ok) {
console.error(`Error: ${(data as { error: string }).error}`);
process.exit(1);
}
const t = data as Record<string, unknown>;
console.log(`✓ Added: ${t.name} [${t.infoHashHex}]`);
console.log(` Status: ${t.status} Uploaded: ${formatBytes(t.uploaded as number)}`);
}
async function cmdList() {
const res = await fetch(`${BASE_URL}/api/torrents`);
const torrents = (await res.json()) as Array<Record<string, unknown>>;
if (torrents.length === 0) {
console.log("No active torrents.");
return;
}
const col = (s: string, w: number) => s.slice(0, w).padEnd(w);
console.log(
`${col("Hash", 10)} ${col("Name", 30)} ${col("Status", 10)} ${col("Uploaded", 12)} Seeders/Leechers`
);
console.log("─".repeat(90));
for (const t of torrents) {
console.log(
`${col(String(t.infoHashHex).slice(0, 8), 10)} ` +
`${col(String(t.name), 30)} ` +
`${col(String(t.status), 10)} ` +
`${col(formatBytes(t.uploaded as number), 12)} ` +
`${t.seeders ?? "?"}/${t.leechers ?? "?"}`
);
}
}
async function cmdRemove(hash: string | undefined) {
if (!hash) {
console.error("Usage: torrent-faker remove <info-hash>");
process.exit(1);
}
const res = await fetch(`${BASE_URL}/api/torrents/${hash}`, { method: "DELETE" });
if (res.status === 404) {
console.error(`Torrent not found: ${hash}`);
process.exit(1);
}
console.log(`✓ Removed ${hash}`);
}
async function cmdStatus() {
const res = await fetch(`${BASE_URL}/api/status`);
const stats = (await res.json()) as Record<string, unknown>;
console.log(`Active seeders : ${stats.activeSeeders}`);
console.log(`Total torrents : ${stats.totalTorrents}`);
console.log(`Total uploaded : ${formatBytes(stats.totalUploaded as number)}`);
console.log(`Uptime : ${formatUptime(stats.uptimeSeconds as number)}`);
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
}
function formatUptime(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
return `${h}h ${m}m ${s}s`;
}
function printUsage() {
console.log(`
Torrent Faker CLI
Usage: torrent-faker <command> [options]
Commands:
add <file.torrent> Start fake-seeding a torrent
list List all active torrents
remove <hash> Stop and remove a torrent (prefix OK)
status Show global upload stats
Environment:
TORRENT_FAKER_API API base URL (default: http://localhost:3000)
`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

51
src/config/Config.ts Normal file
View File

@ -0,0 +1,51 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { load } from "js-yaml";
import { z } from "zod";
const ConfigSchema = z.object({
/** Port for the Hono HTTP server (UI + API) */
port: z.number().int().min(1).max(65535).default(3000),
/** Port reported to trackers in announces */
announcePort: z.number().int().min(1).max(65535).default(6881),
/** Minimum upload rate in bytes/second (e.g. 524288 = 512 KB/s) */
minUploadRateBytesPerSec: z.number().int().positive().default(524_288),
/** Maximum upload rate in bytes/second (e.g. 2097152 = 2 MB/s) */
maxUploadRateBytesPerSec: z.number().int().positive().default(2_097_152),
/** Client profile to use: "qbittorrent" | "transmission" */
clientProfile: z.enum(["qbittorrent", "transmission"]).default("qbittorrent"),
/** Directory to watch for .torrent files */
torrentsDir: z.string().default("./torrents"),
/** Autoload all .torrent files from torrentsDir on startup */
autoLoad: z.boolean().default(true),
});
export type Config = z.infer<typeof ConfigSchema>;
const DEFAULT_CONFIG_PATH = join(import.meta.dir, "config.default.yml");
export function loadConfig(configPath?: string): Config {
let raw: unknown = {};
// Try user config first
const userPath = configPath ?? "./config/config.yml";
if (existsSync(userPath)) {
raw = load(readFileSync(userPath, "utf8")) ?? {};
} else if (existsSync(DEFAULT_CONFIG_PATH)) {
raw = load(readFileSync(DEFAULT_CONFIG_PATH, "utf8")) ?? {};
}
const result = ConfigSchema.safeParse(raw);
if (!result.success) {
console.error("Invalid config:", z.treeifyError(result.error));
throw new Error("Config validation failed");
}
return result.data;
}

View File

@ -0,0 +1,23 @@
# Torrent Faker — default configuration
# Copy this file to config/config.yml to override settings
# Port for the web UI and REST API
port: 3000
# Port reported to trackers in announce requests
announcePort: 6881
# Simulated upload speed range (bytes/second)
# Each torrent picks a random base rate within [min, max]
# 524288 = 512 KB/s, 1048576 = 1 MB/s, 2097152 = 2 MB/s
minUploadRateBytesPerSec: 524288
maxUploadRateBytesPerSec: 2097152
# BitTorrent client to impersonate: qbittorrent | transmission
clientProfile: qbittorrent
# Directory where .torrent files are loaded from
torrentsDir: ./torrents
# Automatically load all .torrent files in torrentsDir on startup
autoLoad: true

139
src/core/bencode/decoder.ts Normal file
View File

@ -0,0 +1,139 @@
/**
* Bencode decoder
* Spec: https://wiki.theory.org/BitTorrentSpecification#Bencoding
*
* Types:
* integers i<decimal>e e.g. i42e
* strings <len>:<bytes> e.g. 4:spam
* lists l<items>e
* dicts d<key><value>...e (keys must be byte strings, sorted)
*
* Binary strings (info_hash raw bytes) are returned as Buffer, not string.
* All dict keys are returned as string (UTF-8).
*/
export type BencodeValue = number | Buffer | BencodeList | BencodeDict;
export type BencodeList = BencodeValue[];
export type BencodeDict = { [key: string]: BencodeValue };
interface DecodeResult {
value: BencodeValue;
/** byte offset after the decoded value */
end: number;
}
export function decode(data: Buffer | Uint8Array): BencodeValue {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
const result = decodeAt(buf, 0);
return result.value;
}
export function decodeAt(buf: Buffer, offset: number): DecodeResult {
const byte = buf[offset];
if (byte === 0x69) {
// 'i' — integer
return decodeInteger(buf, offset);
}
if (byte === 0x6c) {
// 'l' — list
return decodeList(buf, offset);
}
if (byte === 0x64) {
// 'd' — dict
return decodeDict(buf, offset);
}
if (byte >= 0x30 && byte <= 0x39) {
// '0'-'9' — byte string
return decodeString(buf, offset);
}
throw new Error(`Unexpected byte 0x${byte.toString(16)} at offset ${offset}`);
}
function decodeInteger(buf: Buffer, offset: number): DecodeResult {
// format: i<decimal>e
const end = buf.indexOf(0x65, offset + 1); // 'e'
if (end === -1) throw new Error(`Unterminated integer at offset ${offset}`);
const str = buf.subarray(offset + 1, end).toString("ascii");
if (str === "-0") throw new Error("Invalid integer: -0");
const value = Number(str);
if (!Number.isFinite(value)) throw new Error(`Invalid integer: ${str}`);
return { value, end: end + 1 };
}
function decodeString(buf: Buffer, offset: number): DecodeResult {
const colonIdx = buf.indexOf(0x3a, offset); // ':'
if (colonIdx === -1) throw new Error(`Malformed string at offset ${offset}`);
const len = Number(buf.subarray(offset, colonIdx).toString("ascii"));
if (!Number.isInteger(len) || len < 0) {
throw new Error(`Invalid string length at offset ${offset}`);
}
const start = colonIdx + 1;
const value = Buffer.from(buf.subarray(start, start + len));
return { value, end: start + len };
}
function decodeList(buf: Buffer, offset: number): DecodeResult {
const list: BencodeList = [];
let pos = offset + 1; // skip 'l'
while (buf[pos] !== 0x65) {
// 'e'
if (pos >= buf.length) throw new Error(`Unterminated list at offset ${offset}`);
const item = decodeAt(buf, pos);
list.push(item.value);
pos = item.end;
}
return { value: list, end: pos + 1 };
}
function decodeDict(buf: Buffer, offset: number): DecodeResult {
const dict: BencodeDict = {};
let pos = offset + 1; // skip 'd'
while (buf[pos] !== 0x65) {
// 'e'
if (pos >= buf.length) throw new Error(`Unterminated dict at offset ${offset}`);
const keyResult = decodeString(buf, pos);
const key = (keyResult.value as Buffer).toString("utf8");
pos = keyResult.end;
const valResult = decodeAt(buf, pos);
dict[key] = valResult.value;
pos = valResult.end;
}
return { value: dict, end: pos + 1 };
}
/** Convenience: return the raw Buffer slice for a specific dict key path.
* Used to extract the raw bencoded 'info' dict for SHA-1 hashing.
* Walks the top-level dict properly instead of using indexOf, which would
* produce false matches when a value's bytes happen to contain the key pattern. */
export function rawSlice(buf: Buffer, key: string): Buffer | null {
if (buf[0] !== 0x64) return null; // top-level must be a dict ('d')
let pos = 1; // skip 'd'
while (pos < buf.length && buf[pos] !== 0x65 /* 'e' */) {
const keyResult = decodeString(buf, pos);
const k = (keyResult.value as Buffer).toString("utf8");
pos = keyResult.end;
const valueStart = pos;
const valueResult = decodeAt(buf, valueStart);
if (k === key) {
return buf.subarray(valueStart, valueResult.end);
}
pos = valueResult.end;
}
return null;
}

View File

@ -0,0 +1,57 @@
/**
* Bencode encoder serializes JS values to bencode bytes.
* Used primarily for UDP tracker connect/announce requests.
*/
/**
* Encoder-only type hierarchy mirrors BencodeValue but adds `string` at every level.
* The *decoder* never returns strings (bencode byte-strings are always Buffer), so
* BencodeValue intentionally excludes string. The encoder is more permissive: callers
* can pass plain JS strings, and they are serialized as bencode byte-strings.
*/
export type EncodableDict = { [key: string]: EncodableValue };
export type EncodableList = EncodableValue[];
export type EncodableValue = number | string | Buffer | EncodableList | EncodableDict;
export function encode(value: EncodableValue): Buffer {
if (typeof value === "number") {
return Buffer.from(`i${Math.trunc(value)}e`, "ascii");
}
if (Buffer.isBuffer(value)) {
return Buffer.concat([Buffer.from(`${value.length}:`, "ascii"), value]);
}
if (typeof value === "string") {
const strBuf = Buffer.from(value, "utf8");
return Buffer.concat([Buffer.from(`${strBuf.length}:`, "ascii"), strBuf]);
}
if (Array.isArray(value)) {
return encodeList(value);
}
return encodeDict(value);
}
function encodeList(list: EncodableList): Buffer {
const parts: Buffer[] = [Buffer.from("l", "ascii")];
for (const item of list) {
parts.push(encode(item));
}
parts.push(Buffer.from("e", "ascii"));
return Buffer.concat(parts);
}
function encodeDict(dict: EncodableDict): Buffer {
const parts: Buffer[] = [Buffer.from("d", "ascii")];
// Keys must be sorted lexicographically
const keys = Object.keys(dict).sort();
for (const key of keys) {
const keyBuf = Buffer.from(key, "utf8");
parts.push(Buffer.from(`${keyBuf.length}:`, "ascii"), keyBuf);
parts.push(encode(dict[key]));
}
parts.push(Buffer.from("e", "ascii"));
return Buffer.concat(parts);
}

View File

@ -0,0 +1,42 @@
import { randomBytes } from "node:crypto";
export interface ClientProfile {
/** Client identifier prefix, e.g. "-qB4520-" (8 chars) */
idPrefix: string;
/** 20-byte peer ID Buffer (generated once per torrent session) */
peerId: Buffer;
/** HTTP User-Agent header value */
userAgent: string;
/** Random key sent in HTTP announces for session continuity */
key: string;
/** Human-readable client name */
name: string;
}
/**
* Generate a 20-byte peer ID using Azureus-style encoding:
* -<XX><VVVV>-<12 random bytes as printable chars>
*
* Example: -qB4520-<random12>
*/
export function generatePeerId(prefix: string): Buffer {
if (prefix.length !== 8) {
throw new Error(`Peer ID prefix must be exactly 8 chars, got: "${prefix}"`);
}
// 12 random alphanumeric chars to fill the remaining bytes
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
let random = "";
const rand = randomBytes(12);
for (let i = 0; i < 12; i++) {
random += chars[rand[i] % chars.length];
}
return Buffer.from(`${prefix}${random}`, "ascii");
}
/** Generate a random 8-hex-char key for announces */
export function generateKey(): string {
return [...randomBytes(4)]
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
.toUpperCase();
}

View File

@ -0,0 +1,15 @@
import { type ClientProfile, generateKey, generatePeerId } from "../ClientProfile.js";
/**
* Mimics qBittorrent 4.5.2
* Peer ID format: -qB4520-<random12>
*/
export function createQbittorrentProfile(): ClientProfile {
return {
name: "qBittorrent 4.5.2",
idPrefix: "-qB4520-",
peerId: generatePeerId("-qB4520-"),
userAgent: "qBittorrent/4.5.2",
key: generateKey(),
};
}

View File

@ -0,0 +1,15 @@
import { type ClientProfile, generateKey, generatePeerId } from "../ClientProfile.js";
/**
* Mimics Transmission 3.00
* Peer ID format: -TR3000-<random12>
*/
export function createTransmissionProfile(): ClientProfile {
return {
name: "Transmission 3.00",
idPrefix: "-TR3000-",
peerId: generatePeerId("-TR3000-"),
userAgent: "Transmission/3.00",
key: generateKey(),
};
}

View File

@ -0,0 +1,248 @@
import { EventEmitter } from "node:events";
import type { Config } from "@config/Config";
import type { ClientProfile } from "../client/ClientProfile.js";
import type { TorrentFileInfo } from "../torrent/TorrentFile.js";
import { HttpTracker } from "../tracker/HttpTracker.js";
import type { ITracker } from "../tracker/ITracker.js";
import type { TrackerResponse } from "../tracker/TrackerResponse.js";
import { UdpTracker } from "../tracker/UdpTracker.js";
import { SpeedSimulator } from "./SpeedSimulator.js";
export type SeederStatus = "idle" | "running" | "stopping" | "stopped" | "error";
export interface SeederState {
infoHashHex: string;
name: string;
trackerUrl: string;
uploaded: number;
downloaded: number;
left: number;
status: SeederStatus;
lastAnnounce: Date | null;
lastInterval: number;
seeders: number;
leechers: number;
peers: number;
error: string | null;
}
export interface AnnounceRecord {
timestamp: Date;
trackerUrl: string;
uploaded: number;
seeders?: number;
leechers?: number;
peers: number;
event: string;
}
export class FakeSeeder extends EventEmitter {
// Typed on() overloads — avoids unsafe declaration merging
on(event: "announce", listener: (record: AnnounceRecord) => void): this;
on(event: "stopped", listener: () => void): this;
on(event: "stateChange", listener: (state: SeederState) => void): this;
on(event: string, listener: (...args: unknown[]) => void): this {
return super.on(event, listener);
}
private readonly torrent: TorrentFileInfo;
private readonly profile: ClientProfile;
private readonly config: Config;
private readonly trackers: ITracker[];
private readonly speedSim: SpeedSimulator;
private status: SeederStatus = "idle";
private uploaded = 0;
private readonly downloaded = 0;
private left: number;
private lastAnnounce: Date | null = null;
private lastInterval = 1800;
private lastSeeder = 0;
private lastLeecher = 0;
private lastPeers = 0;
private error: string | null = null;
private announceTimer: ReturnType<typeof setTimeout> | null = null;
private tickTimer: ReturnType<typeof setInterval> | null = null;
private lastTickTime = Date.now();
constructor(torrent: TorrentFileInfo, profile: ClientProfile, config: Config) {
super();
this.torrent = torrent;
this.profile = profile;
this.config = config;
this.left = 0; // We're a seeder — we have the full file
this.speedSim = new SpeedSimulator({
minUploadRateBytesPerSec: config.minUploadRateBytesPerSec,
maxUploadRateBytesPerSec: config.maxUploadRateBytesPerSec,
});
this.trackers = buildTrackers(torrent.announceList);
}
get infoHashHex(): string {
return this.torrent.infoHashHex;
}
getState(): SeederState {
return {
infoHashHex: this.torrent.infoHashHex,
name: this.torrent.name,
trackerUrl: this.torrent.announce,
uploaded: this.uploaded,
downloaded: this.downloaded,
left: this.left,
status: this.status,
lastAnnounce: this.lastAnnounce,
lastInterval: this.lastInterval,
seeders: this.lastSeeder,
leechers: this.lastLeecher,
peers: this.lastPeers,
error: this.error,
};
}
async start(): Promise<void> {
if (this.status === "running") return;
this.setStatus("running");
// Start speed tick
this.lastTickTime = Date.now();
this.tickTimer = setInterval(() => {
const now = Date.now();
const delta = now - this.lastTickTime;
this.lastTickTime = now;
this.uploaded += this.speedSim.tick(delta);
this.emit("stateChange", this.getState());
}, 5_000);
// First announce: started
await this.doAnnounce("started");
this.scheduleNextAnnounce();
}
async stop(): Promise<void> {
if (this.status === "stopped" || this.status === "stopping") return;
this.setStatus("stopping");
if (this.announceTimer) {
clearTimeout(this.announceTimer);
this.announceTimer = null;
}
if (this.tickTimer) {
clearInterval(this.tickTimer);
this.tickTimer = null;
}
// Best-effort stopped announce to all trackers
await Promise.allSettled(
this.trackers.map((t) =>
t.stop({
infoHash: this.torrent.infoHash,
peerId: this.profile.peerId,
port: this.config.announcePort,
uploaded: this.uploaded,
downloaded: this.downloaded,
left: this.left,
userAgent: this.profile.userAgent,
key: this.profile.key,
})
)
);
this.setStatus("stopped");
this.emit("stopped");
}
private scheduleNextAnnounce(): void {
const intervalMs = this.lastInterval * 1000;
this.announceTimer = setTimeout(async () => {
await this.doAnnounce("");
if (this.status === "running") {
this.scheduleNextAnnounce();
}
}, intervalMs);
}
private async doAnnounce(event: "started" | "stopped" | "completed" | ""): Promise<void> {
const params = {
infoHash: this.torrent.infoHash,
peerId: this.profile.peerId,
port: this.config.announcePort,
uploaded: this.uploaded,
downloaded: this.downloaded,
left: this.left,
event,
userAgent: this.profile.userAgent,
compact: true,
numWant: 50,
key: this.profile.key,
};
// Announce to all trackers, collect results
const results = await Promise.allSettled(this.trackers.map((t) => t.announce(params)));
let bestInterval = this.lastInterval;
for (let i = 0; i < results.length; i++) {
const result = results[i];
const tracker = this.trackers[i];
if (result.status === "fulfilled") {
const res: TrackerResponse = result.value;
this.lastAnnounce = new Date();
this.lastSeeder = res.seeders ?? this.lastSeeder;
this.lastLeecher = res.leechers ?? this.lastLeecher;
this.lastPeers = res.peers.length;
bestInterval = Math.min(bestInterval, res.interval);
this.error = null; // clear any previous error
const record: AnnounceRecord = {
timestamp: this.lastAnnounce,
trackerUrl: tracker.url,
uploaded: this.uploaded,
seeders: res.seeders,
leechers: res.leechers,
peers: res.peers.length,
event,
};
this.emit("announce", record);
} else {
this.error = (result.reason as Error).message;
// Fallback: scrape to at least get current seeder/leecher counts
if (tracker.scrape) {
tracker.scrape(this.torrent.infoHash).then((stats) => {
if (stats) {
this.lastSeeder = stats.seeders;
this.lastLeecher = stats.leechers;
this.emit("stateChange", this.getState());
}
}).catch(() => {});
}
}
}
this.lastInterval = bestInterval;
this.emit("stateChange", this.getState());
}
private setStatus(s: SeederStatus): void {
this.status = s;
this.emit("stateChange", this.getState());
}
}
function buildTrackers(urls: string[]): ITracker[] {
return urls
.map((url) => {
try {
if (url.startsWith("http://") || url.startsWith("https://")) {
return new HttpTracker(url);
}
if (url.startsWith("udp://")) {
return new UdpTracker(url);
}
} catch {
// skip malformed URLs
}
return null;
})
.filter((t): t is ITracker => t !== null);
}

View File

@ -0,0 +1,65 @@
/**
* Simulates realistic upload speeds with jitter.
*
* Trackers can become suspicious of perfectly constant upload rates.
* This module applies:
* - ±15% gaussian-ish noise on every tick
* - Occasional short burst (5% chance) of 1.52× the base rate
* - Occasional short stall (3% chance) of near-zero speed
*/
export interface SpeedSimulatorOptions {
/** Minimum upload speed in bytes/second */
minUploadRateBytesPerSec: number;
/** Maximum upload speed in bytes/second */
maxUploadRateBytesPerSec: number;
/** Noise factor (01). Default 0.15 = ±15% */
noiseFactor?: number;
}
export class SpeedSimulator {
private readonly baseRate: number;
private readonly noiseFactor: number;
private totalUploaded: number = 0;
constructor(options: SpeedSimulatorOptions) {
const { minUploadRateBytesPerSec: min, maxUploadRateBytesPerSec: max } = options;
// Each seeder gets its own fixed base rate within [min, max] — realistic variance
this.baseRate = min + Math.random() * (max - min);
this.noiseFactor = options.noiseFactor ?? 0.15;
}
/**
* Compute bytes uploaded during the given interval.
* @param intervalMs elapsed milliseconds since last tick
*/
tick(intervalMs: number): number {
const rand = Math.random();
let rate = this.baseRate;
if (rand < 0.03) {
// ~3% chance: near stall (010% of base rate)
rate = this.baseRate * Math.random() * 0.1;
} else if (rand < 0.08) {
// ~5% chance: burst (150200% of base rate)
rate = this.baseRate * (1.5 + Math.random() * 0.5);
} else {
// normal: base ± noise
const noise = (Math.random() * 2 - 1) * this.noiseFactor;
rate = this.baseRate * (1 + noise);
}
const bytes = Math.round((rate * intervalMs) / 1000);
this.totalUploaded += bytes;
return bytes;
}
get uploaded(): number {
return this.totalUploaded;
}
reset(): void {
this.totalUploaded = 0;
}
}

View File

@ -0,0 +1,91 @@
import { describe, expect, it } from "bun:test";
import { decode } from "../bencode/decoder.js";
import { type EncodableDict, encode } from "../bencode/encoder.js";
import { parseTorrentBuffer } from "./TorrentFile.js";
describe("bencode round-trip", () => {
it("encodes and decodes integers", () => {
const buf = encode(42);
expect(buf.toString("ascii")).toBe("i42e");
expect(decode(buf)).toBe(42);
});
it("encodes and decodes negative integers", () => {
const buf = encode(-7);
expect(buf.toString("ascii")).toBe("i-7e");
expect(decode(buf)).toBe(-7);
});
it("encodes and decodes strings", () => {
const buf = encode("spam");
expect(buf.toString("ascii")).toBe("4:spam");
const result = decode(buf) as Buffer;
expect(result.toString("utf8")).toBe("spam");
});
it("encodes and decodes lists", () => {
const buf = encode([1, "two"]);
expect(buf.toString("ascii")).toBe("li1e3:twoe");
const result = decode(buf) as [number, Buffer];
expect(result[0]).toBe(1);
expect((result[1] as Buffer).toString()).toBe("two");
});
it("encodes and decodes dicts", () => {
const buf = encode({ z: 1, a: "hello" });
// keys sorted lexicographically: a → "hello", z → 1
expect(buf.toString("ascii")).toBe("d1:a5:hello1:zi1ee");
// wait, z value is 1, not... let me just decode
const result = decode(buf) as Record<string, unknown>;
expect(result.z).toBe(1);
expect((result.a as Buffer).toString()).toBe("hello");
});
});
describe("parseTorrentBuffer", () => {
/** Build a minimal synthetic .torrent buffer */
function makeTorrent(overrides?: Record<string, unknown>) {
const infoPieces = Buffer.alloc(20); // 1 fake piece hash
const info: EncodableDict = {
name: Buffer.from("test-file.txt"),
length: 1024,
"piece length": 512,
pieces: infoPieces,
};
const torrent: EncodableDict = {
announce: Buffer.from("http://tracker.example.com/announce"),
info,
...(overrides as EncodableDict),
};
return encode(torrent);
}
it("parses name and size", () => {
const buf = makeTorrent();
const t = parseTorrentBuffer(buf);
expect(t.name).toBe("test-file.txt");
expect(t.totalSize).toBe(1024);
});
it("extracts announce URL", () => {
const buf = makeTorrent();
const t = parseTorrentBuffer(buf);
expect(t.announce).toBe("http://tracker.example.com/announce");
expect(t.announceList).toContain("http://tracker.example.com/announce");
});
it("produces a 20-byte info_hash", () => {
const buf = makeTorrent();
const t = parseTorrentBuffer(buf);
expect(t.infoHash.length).toBe(20);
expect(t.infoHashHex).toMatch(/^[0-9a-f]{40}$/);
});
it("single-file torrent has one file entry", () => {
const buf = makeTorrent();
const t = parseTorrentBuffer(buf);
expect(t.isMultiFile).toBe(false);
expect(t.files).toHaveLength(1);
expect(t.files[0].size).toBe(1024);
});
});

View File

@ -0,0 +1,103 @@
import { createHash } from "node:crypto";
import { type BencodeDict, type BencodeList, decode, rawSlice } from "../bencode/decoder.js";
export interface TorrentFileInfo {
/** 20-byte SHA-1 of the bencoded info dict */
infoHash: Buffer;
/** hex string representation of infoHash */
infoHashHex: string;
/** torrent display name */
name: string;
/** primary announce URL */
announce: string;
/** all tracker URLs (flattened from announce-list tiers) */
announceList: string[];
/** total size in bytes */
totalSize: number;
/** piece length in bytes */
pieceLength: number;
/** true if multi-file torrent */
isMultiFile: boolean;
/** file list (single-file torrents have one entry) */
files: TorrentFileEntry[];
}
export interface TorrentFileEntry {
path: string;
size: number;
}
export function parseTorrentBuffer(buf: Buffer): TorrentFileInfo {
const torrent = decode(buf) as BencodeDict;
// --- info_hash: SHA-1 of raw bencoded info dict ---
const infoRaw = rawSlice(buf, "info");
if (!infoRaw) throw new Error("Missing 'info' key in torrent file");
const infoHashHex = createHash("sha1").update(infoRaw).digest("hex");
const infoHash = Buffer.from(infoHashHex, "hex");
const info = torrent.info as BencodeDict;
if (!info) throw new Error("Missing 'info' dict");
const name = bufToStr(info.name as Buffer);
const pieceLength = info["piece length"] as number;
// --- tracker URLs ---
const announce = torrent.announce ? bufToStr(torrent.announce as Buffer) : "";
const announceList = flattenAnnounceList(torrent["announce-list"] as BencodeList | undefined);
if (announce && !announceList.includes(announce)) {
announceList.unshift(announce);
}
// --- file info ---
let files: TorrentFileEntry[];
let totalSize: number;
const isMultiFile = "files" in info;
if (isMultiFile) {
const fileList = info.files as BencodeList;
files = fileList.map((f) => {
const fd = f as BencodeDict;
const pathParts = (fd.path as BencodeList).map((p) => bufToStr(p as Buffer));
return {
path: pathParts.join("/"),
size: fd.length as number,
};
});
totalSize = files.reduce((sum, f) => sum + f.size, 0);
} else {
const length = info.length as number;
files = [{ path: name, size: length }];
totalSize = length;
}
return {
infoHash,
infoHashHex,
name,
announce,
announceList,
totalSize,
pieceLength,
isMultiFile,
files,
};
}
function bufToStr(buf: Buffer): string {
return Buffer.isBuffer(buf) ? buf.toString("utf8") : String(buf);
}
function flattenAnnounceList(announceList: BencodeList | undefined): string[] {
if (!announceList) return [];
const urls: string[] = [];
for (const tier of announceList) {
if (Array.isArray(tier)) {
for (const url of tier) {
const s = bufToStr(url as Buffer);
if (s && !urls.includes(s)) urls.push(s);
}
}
}
return urls;
}

View File

@ -0,0 +1,200 @@
import { type BencodeDict, type BencodeList, decode } from "../bencode/decoder.js";
import type { AnnounceParams, ITracker, ScrapeResult } from "./ITracker.js";
import type { TrackerPeer, TrackerResponse } from "./TrackerResponse.js";
/**
* HTTP/HTTPS tracker client.
*
* Key correctness concern: info_hash and peer_id are raw binary they must be
* percent-encoded byte-by-byte (not hex, not base64). We build the query string
* manually instead of using URLSearchParams to avoid double-encoding.
*/
export class HttpTracker implements ITracker {
readonly url: string;
private trackerId?: string;
private userAgent = "";
constructor(url: string) {
this.url = url;
}
async announce(params: AnnounceParams): Promise<TrackerResponse> {
this.userAgent = params.userAgent;
const qs = buildQueryString(params, this.trackerId);
const sep = this.url.includes("?") ? "&" : "?";
const fullUrl = `${this.url}${sep}${qs}`;
const res = await fetch(fullUrl, {
headers: {
"User-Agent": params.userAgent,
"Accept-Encoding": "gzip",
},
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) {
throw new Error(`HTTP tracker returned ${res.status} for ${this.url}`);
}
const body = Buffer.from(await res.arrayBuffer());
const decoded = decode(body) as BencodeDict;
if (decoded["failure reason"]) {
throw new Error(`Tracker failure: ${(decoded["failure reason"] as Buffer).toString("utf8")}`);
}
if (decoded["tracker id"]) {
this.trackerId = (decoded["tracker id"] as Buffer).toString("utf8");
}
return parseResponse(decoded);
}
async stop(params: Omit<AnnounceParams, "event">): Promise<void> {
try {
await this.announce({ ...params, event: "stopped" });
} catch {
// best-effort
}
}
/**
* Scrape the tracker for current seeder/leecher counts without announcing.
* Derives the scrape URL by replacing the last `/announce` path segment with `/scrape`.
* Returns null if the tracker doesn't support scrape or the request fails.
*/
async scrape(infoHash: Buffer): Promise<ScrapeResult | null> {
// Only supported if announce URL contains /announce in the path
const scrapeUrl = this.url.replace(/(\/announce)([^/]*)$/, "/scrape$2");
if (scrapeUrl === this.url) return null;
try {
const scrapeQSep = scrapeUrl.includes("?") ? "&" : "?";
const fullUrl = `${scrapeUrl}${scrapeQSep}info_hash=${percentEncode(infoHash)}`;
const res = await fetch(fullUrl, {
headers: { "User-Agent": this.userAgent || "Mozilla/5.0" },
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) return null;
const body = Buffer.from(await res.arrayBuffer());
const decoded = decode(body) as BencodeDict;
if (!decoded.files) return null;
const entries = Object.values(decoded.files as BencodeDict);
if (entries.length === 0) return null;
const entry = entries[0] as BencodeDict;
return {
seeders: (entry.complete as number) ?? 0,
leechers: (entry.incomplete as number) ?? 0,
completed: (entry.downloaded as number) ?? 0,
};
} catch {
return null;
}
}
}
function buildQueryString(params: AnnounceParams, trackerId?: string): string {
const parts: string[] = [
`info_hash=${percentEncode(params.infoHash)}`,
`peer_id=${percentEncode(params.peerId)}`,
`port=${params.port}`,
`uploaded=${params.uploaded}`,
`downloaded=${params.downloaded}`,
`left=${params.left}`,
`compact=${params.compact !== false ? 1 : 0}`,
`no_peer_id=1`,
];
if (params.event) {
parts.push(`event=${params.event}`);
}
if (params.numWant !== undefined) {
parts.push(`numwant=${params.numWant}`);
}
if (params.key) {
parts.push(`key=${encodeURIComponent(params.key)}`);
}
if (trackerId) {
parts.push(`trackerid=${encodeURIComponent(trackerId)}`);
}
return parts.join("&");
}
/**
* Percent-encode each byte of a Buffer individually.
* e.g. 0x1a %1A (uppercase hex, safe chars left as-is... but for binary data
* we encode everything non-alphanumeric to be safe).
*/
function percentEncode(buf: Buffer): string {
let result = "";
for (let i = 0; i < buf.length; i++) {
const byte = buf[i];
// safe chars: A-Z a-z 0-9 - _ . ~
if (
(byte >= 0x41 && byte <= 0x5a) || // A-Z
(byte >= 0x61 && byte <= 0x7a) || // a-z
(byte >= 0x30 && byte <= 0x39) || // 0-9
byte === 0x2d || // -
byte === 0x5f || // _
byte === 0x2e || // .
byte === 0x7e // ~
) {
result += String.fromCharCode(byte);
} else {
result += `%${byte.toString(16).padStart(2, "0").toUpperCase()}`;
}
}
return result;
}
function parseResponse(dict: BencodeDict): TrackerResponse {
const interval = (dict.interval as number) ?? 1800;
const minInterval = dict["min interval"] as number | undefined;
const seeders = dict.complete as number | undefined;
const leechers = dict.incomplete as number | undefined;
const warning = dict["warning message"]
? (dict["warning message"] as Buffer).toString("utf8")
: undefined;
const trackerId = dict["tracker id"]
? (dict["tracker id"] as Buffer).toString("utf8")
: undefined;
const peers = parsePeers(dict.peers);
return { interval, minInterval, seeders, leechers, peers, warning, trackerId };
}
function parsePeers(raw: unknown): TrackerPeer[] {
if (!raw) return [];
// Compact format: 6 bytes per peer (4 IP + 2 port)
if (Buffer.isBuffer(raw)) {
const peers: TrackerPeer[] = [];
for (let i = 0; i + 5 < raw.length; i += 6) {
const ip = `${raw[i]}.${raw[i + 1]}.${raw[i + 2]}.${raw[i + 3]}`;
const port = raw.readUInt16BE(i + 4);
peers.push({ ip, port });
}
return peers;
}
// Dict format (older trackers)
if (Array.isArray(raw)) {
return (raw as BencodeList).map((p) => {
const pd = p as BencodeDict;
return {
ip: (pd.ip as Buffer).toString("ascii"),
port: pd.port as number,
};
});
}
return [];
}

View File

@ -0,0 +1,33 @@
import type { AnnounceEvent, TrackerResponse } from "./TrackerResponse.js";
export interface AnnounceParams {
infoHash: Buffer;
peerId: Buffer;
port: number;
uploaded: number;
downloaded: number;
left: number;
event: AnnounceEvent;
/** HTTP User-Agent header — should match the spoofed client profile */
userAgent: string;
/** compact=1 for HTTP trackers */
compact?: boolean;
numWant?: number;
key?: string;
trackerId?: string;
}
export interface ScrapeResult {
seeders: number;
leechers: number;
completed: number;
}
export interface ITracker {
readonly url: string;
announce(params: AnnounceParams): Promise<TrackerResponse>;
/** Send stopped event and clean up resources */
stop(params: Omit<AnnounceParams, "event">): Promise<void>;
/** Fetch current seeder/leecher counts without announcing (optional) */
scrape?(infoHash: Buffer): Promise<ScrapeResult | null>;
}

View File

@ -0,0 +1,24 @@
export interface TrackerPeer {
ip: string;
port: number;
}
export interface TrackerResponse {
/** re-announce interval in seconds */
interval: number;
/** minimum re-announce interval (optional, from tracker) */
minInterval?: number;
seeders?: number;
leechers?: number;
peers: TrackerPeer[];
/** tracker warning message */
warning?: string;
/** tracker ID (HTTP only) */
trackerId?: string;
}
export interface TrackerError {
reason: string;
}
export type AnnounceEvent = "started" | "stopped" | "completed" | "";

View File

@ -0,0 +1,214 @@
import { createSocket, type Socket } from "node:dgram";
import type { AnnounceParams, ITracker } from "./ITracker.js";
import type { TrackerPeer, TrackerResponse } from "./TrackerResponse.js";
/**
* UDP tracker protocol (BEP 15)
* https://www.bittorrent.org/beps/bep_0015.html
*
* Flow:
* 1. Connect request action=0, magic connection_id
* 2. Connect response server returns connection_id (valid ~1 min)
* 3. Announce request action=1 with connection_id
* 4. Announce response peers, interval, seeders, leechers
*/
const CONNECT_MAGIC = BigInt("0x41727101980");
const CONNECT_ACTION = 0;
const ANNOUNCE_ACTION = 1;
const TIMEOUT_MS = 10_000;
const MAX_RETRIES = 3;
export class UdpTracker implements ITracker {
readonly url: string;
private readonly host: string;
private readonly port: number;
private connectionId: bigint | null = null;
private connectionExpiry = 0;
constructor(url: string) {
this.url = url;
const parsed = new URL(url);
this.host = parsed.hostname;
this.port = Number(parsed.port) || 80;
}
async announce(params: AnnounceParams): Promise<TrackerResponse> {
const socket = createSocket("udp4");
try {
const connId = await this.getConnectionId(socket);
return await this.sendAnnounce(socket, connId, params);
} finally {
socket.close();
}
}
async stop(params: Omit<AnnounceParams, "event">): Promise<void> {
try {
await this.announce({ ...params, event: "stopped" });
} catch {
// best-effort
}
}
private async getConnectionId(socket: Socket): Promise<bigint> {
if (this.connectionId && Date.now() < this.connectionExpiry) {
return this.connectionId;
}
const connId = await this.connect(socket);
this.connectionId = connId;
this.connectionExpiry = Date.now() + 60_000; // valid ~60s
return connId;
}
private connect(socket: Socket): Promise<bigint> {
return retry(MAX_RETRIES, async () => {
const transactionId = crypto.getRandomValues(new Uint32Array(1))[0];
const req = buildConnectRequest(transactionId);
const resp = await sendAndReceive(socket, req, this.host, this.port, TIMEOUT_MS);
return parseConnectResponse(resp, transactionId);
});
}
private sendAnnounce(
socket: Socket,
connectionId: bigint,
params: AnnounceParams
): Promise<TrackerResponse> {
return retry(MAX_RETRIES, async () => {
const transactionId = crypto.getRandomValues(new Uint32Array(1))[0];
const req = buildAnnounceRequest(connectionId, transactionId, params);
const resp = await sendAndReceive(socket, req, this.host, this.port, TIMEOUT_MS);
return parseAnnounceResponse(resp, transactionId);
});
}
}
// ─── Packet builders ──────────────────────────────────────────────────────────
function buildConnectRequest(transactionId: number): Buffer {
const buf = Buffer.allocUnsafe(16);
buf.writeBigUInt64BE(CONNECT_MAGIC, 0);
buf.writeUInt32BE(CONNECT_ACTION, 8);
buf.writeUInt32BE(transactionId, 12);
return buf;
}
function buildAnnounceRequest(
connectionId: bigint,
transactionId: number,
p: AnnounceParams
): Buffer {
const buf = Buffer.allocUnsafe(98);
buf.writeBigUInt64BE(connectionId, 0);
buf.writeUInt32BE(ANNOUNCE_ACTION, 8);
buf.writeUInt32BE(transactionId, 12);
p.infoHash.copy(buf, 16); // 20 bytes
p.peerId.copy(buf, 36); // 20 bytes
buf.writeBigUInt64BE(BigInt(p.downloaded), 56);
buf.writeBigUInt64BE(BigInt(p.left), 64);
buf.writeBigUInt64BE(BigInt(p.uploaded), 72);
buf.writeUInt32BE(eventToInt(p.event ?? ""), 80);
buf.writeUInt32BE(0, 84); // IP address (0 = default)
buf.writeUInt32BE(0, 88); // key (use 0 if not provided)
buf.writeInt32BE(p.numWant ?? -1, 92);
buf.writeUInt16BE(p.port, 96);
return buf;
}
function eventToInt(event: string): number {
switch (event) {
case "completed":
return 1;
case "started":
return 2;
case "stopped":
return 3;
default:
return 0;
}
}
// ─── Packet parsers ───────────────────────────────────────────────────────────
// Use Uint8Array + DataView for reads — avoids Buffer<ArrayBuffer> (NonSharedBuffer)
// type issues while being portable and spec-correct.
function parseConnectResponse(data: Uint8Array, expectedTxId: number): bigint {
if (data.length < 16) throw new Error("Connect response too short");
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
const action = view.getUint32(0);
const txId = view.getUint32(4);
if (action !== CONNECT_ACTION) throw new Error(`Expected action 0, got ${action}`);
if (txId !== expectedTxId) throw new Error("Transaction ID mismatch in connect response");
return view.getBigUint64(8);
}
function parseAnnounceResponse(data: Uint8Array, expectedTxId: number): TrackerResponse {
if (data.length < 20) throw new Error("Announce response too short");
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
const action = view.getUint32(0);
const txId = view.getUint32(4);
if (action !== ANNOUNCE_ACTION) throw new Error(`Expected action 1, got ${action}`);
if (txId !== expectedTxId) throw new Error("Transaction ID mismatch in announce response");
const interval = view.getUint32(8);
const leechers = view.getUint32(12);
const seeders = view.getUint32(16);
const peers: TrackerPeer[] = [];
for (let i = 20; i + 5 < data.length; i += 6) {
const ip = `${data[i]}.${data[i + 1]}.${data[i + 2]}.${data[i + 3]}`;
const port = view.getUint16(i + 4);
peers.push({ ip, port });
}
return { interval, leechers, seeders, peers };
}
// ─── UDP helpers ──────────────────────────────────────────────────────────────
function sendAndReceive(
socket: Socket,
msg: Buffer,
host: string,
port: number,
timeoutMs: number
): Promise<Uint8Array> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`UDP timeout after ${timeoutMs}ms`));
}, timeoutMs);
socket.once("message", (data) => {
clearTimeout(timer);
resolve(data); // Buffer<ArrayBuffer> extends Uint8Array — no copy needed
});
socket.once("error", (err) => {
clearTimeout(timer);
reject(err);
});
socket.send(msg, port, host, (err) => {
if (err) {
clearTimeout(timer);
reject(err);
}
});
});
}
async function retry<T>(times: number, fn: () => Promise<T>): Promise<T> {
let lastErr: Error = new Error("No attempts made");
for (let i = 0; i < times; i++) {
try {
return await fn();
} catch (e) {
lastErr = e as Error;
}
}
throw lastErr;
}

44
src/index.ts Normal file
View File

@ -0,0 +1,44 @@
import { readdir } from "node:fs/promises";
import { join } from "node:path";
import { SeederRegistry } from "@api/SeederRegistry";
import { startServer } from "@api/server";
import { loadConfig } from "@config/Config";
const config = loadConfig();
const registry = new SeederRegistry(config);
// Autoload .torrent files from torrentsDir on startup
if (config.autoLoad) {
try {
const files = await readdir(config.torrentsDir);
const torrents = files.filter((f) => f.endsWith(".torrent"));
if (torrents.length > 0) {
console.log(`Loading ${torrents.length} torrent(s) from ${config.torrentsDir}`);
for (const file of torrents) {
const path = join(config.torrentsDir, file);
const buf = Buffer.from(await Bun.file(path).arrayBuffer());
try {
const state = await registry.addTorrent(buf);
console.log(`${state.name} [${state.infoHashHex.slice(0, 8)}]`);
} catch (err) {
console.warn(` ✗ Failed to load ${file}: ${(err as Error).message}`);
}
}
}
} catch {
// torrentsDir doesn't exist yet — that's fine
}
}
// Graceful shutdown
async function shutdown() {
console.log("\nShutting down — sending stopped announces…");
await registry.stopAll();
process.exit(0);
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
startServer(registry, config, config.port);

0
torrents/.gitkeep Normal file
View File

20
tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["ESNext"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["bun-types"],
"baseUrl": ".",
"paths": {
"@core/*": ["src/core/*"],
"@api/*": ["src/api/*"],
"@config/*": ["src/config/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "ui"]
}

159
ui/bun.lock Normal file
View File

@ -0,0 +1,159 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "torrent-faker-ui",
"dependencies": {
"svelte": "^5.55.0",
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"vite": "^8.0.3",
},
},
},
"packages": {
"@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
"@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.2", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw=="],
"@oxc-project/types": ["@oxc-project/types@0.122.0", "", {}, "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.12", "", { "os": "android", "cpu": "arm64" }, "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12", "", { "os": "linux", "cpu": "arm" }, "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "x64" }, "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.12", "", { "os": "linux", "cpu": "x64" }, "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.12", "", { "os": "none", "cpu": "arm64" }, "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.12", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.12", "", { "os": "win32", "cpu": "x64" }, "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.12", "", {}, "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw=="],
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.9", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA=="],
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@7.0.0", "", { "dependencies": { "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.2" }, "peerDependencies": { "svelte": "^5.46.4", "vite": "^8.0.0-beta.7 || ^8.0.0" } }, "sha512-ILXmxC7HAsnkK2eslgPetrqqW1BKSL7LktsFgqzNj83MaivMGZzluWq32m25j2mDOjmSKX7GGWahePhuEs7P/g=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.57.2", "", {}, "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"devalue": ["devalue@5.6.4", "", {}, "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA=="],
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
"esrap": ["esrap@2.2.4", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15", "@typescript-eslint/types": "^8.2.0" } }, "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
"rolldown": ["rolldown@1.0.0-rc.12", "", { "dependencies": { "@oxc-project/types": "=0.122.0", "@rolldown/pluginutils": "1.0.0-rc.12" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-x64": "1.0.0-rc.12", "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"svelte": ["svelte@5.55.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.2", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-SThllKq6TRMBwPtat7ASnm/9CDXnIhBR0NPGw0ujn2DVYx9rVwsPZxDaDQcYGdUz/3BYVsCzdq7pZarRQoGvtw=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"vite": ["vite@8.0.3", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.12", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ=="],
"vitefu": ["vitefu@1.1.2", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw=="],
"zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
}
}

12
ui/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Torrent Faker</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

18
ui/package.json Normal file
View File

@ -0,0 +1,18 @@
{
"name": "torrent-faker-ui",
"version": "0.1.0",
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"vite": "^8.0.3"
},
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"type": "module",
"dependencies": {
"svelte": "^5.55.0"
}
}

207
ui/src/App.svelte Normal file
View File

@ -0,0 +1,207 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { listTorrents, addTorrent, connectSSE } from "./lib/api.js";
import type { TorrentState, GlobalStats } from "./lib/api.js";
import StatsPanel from "./components/StatsPanel.svelte";
import TorrentList from "./components/TorrentList.svelte";
import ConfigModal from "./components/ConfigModal.svelte";
let torrents: TorrentState[] = [];
let stats: GlobalStats | null = null;
let es: EventSource | null = null;
let uploading = false;
let uploadError = "";
let dragOver = false;
let showConfig = false;
onMount(async () => {
torrents = await listTorrents();
es = connectSSE(
(updated) => {
const idx = torrents.findIndex((t) => t.infoHashHex === updated.infoHashHex);
if (idx >= 0) {
torrents = [...torrents.slice(0, idx), updated, ...torrents.slice(idx + 1)];
}
},
(s) => { stats = s; },
(all) => { torrents = all; }
);
});
onDestroy(() => es?.close());
async function handleFiles(files: FileList | null) {
if (!files || files.length === 0) return;
uploading = true;
uploadError = "";
try {
for (const file of Array.from(files)) {
if (!file.name.endsWith(".torrent")) continue;
const state = await addTorrent(file);
if (!torrents.find((t) => t.infoHashHex === state.infoHashHex)) {
torrents = [...torrents, state];
}
}
} catch (e) {
uploadError = (e as Error).message;
} finally {
uploading = false;
}
}
function onDrop(e: DragEvent) {
e.preventDefault();
dragOver = false;
handleFiles(e.dataTransfer?.files ?? null);
}
function onFileInput(e: Event) {
handleFiles((e.target as HTMLInputElement).files);
}
</script>
<main>
<header>
<div>
<h1>Torrent Faker</h1>
<p class="subtitle">Fake seeding stats reporter</p>
</div>
<button class="settings-btn" on:click={() => showConfig = true} aria-label="Settings" title="Settings">
</button>
</header>
{#if showConfig}
<ConfigModal on:close={() => showConfig = false} />
{/if}
<StatsPanel {stats} />
<!-- Drop zone -->
<div
class="dropzone"
class:active={dragOver}
on:dragover|preventDefault={() => { dragOver = true; }}
on:dragleave={() => { dragOver = false; }}
on:drop={onDrop}
role="button"
tabindex="0"
on:keydown={(e) => e.key === "Enter" && document.getElementById("file-input")?.click()}
aria-label="Drop torrent file or click to browse"
>
{#if uploading}
<span>Adding torrent…</span>
{:else}
<span>Drop <code>.torrent</code> files here or <label class="browse" for="file-input">browse</label></span>
<input id="file-input" type="file" accept=".torrent" multiple on:change={onFileInput} />
{/if}
</div>
{#if uploadError}
<p class="error">{uploadError}</p>
{/if}
<section>
<TorrentList bind:torrents />
</section>
</main>
<style>
:global(*, *::before, *::after) {
box-sizing: border-box;
}
:global(body) {
margin: 0;
font-family: system-ui, -apple-system, sans-serif;
background: #0f0f1a;
color: #d0d0f0;
min-height: 100vh;
}
main {
max-width: 1100px;
margin: 0 auto;
padding: 2rem 1.5rem;
}
header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 1.5rem;
}
.settings-btn {
background: #1e1e38;
border: 1px solid #3a3a5a;
color: #8888aa;
font-size: 1.2rem;
width: 2.2rem;
height: 2.2rem;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: color 0.15s, border-color 0.15s;
}
.settings-btn:hover {
color: #a78bfa;
border-color: #a78bfa;
}
h1 {
margin: 0;
font-size: 1.8rem;
color: #a78bfa;
}
.subtitle {
margin: 0.25rem 0 0;
color: #8888aa;
font-size: 0.9rem;
}
.dropzone {
border: 2px dashed #3a3a5a;
border-radius: 8px;
padding: 1.5rem;
text-align: center;
color: #8888aa;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
margin-bottom: 1.5rem;
user-select: none;
}
.dropzone.active {
border-color: #a78bfa;
background: #a78bfa11;
color: #a78bfa;
}
.dropzone input[type="file"] {
display: none;
}
.browse {
color: #a78bfa;
cursor: pointer;
text-decoration: underline;
}
.error {
color: #f87171;
font-size: 0.875rem;
margin: -1rem 0 1rem;
}
section {
background: #13132a;
border-radius: 8px;
overflow: hidden;
}
</style>

View File

@ -0,0 +1,300 @@
<script lang="ts">
import { onMount, createEventDispatcher } from "svelte";
import { getConfig, updateConfig } from "../lib/api.js";
import type { AppConfig } from "../lib/api.js";
const dispatch = createEventDispatcher<{ close: void; saved: AppConfig }>();
let config: AppConfig | null = null;
let saving = false;
let error = "";
// Form fields (KB/s for display, bytes internally)
let minKBs = 512;
let maxKBs = 2048;
let clientProfile: "qbittorrent" | "transmission" = "qbittorrent";
let announcePort = 6881;
onMount(async () => {
try {
config = await getConfig();
minKBs = Math.round(config.minUploadRateBytesPerSec / 1024);
maxKBs = Math.round(config.maxUploadRateBytesPerSec / 1024);
clientProfile = config.clientProfile;
announcePort = config.announcePort;
} catch (e) {
error = "Failed to load config";
}
});
async function save() {
error = "";
if (minKBs <= 0 || maxKBs <= 0) {
error = "Speed values must be positive";
return;
}
if (minKBs > maxKBs) {
error = "Min speed must be ≤ max speed";
return;
}
saving = true;
try {
const updated = await updateConfig({
minUploadRateBytesPerSec: minKBs * 1024,
maxUploadRateBytesPerSec: maxKBs * 1024,
clientProfile,
announcePort,
});
dispatch("saved", updated);
dispatch("close");
} catch (e) {
error = (e as Error).message;
} finally {
saving = false;
}
}
function onBackdrop(e: MouseEvent) {
if (e.target === e.currentTarget) dispatch("close");
}
function onKeydown(e: KeyboardEvent) {
if (e.key === "Escape") dispatch("close");
}
</script>
<svelte:window on:keydown={onKeydown} />
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="backdrop" on:click={onBackdrop} role="dialog" aria-modal="true" aria-label="Settings">
<div class="modal">
<header>
<h2>Settings</h2>
<button class="close" on:click={() => dispatch("close")} aria-label="Close"></button>
</header>
{#if !config}
<p class="loading">Loading…</p>
{:else}
<form on:submit|preventDefault={save}>
<fieldset>
<legend>Upload Speed</legend>
<p class="hint">
Each torrent picks a random base rate in this range on start.<br>
Changes apply to newly added torrents.
</p>
<div class="row">
<label for="min-speed">Min (KB/s)</label>
<input
id="min-speed"
type="number"
min="1"
max={maxKBs}
bind:value={minKBs}
/>
</div>
<div class="row">
<label for="max-speed">Max (KB/s)</label>
<input
id="max-speed"
type="number"
min={minKBs}
bind:value={maxKBs}
/>
</div>
<p class="range-display">
{minKBs} {maxKBs} KB/s
({(minKBs / 1024).toFixed(1)} {(maxKBs / 1024).toFixed(1)} MB/s)
</p>
</fieldset>
<fieldset>
<legend>Client</legend>
<div class="row">
<label for="client-profile">Impersonate</label>
<select id="client-profile" bind:value={clientProfile}>
<option value="qbittorrent">qBittorrent</option>
<option value="transmission">Transmission</option>
</select>
</div>
<div class="row">
<label for="announce-port">Announce Port</label>
<input
id="announce-port"
type="number"
min="1"
max="65535"
bind:value={announcePort}
/>
</div>
</fieldset>
{#if error}
<p class="error">{error}</p>
{/if}
<div class="actions">
<button type="button" class="btn-secondary" on:click={() => dispatch("close")}>
Cancel
</button>
<button type="submit" class="btn-primary" disabled={saving}>
{saving ? "Saving…" : "Save"}
</button>
</div>
</form>
{/if}
</div>
</div>
<style>
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal {
background: #1a1a2e;
border: 1px solid #2a2a4a;
border-radius: 10px;
width: 100%;
max-width: 420px;
padding: 1.5rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.25rem;
}
h2 {
margin: 0;
font-size: 1.1rem;
color: #a78bfa;
}
.close {
background: none;
border: none;
color: #8888aa;
font-size: 1rem;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 4px;
line-height: 1;
}
.close:hover { color: #d0d0f0; }
fieldset {
border: 1px solid #2a2a4a;
border-radius: 6px;
padding: 1rem;
margin: 0 0 1rem;
}
legend {
color: #8888aa;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0 0.5rem;
}
.hint {
font-size: 0.8rem;
color: #6666aa;
margin: 0 0 0.75rem;
line-height: 1.4;
}
.row {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.6rem;
}
label {
font-size: 0.875rem;
color: #9999bb;
width: 120px;
flex-shrink: 0;
}
input[type="number"],
select {
flex: 1;
background: #0f0f1a;
border: 1px solid #3a3a5a;
border-radius: 5px;
color: #d0d0f0;
padding: 0.4rem 0.6rem;
font-size: 0.9rem;
}
input[type="number"]:focus,
select:focus {
outline: none;
border-color: #a78bfa;
}
.range-display {
font-size: 0.8rem;
color: #a78bfa;
margin: 0.5rem 0 0;
text-align: right;
}
.error {
color: #f87171;
font-size: 0.85rem;
margin: 0 0 0.75rem;
}
.loading {
color: #8888aa;
text-align: center;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 0.25rem;
}
.btn-primary,
.btn-secondary {
padding: 0.5rem 1.25rem;
border-radius: 6px;
font-size: 0.9rem;
cursor: pointer;
border: none;
}
.btn-primary {
background: #7c3aed;
color: #fff;
}
.btn-primary:hover:not(:disabled) { background: #6d28d9; }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-secondary {
background: #2a2a4a;
color: #d0d0f0;
}
.btn-secondary:hover { background: #3a3a5a; }
</style>

View File

@ -0,0 +1,56 @@
<script lang="ts">
import { formatBytes, formatUptime } from "../lib/api.js";
import type { GlobalStats } from "../lib/api.js";
export let stats: GlobalStats | null = null;
</script>
<div class="stats-panel">
<div class="stat">
<span class="label">Active Seeders</span>
<span class="value">{stats?.activeSeeders ?? "—"}</span>
</div>
<div class="stat">
<span class="label">Total Torrents</span>
<span class="value">{stats?.totalTorrents ?? "—"}</span>
</div>
<div class="stat">
<span class="label">Total Uploaded</span>
<span class="value">{stats ? formatBytes(stats.totalUploaded) : "—"}</span>
</div>
<div class="stat">
<span class="label">Uptime</span>
<span class="value">{stats ? formatUptime(stats.uptimeSeconds) : "—"}</span>
</div>
</div>
<style>
.stats-panel {
display: flex;
gap: 2rem;
padding: 1rem 1.5rem;
background: #1a1a2e;
border-radius: 8px;
margin-bottom: 1.5rem;
}
.stat {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.label {
font-size: 0.75rem;
color: #8888aa;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.value {
font-size: 1.4rem;
font-weight: 600;
color: #e0e0ff;
font-variant-numeric: tabular-nums;
}
</style>

View File

@ -0,0 +1,158 @@
<script lang="ts">
import { formatBytes } from "../lib/api.js";
import { removeTorrent } from "../lib/api.js";
import type { TorrentState } from "../lib/api.js";
export let torrents: TorrentState[] = [];
let removing = new Set<string>();
async function handleRemove(hash: string) {
removing = new Set([...removing, hash]);
try {
await removeTorrent(hash);
torrents = torrents.filter((t) => t.infoHashHex !== hash);
} finally {
removing.delete(hash);
removing = removing;
}
}
function statusColor(status: TorrentState["status"]): string {
switch (status) {
case "running": return "#4ade80";
case "stopping": return "#facc15";
case "stopped": return "#94a3b8";
case "error": return "#f87171";
default: return "#94a3b8";
}
}
</script>
{#if torrents.length === 0}
<p class="empty">No active torrents. Drop a .torrent file above to start seeding.</p>
{:else}
<table>
<thead>
<tr>
<th>Name</th>
<th>Tracker</th>
<th>Hash</th>
<th>Status</th>
<th>Uploaded</th>
<th>Seeders</th>
<th>Leechers</th>
<th>Last Announce</th>
<th></th>
</tr>
</thead>
<tbody>
{#each torrents as t (t.infoHashHex)}
<tr>
<td class="name" title={t.name}>{t.name}</td>
<td class="hash" title={t.trackerUrl}>{new URL(t.trackerUrl).hostname}</td>
<td class="hash" title={t.infoHashHex}>{t.infoHashHex.slice(0, 8)}…</td>
<td>
<span class="badge" style="color: {statusColor(t.status)}">{t.status}</span>
{#if t.error}<span class="err-icon" title={t.error}>⚠</span>{/if}
</td>
<td class="num">{formatBytes(t.uploaded)}</td>
<td class="num">{t.seeders ?? "?"}</td>
<td class="num">{t.leechers ?? "?"}</td>
<td class="time">
{t.lastAnnounce ? new Date(t.lastAnnounce).toLocaleTimeString() : "—"}
</td>
<td>
<button
class="remove"
disabled={removing.has(t.infoHashHex)}
on:click={() => handleRemove(t.infoHashHex)}
>
{removing.has(t.infoHashHex) ? "…" : "✕"}
</button>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
<style>
.empty {
color: #8888aa;
text-align: center;
padding: 2rem;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
th {
text-align: left;
padding: 0.5rem 0.75rem;
color: #8888aa;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid #2a2a4a;
}
td {
padding: 0.6rem 0.75rem;
border-bottom: 1px solid #1a1a2e;
color: #d0d0f0;
}
td.name {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
td.hash, td.time {
font-family: monospace;
font-size: 0.8rem;
color: #8888aa;
}
td.num {
font-variant-numeric: tabular-nums;
}
.badge {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.err-icon {
margin-left: 0.4rem;
color: #f87171;
font-size: 0.8rem;
cursor: help;
}
button.remove {
background: none;
border: 1px solid #f87171;
color: #f87171;
border-radius: 4px;
cursor: pointer;
padding: 0.2rem 0.5rem;
font-size: 0.75rem;
transition: background 0.15s;
}
button.remove:hover {
background: #f8717122;
}
button.remove:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

115
ui/src/lib/api.ts Normal file
View File

@ -0,0 +1,115 @@
const BASE = "/api";
export interface TorrentState {
infoHashHex: string;
name: string;
trackerUrl: string;
uploaded: number;
downloaded: number;
left: number;
status: "idle" | "running" | "stopping" | "stopped" | "error";
lastAnnounce: string | null;
lastInterval: number;
seeders: number;
leechers: number;
peers: number;
error: string | null;
addedAt: string;
}
export interface GlobalStats {
activeSeeders: number;
totalTorrents: number;
totalUploaded: number;
uptimeSeconds: number;
startedAt: string;
}
export async function listTorrents(): Promise<TorrentState[]> {
const res = await fetch(`${BASE}/torrents`);
return res.json();
}
export async function addTorrent(file: File): Promise<TorrentState> {
const form = new FormData();
form.append("torrent", file);
const res = await fetch(`${BASE}/torrents`, { method: "POST", body: form });
if (!res.ok) {
const err = await res.json() as { error: string };
throw new Error(err.error);
}
return res.json();
}
export async function removeTorrent(hash: string): Promise<void> {
await fetch(`${BASE}/torrents/${hash}`, { method: "DELETE" });
}
export interface AppConfig {
port: number;
announcePort: number;
minUploadRateBytesPerSec: number;
maxUploadRateBytesPerSec: number;
clientProfile: "qbittorrent" | "transmission";
torrentsDir: string;
autoLoad: boolean;
}
export async function getConfig(): Promise<AppConfig> {
const res = await fetch(`${BASE}/config`);
return res.json();
}
export async function updateConfig(patch: Partial<Pick<AppConfig, "minUploadRateBytesPerSec" | "maxUploadRateBytesPerSec" | "clientProfile" | "announcePort">>): Promise<AppConfig> {
const res = await fetch(`${BASE}/config`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
if (!res.ok) {
const err = await res.json() as { error: string };
throw new Error(typeof err.error === "string" ? err.error : JSON.stringify(err.error));
}
return res.json();
}
export async function getStats(): Promise<GlobalStats> {
const res = await fetch(`${BASE}/status`);
return res.json();
}
export function connectSSE(
onTorrent: (state: TorrentState) => void,
onStats: (stats: GlobalStats) => void,
onTorrents: (torrents: TorrentState[]) => void,
): EventSource {
const es = new EventSource(`${BASE}/status/stream`);
es.addEventListener("torrent", (e) => {
onTorrent(JSON.parse(e.data));
});
es.addEventListener("stats", (e) => {
onStats(JSON.parse(e.data));
});
es.addEventListener("torrents", (e) => {
onTorrents(JSON.parse(e.data));
});
return es;
}
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
}
export function formatUptime(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
return `${h}h ${m}m ${s}s`;
}

8
ui/src/main.ts Normal file
View File

@ -0,0 +1,8 @@
import { mount } from "svelte";
import App from "./App.svelte";
const app = mount(App, {
target: document.getElementById("app")!,
});
export default app;

5
ui/svelte.config.js Normal file
View File

@ -0,0 +1,5 @@
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
export default {
preprocess: vitePreprocess(),
};

14
ui/vite.config.ts Normal file
View File

@ -0,0 +1,14 @@
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
export default defineConfig({
plugins: [svelte()],
server: {
proxy: {
"/api": "http://localhost:3000",
},
},
build: {
outDir: "dist",
},
});