From c131b84819765126ec6767db739c80989643f461 Mon Sep 17 00:00:00 2001 From: Julien Quiaios Date: Sun, 29 Mar 2026 20:24:32 -0400 Subject: [PATCH] feat: Initial commit --- .gitignore | 8 + CLAUDE.md | 152 ++++++++++++ Dockerfile | 33 +++ README.md | 162 ++++++++++++ biome.json | 28 +++ bun.lock | 59 +++++ config/config.yml | 7 + docker-compose.yml | 16 ++ package.json | 27 ++ src/api/SeederRegistry.ts | 102 ++++++++ src/api/response.ts | 35 +++ src/api/routes/config.ts | 65 +++++ src/api/routes/status.ts | 74 ++++++ src/api/routes/torrents.ts | 62 +++++ src/api/server.ts | 46 ++++ src/cli/index.ts | 159 ++++++++++++ src/config/Config.ts | 51 ++++ src/config/config.default.yml | 23 ++ src/core/bencode/decoder.ts | 139 +++++++++++ src/core/bencode/encoder.ts | 57 +++++ src/core/client/ClientProfile.ts | 42 ++++ src/core/client/profiles/qbittorrent.ts | 15 ++ src/core/client/profiles/transmission.ts | 15 ++ src/core/seeder/FakeSeeder.ts | 248 +++++++++++++++++++ src/core/seeder/SpeedSimulator.ts | 65 +++++ src/core/torrent/TorrentFile.test.ts | 91 +++++++ src/core/torrent/TorrentFile.ts | 103 ++++++++ src/core/tracker/HttpTracker.ts | 200 +++++++++++++++ src/core/tracker/ITracker.ts | 33 +++ src/core/tracker/TrackerResponse.ts | 24 ++ src/core/tracker/UdpTracker.ts | 214 ++++++++++++++++ src/index.ts | 44 ++++ torrents/.gitkeep | 0 tsconfig.json | 20 ++ ui/bun.lock | 159 ++++++++++++ ui/index.html | 12 + ui/package.json | 18 ++ ui/src/App.svelte | 207 ++++++++++++++++ ui/src/components/ConfigModal.svelte | 300 +++++++++++++++++++++++ ui/src/components/StatsPanel.svelte | 56 +++++ ui/src/components/TorrentList.svelte | 158 ++++++++++++ ui/src/lib/api.ts | 115 +++++++++ ui/src/main.ts | 8 + ui/svelte.config.js | 5 + ui/vite.config.ts | 14 ++ 45 files changed, 3471 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 biome.json create mode 100644 bun.lock create mode 100644 config/config.yml create mode 100644 docker-compose.yml create mode 100644 package.json create mode 100644 src/api/SeederRegistry.ts create mode 100644 src/api/response.ts create mode 100644 src/api/routes/config.ts create mode 100644 src/api/routes/status.ts create mode 100644 src/api/routes/torrents.ts create mode 100644 src/api/server.ts create mode 100644 src/cli/index.ts create mode 100644 src/config/Config.ts create mode 100644 src/config/config.default.yml create mode 100644 src/core/bencode/decoder.ts create mode 100644 src/core/bencode/encoder.ts create mode 100644 src/core/client/ClientProfile.ts create mode 100644 src/core/client/profiles/qbittorrent.ts create mode 100644 src/core/client/profiles/transmission.ts create mode 100644 src/core/seeder/FakeSeeder.ts create mode 100644 src/core/seeder/SpeedSimulator.ts create mode 100644 src/core/torrent/TorrentFile.test.ts create mode 100644 src/core/torrent/TorrentFile.ts create mode 100644 src/core/tracker/HttpTracker.ts create mode 100644 src/core/tracker/ITracker.ts create mode 100644 src/core/tracker/TrackerResponse.ts create mode 100644 src/core/tracker/UdpTracker.ts create mode 100644 src/index.ts create mode 100644 torrents/.gitkeep create mode 100644 tsconfig.json create mode 100644 ui/bun.lock create mode 100644 ui/index.html create mode 100644 ui/package.json create mode 100644 ui/src/App.svelte create mode 100644 ui/src/components/ConfigModal.svelte create mode 100644 ui/src/components/StatsPanel.svelte create mode 100644 ui/src/components/TorrentList.svelte create mode 100644 ui/src/lib/api.ts create mode 100644 ui/src/main.ts create mode 100644 ui/svelte.config.js create mode 100644 ui/vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..56aac5a --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +ui/node_modules/ +ui/dist/ +dist/ +.env +*.log + +torrents/* \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e30cd82 --- /dev/null +++ b/CLAUDE.md @@ -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 # 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 # Start fake-seeding +bun run cli list # List active torrents +bun run cli remove # 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). diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2180ed6 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..0f88659 --- /dev/null +++ b/README.md @@ -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 . + +### 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 150–200% of base rate +- 3% chance: stall to 0–10% of base rate + +This produces upload curves that resemble real client behavior. diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..174d0d3 --- /dev/null +++ b/biome.json @@ -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" + } + } +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..d995497 --- /dev/null +++ b/bun.lock @@ -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=="], + } +} diff --git a/config/config.yml b/config/config.yml new file mode 100644 index 0000000..05ff582 --- /dev/null +++ b/config/config.yml @@ -0,0 +1,7 @@ +port: 3000 +announcePort: 6881 +minUploadRateBytesPerSec: 2048000 +maxUploadRateBytesPerSec: 6144000 +clientProfile: qbittorrent +torrentsDir: ./torrents +autoLoad: true diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a5f1033 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/package.json b/package.json new file mode 100644 index 0000000..1d3a7ab --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/api/SeederRegistry.ts b/src/api/SeederRegistry.ts new file mode 100644 index 0000000..37d5955 --- /dev/null +++ b/src/api/SeederRegistry.ts @@ -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(); + private readonly config: Config; + private readonly changeListeners = new Set(); + 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 { + 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 { + const entry = this.seeders.get(infoHashHex); + if (!entry) return false; + await entry.seeder.stop(); + this.seeders.delete(infoHashHex); + return true; + } + + listTorrents(): Array { + 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 { + await Promise.allSettled(Array.from(this.seeders.values()).map((e) => e.seeder.stop())); + this.seeders.clear(); + } +} diff --git a/src/api/response.ts b/src/api/response.ts new file mode 100644 index 0000000..6c67983 --- /dev/null +++ b/src/api/response.ts @@ -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(data, 201 as const)`. + * No inline type parameters, no magic numbers. + */ +export const res = { + ok: (c: Context, data: T) => c.json(data, Status.OK), + + created: (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), +}; diff --git a/src/api/routes/config.ts b/src/api/routes/config.ts new file mode 100644 index 0000000..fab4f1a --- /dev/null +++ b/src/api/routes/config.ts @@ -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; +} diff --git a/src/api/routes/status.ts b/src/api/routes/status.ts new file mode 100644 index 0000000..e3c4b7a --- /dev/null +++ b/src/api/routes/status.ts @@ -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((resolve) => { + stream.onAbort(() => resolve()); + }); + }); + }); + + return app; +} diff --git a/src/api/routes/torrents.ts b/src/api/routes/torrents.ts new file mode 100644 index 0000000..766de03 --- /dev/null +++ b/src/api/routes/torrents.ts @@ -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; +} diff --git a/src/api/server.ts b/src/api/server.ts new file mode 100644 index 0000000..8af6b81 --- /dev/null +++ b/src/api/server.ts @@ -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}`); +} diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100644 index 0000000..616d37c --- /dev/null +++ b/src/cli/index.ts @@ -0,0 +1,159 @@ +#!/usr/bin/env bun +/** + * CLI for Torrent Faker + * + * Commands: + * add — Add and start seeding a torrent + * list — List all active torrents + * remove — 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 "); + 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; + 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>; + + 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 "); + 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; + + 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 [options] + +Commands: + add Start fake-seeding a torrent + list List all active torrents + remove 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); +}); diff --git a/src/config/Config.ts b/src/config/Config.ts new file mode 100644 index 0000000..44d67f7 --- /dev/null +++ b/src/config/Config.ts @@ -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; + +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; +} diff --git a/src/config/config.default.yml b/src/config/config.default.yml new file mode 100644 index 0000000..b599cc9 --- /dev/null +++ b/src/config/config.default.yml @@ -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 diff --git a/src/core/bencode/decoder.ts b/src/core/bencode/decoder.ts new file mode 100644 index 0000000..e00da1a --- /dev/null +++ b/src/core/bencode/decoder.ts @@ -0,0 +1,139 @@ +/** + * Bencode decoder + * Spec: https://wiki.theory.org/BitTorrentSpecification#Bencoding + * + * Types: + * integers → ie e.g. i42e + * strings → : e.g. 4:spam + * lists → le + * dicts → d...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: ie + 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; +} diff --git a/src/core/bencode/encoder.ts b/src/core/bencode/encoder.ts new file mode 100644 index 0000000..5efc186 --- /dev/null +++ b/src/core/bencode/encoder.ts @@ -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); +} diff --git a/src/core/client/ClientProfile.ts b/src/core/client/ClientProfile.ts new file mode 100644 index 0000000..372ef80 --- /dev/null +++ b/src/core/client/ClientProfile.ts @@ -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: + * --<12 random bytes as printable chars> + * + * Example: -qB4520- + */ +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(); +} diff --git a/src/core/client/profiles/qbittorrent.ts b/src/core/client/profiles/qbittorrent.ts new file mode 100644 index 0000000..3959ebf --- /dev/null +++ b/src/core/client/profiles/qbittorrent.ts @@ -0,0 +1,15 @@ +import { type ClientProfile, generateKey, generatePeerId } from "../ClientProfile.js"; + +/** + * Mimics qBittorrent 4.5.2 + * Peer ID format: -qB4520- + */ +export function createQbittorrentProfile(): ClientProfile { + return { + name: "qBittorrent 4.5.2", + idPrefix: "-qB4520-", + peerId: generatePeerId("-qB4520-"), + userAgent: "qBittorrent/4.5.2", + key: generateKey(), + }; +} diff --git a/src/core/client/profiles/transmission.ts b/src/core/client/profiles/transmission.ts new file mode 100644 index 0000000..222b094 --- /dev/null +++ b/src/core/client/profiles/transmission.ts @@ -0,0 +1,15 @@ +import { type ClientProfile, generateKey, generatePeerId } from "../ClientProfile.js"; + +/** + * Mimics Transmission 3.00 + * Peer ID format: -TR3000- + */ +export function createTransmissionProfile(): ClientProfile { + return { + name: "Transmission 3.00", + idPrefix: "-TR3000-", + peerId: generatePeerId("-TR3000-"), + userAgent: "Transmission/3.00", + key: generateKey(), + }; +} diff --git a/src/core/seeder/FakeSeeder.ts b/src/core/seeder/FakeSeeder.ts new file mode 100644 index 0000000..362c978 --- /dev/null +++ b/src/core/seeder/FakeSeeder.ts @@ -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 | null = null; + private tickTimer: ReturnType | 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 { + 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 { + 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 { + 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); +} diff --git a/src/core/seeder/SpeedSimulator.ts b/src/core/seeder/SpeedSimulator.ts new file mode 100644 index 0000000..f0809fa --- /dev/null +++ b/src/core/seeder/SpeedSimulator.ts @@ -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.5–2× 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 (0–1). 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 (0–10% of base rate) + rate = this.baseRate * Math.random() * 0.1; + } else if (rand < 0.08) { + // ~5% chance: burst (150–200% 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; + } +} diff --git a/src/core/torrent/TorrentFile.test.ts b/src/core/torrent/TorrentFile.test.ts new file mode 100644 index 0000000..720a45d --- /dev/null +++ b/src/core/torrent/TorrentFile.test.ts @@ -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; + 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) { + 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); + }); +}); diff --git a/src/core/torrent/TorrentFile.ts b/src/core/torrent/TorrentFile.ts new file mode 100644 index 0000000..c3ff0fc --- /dev/null +++ b/src/core/torrent/TorrentFile.ts @@ -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; +} diff --git a/src/core/tracker/HttpTracker.ts b/src/core/tracker/HttpTracker.ts new file mode 100644 index 0000000..468ff22 --- /dev/null +++ b/src/core/tracker/HttpTracker.ts @@ -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 { + 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): Promise { + 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 { + // 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 []; +} diff --git a/src/core/tracker/ITracker.ts b/src/core/tracker/ITracker.ts new file mode 100644 index 0000000..777455e --- /dev/null +++ b/src/core/tracker/ITracker.ts @@ -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; + /** Send stopped event and clean up resources */ + stop(params: Omit): Promise; + /** Fetch current seeder/leecher counts without announcing (optional) */ + scrape?(infoHash: Buffer): Promise; +} diff --git a/src/core/tracker/TrackerResponse.ts b/src/core/tracker/TrackerResponse.ts new file mode 100644 index 0000000..0ade7b7 --- /dev/null +++ b/src/core/tracker/TrackerResponse.ts @@ -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" | ""; diff --git a/src/core/tracker/UdpTracker.ts b/src/core/tracker/UdpTracker.ts new file mode 100644 index 0000000..d348ad6 --- /dev/null +++ b/src/core/tracker/UdpTracker.ts @@ -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 { + 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): Promise { + try { + await this.announce({ ...params, event: "stopped" }); + } catch { + // best-effort + } + } + + private async getConnectionId(socket: Socket): Promise { + 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 { + 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 { + 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 (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 { + 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 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(times: number, fn: () => Promise): Promise { + 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; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..80f058f --- /dev/null +++ b/src/index.ts @@ -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); diff --git a/torrents/.gitkeep b/torrents/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..48c264e --- /dev/null +++ b/tsconfig.json @@ -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"] +} diff --git a/ui/bun.lock b/ui/bun.lock new file mode 100644 index 0000000..d0825ab --- /dev/null +++ b/ui/bun.lock @@ -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=="], + } +} diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..ddfab90 --- /dev/null +++ b/ui/index.html @@ -0,0 +1,12 @@ + + + + + + Torrent Faker + + +
+ + + diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..5584a6d --- /dev/null +++ b/ui/package.json @@ -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" + } +} diff --git a/ui/src/App.svelte b/ui/src/App.svelte new file mode 100644 index 0000000..c43b128 --- /dev/null +++ b/ui/src/App.svelte @@ -0,0 +1,207 @@ + + +
+
+
+

Torrent Faker

+

Fake seeding stats reporter

+
+ +
+ + {#if showConfig} + showConfig = false} /> + {/if} + + + + +
{ 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} + Adding torrent… + {:else} + Drop .torrent files here or + + {/if} +
+ + {#if uploadError} +

{uploadError}

+ {/if} + +
+ +
+
+ + diff --git a/ui/src/components/ConfigModal.svelte b/ui/src/components/ConfigModal.svelte new file mode 100644 index 0000000..081852c --- /dev/null +++ b/ui/src/components/ConfigModal.svelte @@ -0,0 +1,300 @@ + + + + + + + + diff --git a/ui/src/components/StatsPanel.svelte b/ui/src/components/StatsPanel.svelte new file mode 100644 index 0000000..d0668af --- /dev/null +++ b/ui/src/components/StatsPanel.svelte @@ -0,0 +1,56 @@ + + +
+
+ Active Seeders + {stats?.activeSeeders ?? "—"} +
+
+ Total Torrents + {stats?.totalTorrents ?? "—"} +
+
+ Total Uploaded + {stats ? formatBytes(stats.totalUploaded) : "—"} +
+
+ Uptime + {stats ? formatUptime(stats.uptimeSeconds) : "—"} +
+
+ + diff --git a/ui/src/components/TorrentList.svelte b/ui/src/components/TorrentList.svelte new file mode 100644 index 0000000..42588b5 --- /dev/null +++ b/ui/src/components/TorrentList.svelte @@ -0,0 +1,158 @@ + + +{#if torrents.length === 0} +

No active torrents. Drop a .torrent file above to start seeding.

+{:else} + + + + + + + + + + + + + + + + {#each torrents as t (t.infoHashHex)} + + + + + + + + + + + + {/each} + +
NameTrackerHashStatusUploadedSeedersLeechersLast Announce
{t.name}{new URL(t.trackerUrl).hostname}{t.infoHashHex.slice(0, 8)}… + {t.status} + {#if t.error}{/if} + {formatBytes(t.uploaded)}{t.seeders ?? "?"}{t.leechers ?? "?"} + {t.lastAnnounce ? new Date(t.lastAnnounce).toLocaleTimeString() : "—"} + + +
+{/if} + + diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts new file mode 100644 index 0000000..469e957 --- /dev/null +++ b/ui/src/lib/api.ts @@ -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 { + const res = await fetch(`${BASE}/torrents`); + return res.json(); +} + +export async function addTorrent(file: File): Promise { + 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 { + 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 { + const res = await fetch(`${BASE}/config`); + return res.json(); +} + +export async function updateConfig(patch: Partial>): Promise { + 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 { + 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`; +} diff --git a/ui/src/main.ts b/ui/src/main.ts new file mode 100644 index 0000000..a319f90 --- /dev/null +++ b/ui/src/main.ts @@ -0,0 +1,8 @@ +import { mount } from "svelte"; +import App from "./App.svelte"; + +const app = mount(App, { + target: document.getElementById("app")!, +}); + +export default app; diff --git a/ui/svelte.config.js b/ui/svelte.config.js new file mode 100644 index 0000000..d0e6448 --- /dev/null +++ b/ui/svelte.config.js @@ -0,0 +1,5 @@ +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; + +export default { + preprocess: vitePreprocess(), +}; diff --git a/ui/vite.config.ts b/ui/vite.config.ts new file mode 100644 index 0000000..41cfe43 --- /dev/null +++ b/ui/vite.config.ts @@ -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", + }, +});