feat: Initial commit
This commit is contained in:
commit
c131b84819
|
|
@ -0,0 +1,8 @@
|
|||
node_modules/
|
||||
ui/node_modules/
|
||||
ui/dist/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
|
||||
torrents/*
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
# CLAUDE.md — Torrent Faker
|
||||
|
||||
## Project Purpose
|
||||
|
||||
Torrent Faker is a fake BitTorrent seeder that announces upload stats to real trackers without transferring any actual data. It simulates realistic seeding behavior to satisfy tracker requirements.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-----------|-------------------------------------|
|
||||
| Runtime | Bun |
|
||||
| Language | TypeScript (strict, ESNext) |
|
||||
| HTTP | Hono 4.x |
|
||||
| Config | YAML (`js-yaml`) + Zod validation |
|
||||
| Linting | Biome |
|
||||
| Frontend | Svelte 5 + Vite (in `ui/`) |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
index.ts # Entry point: loads config, starts registry + HTTP server
|
||||
cli/index.ts # CLI client (talks to running API)
|
||||
config/Config.ts # Zod schema + config loader
|
||||
config/config.default.yml # Default config values
|
||||
api/
|
||||
server.ts # Hono app, mounts routes, serves UI
|
||||
SeederRegistry.ts # Central in-memory store for all FakeSeeder instances
|
||||
response.ts # HTTP response helpers
|
||||
routes/
|
||||
torrents.ts # GET/POST/DELETE /api/torrents
|
||||
status.ts # GET /api/status, GET /api/status/stream (SSE)
|
||||
config.ts # GET/PATCH /api/config
|
||||
core/
|
||||
bencode/ # Bencode decoder + encoder
|
||||
client/
|
||||
ClientProfile.ts # Profile interface + peer ID generation
|
||||
profiles/ # qbittorrent.ts, transmission.ts
|
||||
seeder/
|
||||
FakeSeeder.ts # Announce loop, lifecycle (started → running → stopped)
|
||||
SpeedSimulator.ts # Gaussian noise + burst/stall events
|
||||
torrent/TorrentFile.ts # Parses .torrent, extracts info hash + tracker list
|
||||
tracker/
|
||||
ITracker.ts # Tracker interface
|
||||
HttpTracker.ts # HTTP/HTTPS tracker (BEP 3)
|
||||
UdpTracker.ts # UDP tracker (BEP 15)
|
||||
TrackerResponse.ts # Response types
|
||||
config/config.yml # Runtime config (gitignored defaults)
|
||||
torrents/ # Drop .torrent files here
|
||||
ui/ # Svelte frontend (built to ui/dist/)
|
||||
```
|
||||
|
||||
## TypeScript Path Aliases
|
||||
|
||||
```
|
||||
@core/* → src/core/*
|
||||
@api/* → src/api/*
|
||||
@config/* → src/config/*
|
||||
```
|
||||
|
||||
## Dev Commands
|
||||
|
||||
```bash
|
||||
bun run dev # Start server (hot-reload)
|
||||
bun run start # Start server (production)
|
||||
bun run cli <cmd> # Run CLI against running server
|
||||
bun test # Run tests
|
||||
bun run lint # Biome check
|
||||
bun run format # Biome format --write
|
||||
bun run build:ui # Build Svelte UI to ui/dist/
|
||||
bun run build # Full build (UI only for now)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
SeederRegistry
|
||||
└── FakeSeeder (one per torrent)
|
||||
├── SpeedSimulator — random base rate, ±15% jitter, burst/stall
|
||||
└── Tracker[]
|
||||
├── HttpTracker (BEP 3)
|
||||
└── UdpTracker (BEP 15)
|
||||
```
|
||||
|
||||
- `SeederRegistry` is the single source of truth; routes call it, not FakeSeeder directly.
|
||||
- `FakeSeeder` extends `EventEmitter`; emits `announce`, `stopped`, `stateChange`.
|
||||
- `SeederRegistry` subscribes to seeder events and pushes SSE updates to connected clients.
|
||||
- Announce lifecycle: first call sends `event=started`, subsequent calls send `event=` (empty), final call on removal sends `event=stopped`.
|
||||
|
||||
## Configuration
|
||||
|
||||
Runtime config file: `config/config.yml` (copied from `src/config/config.default.yml`).
|
||||
|
||||
| Field | Default | Description |
|
||||
|---------------------------|-----------|------------------------------------------|
|
||||
| `port` | 3000 | HTTP server port (UI + API) |
|
||||
| `announcePort` | 6881 | Port reported to trackers |
|
||||
| `minUploadRateBytesPerSec`| 524288 | Min simulated speed (512 KB/s) |
|
||||
| `maxUploadRateBytesPerSec`| 2097152 | Max simulated speed (2 MB/s) |
|
||||
| `clientProfile` | qbittorrent | Client to impersonate |
|
||||
| `torrentsDir` | ./torrents | Directory for .torrent files |
|
||||
| `autoLoad` | true | Load all torrents in dir on startup |
|
||||
|
||||
Config is Zod-validated at startup. `PATCH /api/config` persists changes back to `config.yml`.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------------------------------|---------------------------------------|
|
||||
| GET | `/api/torrents` | List all active torrents |
|
||||
| POST | `/api/torrents` | Upload `.torrent` file (multipart) |
|
||||
| GET | `/api/torrents/:hash` | Stats + announce history for one |
|
||||
| DELETE | `/api/torrents/:hash` | Stop seeding and remove |
|
||||
| GET | `/api/status` | Global stats snapshot |
|
||||
| GET | `/api/status/stream` | SSE stream (stats, torrent, ping) |
|
||||
| GET | `/api/config` | Current config |
|
||||
| PATCH | `/api/config` | Update mutable config fields |
|
||||
| GET | `/*` | Serve Svelte SPA from `ui/dist/` |
|
||||
|
||||
### SSE Event Types
|
||||
- `stats` — global stats on every change
|
||||
- `torrent` — per-torrent state update on announce/change
|
||||
- `torrents` — full list on initial connection
|
||||
- `ping` — keep-alive every 30s
|
||||
|
||||
## CLI
|
||||
|
||||
Requires a running server. Reads `TORRENT_FAKER_API` env var (default: `http://localhost:3000`).
|
||||
|
||||
```bash
|
||||
bun run cli add <file.torrent> # Start fake-seeding
|
||||
bun run cli list # List active torrents
|
||||
bun run cli remove <hash> # Stop and remove (hash prefix OK)
|
||||
bun run cli status # Global stats
|
||||
```
|
||||
|
||||
## Client Profiles
|
||||
|
||||
| Profile | Peer ID prefix | User-Agent |
|
||||
|---------------|----------------|-----------------------|
|
||||
| `qbittorrent` | `-qB4520-` | `qBittorrent/4.5.2` |
|
||||
| `transmission`| `-TR3000-` | `Transmission/3.00` |
|
||||
|
||||
Each seeder generates a unique 20-byte peer ID per session.
|
||||
|
||||
## Code Conventions
|
||||
|
||||
- Biome enforces formatting; run `bun run format` before committing.
|
||||
- Zod schemas for all external input (config file, API request bodies).
|
||||
- No ORM — binary protocols implemented manually (bencode, UDP packets).
|
||||
- EventEmitter pattern for seeder → registry communication.
|
||||
- SSE for live UI updates (no WebSockets, no polling).
|
||||
|
|
@ -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"]
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
# Torrent Faker
|
||||
|
||||
Fake BitTorrent seeder — announces upload statistics to real trackers without transferring any actual data.
|
||||
|
||||
Useful for keeping a torrent "alive" on a tracker, testing tracker behavior, or load-testing tracker infrastructure without consuming bandwidth.
|
||||
|
||||
## Features
|
||||
|
||||
- Announces to HTTP/HTTPS trackers (BEP 3) and UDP trackers (BEP 15)
|
||||
- Simulates realistic upload speeds with jitter, burst, and stall events
|
||||
- Impersonates real BitTorrent clients (qBittorrent, Transmission)
|
||||
- Web UI for managing torrents and viewing live stats
|
||||
- REST API for programmatic control
|
||||
- CLI for quick command-line use
|
||||
- Docker support
|
||||
|
||||
## Prerequisites
|
||||
|
||||
**Option A — Bun (native):** [Bun](https://bun.sh) ≥ 1.0
|
||||
|
||||
**Option B — Docker:** Docker + Docker Compose
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Docker (recommended)
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Drop `.torrent` files into the `torrents/` directory — they are loaded automatically.
|
||||
|
||||
Access the web UI at <http://localhost:3000>.
|
||||
|
||||
### Native (Bun)
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
bun install
|
||||
|
||||
# Build the UI
|
||||
bun run build:ui
|
||||
|
||||
# Start the server
|
||||
bun run start
|
||||
```
|
||||
|
||||
The server starts on port 3000 by default.
|
||||
|
||||
## Configuration
|
||||
|
||||
Copy the default config and edit as needed:
|
||||
|
||||
```bash
|
||||
cp src/config/config.default.yml config/config.yml
|
||||
```
|
||||
|
||||
| Field | Default | Description |
|
||||
|-----------------------------|--------------|-----------------------------------------------|
|
||||
| `port` | `3000` | HTTP port for the web UI and REST API |
|
||||
| `announcePort` | `6881` | Port reported to trackers in announce requests |
|
||||
| `minUploadRateBytesPerSec` | `524288` | Minimum simulated upload speed (512 KB/s) |
|
||||
| `maxUploadRateBytesPerSec` | `2097152` | Maximum simulated upload speed (2 MB/s) |
|
||||
| `clientProfile` | `qbittorrent`| Client to impersonate: `qbittorrent` or `transmission` |
|
||||
| `torrentsDir` | `./torrents` | Directory scanned for `.torrent` files |
|
||||
| `autoLoad` | `true` | Auto-load all torrents in `torrentsDir` on startup |
|
||||
|
||||
Configuration can also be updated at runtime via the web UI or `PATCH /api/config`.
|
||||
|
||||
## Web UI
|
||||
|
||||
The web UI is served at the root URL and provides:
|
||||
|
||||
- Live upload stats per torrent (speed, total uploaded, seeders/leechers)
|
||||
- Global stats panel (active seeders, total uploaded, uptime)
|
||||
- Add torrents by file upload
|
||||
- Remove torrents
|
||||
- Edit configuration settings
|
||||
|
||||
Stats update in real time via Server-Sent Events.
|
||||
|
||||
## CLI
|
||||
|
||||
The CLI requires a running server instance.
|
||||
|
||||
```bash
|
||||
# Add and start seeding a torrent
|
||||
bun run cli add ./torrents/example.torrent
|
||||
|
||||
# List all active torrents
|
||||
bun run cli list
|
||||
|
||||
# Remove a torrent (info hash or prefix)
|
||||
bun run cli remove a1b2c3d4
|
||||
|
||||
# Show global stats
|
||||
bun run cli status
|
||||
```
|
||||
|
||||
Set `TORRENT_FAKER_API` to point to a non-local server:
|
||||
|
||||
```bash
|
||||
TORRENT_FAKER_API=http://my-server:3000 bun run cli list
|
||||
```
|
||||
|
||||
## REST API
|
||||
|
||||
| Method | Path | Description |
|
||||
|----------|---------------------------|--------------------------------------------|
|
||||
| `GET` | `/api/torrents` | List all active torrents |
|
||||
| `POST` | `/api/torrents` | Upload a `.torrent` file (`multipart/form-data`, field: `torrent`) |
|
||||
| `GET` | `/api/torrents/:hash` | Stats and announce history for one torrent |
|
||||
| `DELETE` | `/api/torrents/:hash` | Stop seeding and remove a torrent |
|
||||
| `GET` | `/api/status` | Global stats snapshot |
|
||||
| `GET` | `/api/status/stream` | SSE stream for live updates |
|
||||
| `GET` | `/api/config` | Current configuration |
|
||||
| `PATCH` | `/api/config` | Update mutable configuration fields |
|
||||
|
||||
### Add a torrent via curl
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/torrents \
|
||||
-F "torrent=@./example.torrent"
|
||||
```
|
||||
|
||||
### SSE stream events
|
||||
|
||||
Connect to `/api/status/stream` to receive:
|
||||
|
||||
- `stats` — global stats on every change
|
||||
- `torrent` — per-torrent state on each announce
|
||||
- `torrents` — full torrent list on initial connection
|
||||
- `ping` — keep-alive every 30 seconds
|
||||
|
||||
## Client Profiles
|
||||
|
||||
| Profile | Peer ID prefix | User-Agent |
|
||||
|----------------|----------------|-----------------------|
|
||||
| `qbittorrent` | `-qB4520-` | `qBittorrent/4.5.2` |
|
||||
| `transmission` | `-TR3000-` | `Transmission/3.00` |
|
||||
|
||||
Each seeder session uses a unique randomly-generated peer ID.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
bun run dev # Start server with hot-reload
|
||||
bun test # Run tests
|
||||
bun run lint # Lint with Biome
|
||||
bun run format # Auto-format with Biome
|
||||
bun run build:ui # Build Svelte UI to ui/dist/
|
||||
```
|
||||
|
||||
## Speed Simulation
|
||||
|
||||
Each torrent picks a random base upload rate within the configured min/max range. Per announce interval (every ~5 seconds):
|
||||
|
||||
- Normal: ±15% Gaussian-like noise around the base rate
|
||||
- 5% chance: burst to 150–200% of base rate
|
||||
- 3% chance: stall to 0–10% of base rate
|
||||
|
||||
This produces upload curves that resemble real client behavior.
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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=="],
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
port: 3000
|
||||
announcePort: 6881
|
||||
minUploadRateBytesPerSec: 2048000
|
||||
maxUploadRateBytesPerSec: 6144000
|
||||
clientProfile: qbittorrent
|
||||
torrentsDir: ./torrents
|
||||
autoLoad: true
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
/**
|
||||
* In-memory registry of active FakeSeeders.
|
||||
* Shared singleton between the API routes and the main entry point.
|
||||
*/
|
||||
import type { EventEmitter } from "node:events";
|
||||
import type { Config } from "@config/Config";
|
||||
import { createQbittorrentProfile } from "@core/client/profiles/qbittorrent";
|
||||
import { createTransmissionProfile } from "@core/client/profiles/transmission";
|
||||
import { type AnnounceRecord, FakeSeeder, type SeederState } from "@core/seeder/FakeSeeder";
|
||||
import { parseTorrentBuffer } from "@core/torrent/TorrentFile";
|
||||
|
||||
export interface SeederEntry {
|
||||
seeder: FakeSeeder;
|
||||
addedAt: Date;
|
||||
history: AnnounceRecord[];
|
||||
}
|
||||
|
||||
type ChangeListener = (state: SeederState) => void;
|
||||
|
||||
export class SeederRegistry {
|
||||
private readonly seeders = new Map<string, SeederEntry>();
|
||||
private readonly config: Config;
|
||||
private readonly changeListeners = new Set<ChangeListener>();
|
||||
private startedAt = new Date();
|
||||
|
||||
constructor(config: Config) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
onStateChange(listener: ChangeListener): () => void {
|
||||
this.changeListeners.add(listener);
|
||||
return () => this.changeListeners.delete(listener);
|
||||
}
|
||||
|
||||
async addTorrent(buf: Buffer): Promise<SeederState> {
|
||||
const torrent = parseTorrentBuffer(buf);
|
||||
|
||||
const existing = this.seeders.get(torrent.infoHashHex);
|
||||
if (existing) {
|
||||
return existing.seeder.getState();
|
||||
}
|
||||
|
||||
const profile =
|
||||
this.config.clientProfile === "transmission"
|
||||
? createTransmissionProfile()
|
||||
: createQbittorrentProfile();
|
||||
|
||||
const seeder = new FakeSeeder(torrent, profile, this.config);
|
||||
const entry: SeederEntry = { seeder, addedAt: new Date(), history: [] };
|
||||
|
||||
seeder.on("announce", (record) => {
|
||||
entry.history.push(record);
|
||||
if (entry.history.length > 100) entry.history.shift();
|
||||
});
|
||||
|
||||
seeder.on("stateChange", (state) => {
|
||||
for (const listener of this.changeListeners) {
|
||||
listener(state);
|
||||
}
|
||||
});
|
||||
|
||||
this.seeders.set(torrent.infoHashHex, entry);
|
||||
await seeder.start();
|
||||
|
||||
return seeder.getState();
|
||||
}
|
||||
|
||||
async removeTorrent(infoHashHex: string): Promise<boolean> {
|
||||
const entry = this.seeders.get(infoHashHex);
|
||||
if (!entry) return false;
|
||||
await entry.seeder.stop();
|
||||
this.seeders.delete(infoHashHex);
|
||||
return true;
|
||||
}
|
||||
|
||||
listTorrents(): Array<SeederState & { addedAt: Date }> {
|
||||
return Array.from(this.seeders.values()).map((e) => ({
|
||||
...e.seeder.getState(),
|
||||
addedAt: e.addedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
getHistory(infoHashHex: string): AnnounceRecord[] {
|
||||
return this.seeders.get(infoHashHex)?.history ?? [];
|
||||
}
|
||||
|
||||
globalStats() {
|
||||
const list = this.listTorrents();
|
||||
return {
|
||||
activeSeeders: list.filter((t) => t.status === "running").length,
|
||||
totalTorrents: list.length,
|
||||
totalUploaded: list.reduce((s, t) => s + t.uploaded, 0),
|
||||
uptimeSeconds: Math.floor((Date.now() - this.startedAt.getTime()) / 1000),
|
||||
startedAt: this.startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async stopAll(): Promise<void> {
|
||||
await Promise.allSettled(Array.from(this.seeders.values()).map((e) => e.seeder.stop()));
|
||||
this.seeders.clear();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import type { Context } from "hono";
|
||||
|
||||
/**
|
||||
* Named HTTP status codes — typed as literals via `as const`.
|
||||
* Literal types (200, 201 …) are what Hono's TypedResponse conditional needs
|
||||
* to resolve correctly; plain `number` makes the inference fail.
|
||||
*/
|
||||
export const Status = {
|
||||
OK: 200,
|
||||
CREATED: 201,
|
||||
BAD_REQUEST: 400,
|
||||
NOT_FOUND: 404,
|
||||
UNPROCESSABLE_ENTITY: 422,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Semantic response helpers.
|
||||
* Routes call `res.created(c, data)` instead of `c.json<T>(data, 201 as const)`.
|
||||
* No inline type parameters, no magic numbers.
|
||||
*/
|
||||
export const res = {
|
||||
ok: <T>(c: Context, data: T) => c.json(data, Status.OK),
|
||||
|
||||
created: <T>(c: Context, data: T) => c.json(data, Status.CREATED),
|
||||
|
||||
badRequest: (c: Context, message: string) => c.json({ error: message }, Status.BAD_REQUEST),
|
||||
|
||||
/** For structured validation errors (e.g. Zod's formatted output) */
|
||||
validationError: (c: Context, details: unknown) => c.json({ error: details }, Status.BAD_REQUEST),
|
||||
|
||||
notFound: (c: Context, message = "Not found") => c.json({ error: message }, Status.NOT_FOUND),
|
||||
|
||||
unprocessable: (c: Context, message: string) =>
|
||||
c.json({ error: message }, Status.UNPROCESSABLE_ENTITY),
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import { Hono } from "hono";
|
||||
import { streamSSE } from "hono/streaming";
|
||||
import type { SeederRegistry } from "../SeederRegistry.js";
|
||||
|
||||
export function statusRouter(registry: SeederRegistry) {
|
||||
const app = new Hono();
|
||||
|
||||
/** Global stats snapshot */
|
||||
app.get("/", (c) => {
|
||||
return c.json(registry.globalStats());
|
||||
});
|
||||
|
||||
/**
|
||||
* SSE stream — pushes a state update whenever any seeder changes.
|
||||
* The UI subscribes to this to get live updates without polling.
|
||||
*
|
||||
* Events:
|
||||
* - "stats" with global stats (every change)
|
||||
* - "torrent" with per-torrent state (on each seeder stateChange)
|
||||
*/
|
||||
app.get("/stream", (c) => {
|
||||
return streamSSE(c, async (stream) => {
|
||||
// Send initial snapshot
|
||||
await stream.writeSSE({
|
||||
event: "stats",
|
||||
data: JSON.stringify(registry.globalStats()),
|
||||
});
|
||||
|
||||
await stream.writeSSE({
|
||||
event: "torrents",
|
||||
data: JSON.stringify(registry.listTorrents()),
|
||||
});
|
||||
|
||||
// Subscribe to changes
|
||||
const unsubscribe = registry.onStateChange(async (torrentState) => {
|
||||
try {
|
||||
await stream.writeSSE({
|
||||
event: "torrent",
|
||||
data: JSON.stringify(torrentState),
|
||||
});
|
||||
await stream.writeSSE({
|
||||
event: "stats",
|
||||
data: JSON.stringify(registry.globalStats()),
|
||||
});
|
||||
} catch {
|
||||
// client disconnected
|
||||
}
|
||||
});
|
||||
|
||||
// Keep alive with a comment every 30s
|
||||
const keepAlive = setInterval(async () => {
|
||||
try {
|
||||
await stream.writeSSE({ event: "ping", data: "" });
|
||||
} catch {
|
||||
clearInterval(keepAlive);
|
||||
unsubscribe();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
// Clean up when client disconnects
|
||||
stream.onAbort(() => {
|
||||
clearInterval(keepAlive);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
// Hold the connection open
|
||||
await new Promise<void>((resolve) => {
|
||||
stream.onAbort(() => resolve());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
#!/usr/bin/env bun
|
||||
/**
|
||||
* CLI for Torrent Faker
|
||||
*
|
||||
* Commands:
|
||||
* add <file.torrent> — Add and start seeding a torrent
|
||||
* list — List all active torrents
|
||||
* remove <hash> — Stop seeding and remove a torrent
|
||||
* status — Show global stats
|
||||
*
|
||||
* The CLI talks to the running API server (default http://localhost:3000).
|
||||
* Set TORRENT_FAKER_API env var to override the base URL.
|
||||
*/
|
||||
|
||||
const BASE_URL = process.env.TORRENT_FAKER_API ?? "http://localhost:3000";
|
||||
|
||||
const [, , command, ...args] = process.argv;
|
||||
|
||||
async function main() {
|
||||
switch (command) {
|
||||
case "add":
|
||||
await cmdAdd(args[0]);
|
||||
break;
|
||||
case "list":
|
||||
await cmdList();
|
||||
break;
|
||||
case "remove":
|
||||
case "rm":
|
||||
await cmdRemove(args[0]);
|
||||
break;
|
||||
case "status":
|
||||
await cmdStatus();
|
||||
break;
|
||||
default:
|
||||
printUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdAdd(filePath: string | undefined) {
|
||||
if (!filePath) {
|
||||
console.error("Usage: torrent-faker add <file.torrent>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const file = Bun.file(filePath);
|
||||
if (!(await file.exists())) {
|
||||
console.error(`File not found: ${filePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const form = new FormData();
|
||||
form.append("torrent", new Blob([await file.arrayBuffer()]), file.name);
|
||||
|
||||
const res = await fetch(`${BASE_URL}/api/torrents`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
console.error(`Error: ${(data as { error: string }).error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const t = data as Record<string, unknown>;
|
||||
console.log(`✓ Added: ${t.name} [${t.infoHashHex}]`);
|
||||
console.log(` Status: ${t.status} Uploaded: ${formatBytes(t.uploaded as number)}`);
|
||||
}
|
||||
|
||||
async function cmdList() {
|
||||
const res = await fetch(`${BASE_URL}/api/torrents`);
|
||||
const torrents = (await res.json()) as Array<Record<string, unknown>>;
|
||||
|
||||
if (torrents.length === 0) {
|
||||
console.log("No active torrents.");
|
||||
return;
|
||||
}
|
||||
|
||||
const col = (s: string, w: number) => s.slice(0, w).padEnd(w);
|
||||
|
||||
console.log(
|
||||
`${col("Hash", 10)} ${col("Name", 30)} ${col("Status", 10)} ${col("Uploaded", 12)} Seeders/Leechers`
|
||||
);
|
||||
console.log("─".repeat(90));
|
||||
|
||||
for (const t of torrents) {
|
||||
console.log(
|
||||
`${col(String(t.infoHashHex).slice(0, 8), 10)} ` +
|
||||
`${col(String(t.name), 30)} ` +
|
||||
`${col(String(t.status), 10)} ` +
|
||||
`${col(formatBytes(t.uploaded as number), 12)} ` +
|
||||
`${t.seeders ?? "?"}/${t.leechers ?? "?"}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdRemove(hash: string | undefined) {
|
||||
if (!hash) {
|
||||
console.error("Usage: torrent-faker remove <info-hash>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const res = await fetch(`${BASE_URL}/api/torrents/${hash}`, { method: "DELETE" });
|
||||
|
||||
if (res.status === 404) {
|
||||
console.error(`Torrent not found: ${hash}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`✓ Removed ${hash}`);
|
||||
}
|
||||
|
||||
async function cmdStatus() {
|
||||
const res = await fetch(`${BASE_URL}/api/status`);
|
||||
const stats = (await res.json()) as Record<string, unknown>;
|
||||
|
||||
console.log(`Active seeders : ${stats.activeSeeders}`);
|
||||
console.log(`Total torrents : ${stats.totalTorrents}`);
|
||||
console.log(`Total uploaded : ${formatBytes(stats.totalUploaded as number)}`);
|
||||
console.log(`Uptime : ${formatUptime(stats.uptimeSeconds as number)}`);
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
|
||||
return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
return `${h}h ${m}m ${s}s`;
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
console.log(`
|
||||
Torrent Faker CLI
|
||||
|
||||
Usage: torrent-faker <command> [options]
|
||||
|
||||
Commands:
|
||||
add <file.torrent> Start fake-seeding a torrent
|
||||
list List all active torrents
|
||||
remove <hash> Stop and remove a torrent (prefix OK)
|
||||
status Show global upload stats
|
||||
|
||||
Environment:
|
||||
TORRENT_FAKER_API API base URL (default: http://localhost:3000)
|
||||
`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { load } from "js-yaml";
|
||||
import { z } from "zod";
|
||||
|
||||
const ConfigSchema = z.object({
|
||||
/** Port for the Hono HTTP server (UI + API) */
|
||||
port: z.number().int().min(1).max(65535).default(3000),
|
||||
|
||||
/** Port reported to trackers in announces */
|
||||
announcePort: z.number().int().min(1).max(65535).default(6881),
|
||||
|
||||
/** Minimum upload rate in bytes/second (e.g. 524288 = 512 KB/s) */
|
||||
minUploadRateBytesPerSec: z.number().int().positive().default(524_288),
|
||||
|
||||
/** Maximum upload rate in bytes/second (e.g. 2097152 = 2 MB/s) */
|
||||
maxUploadRateBytesPerSec: z.number().int().positive().default(2_097_152),
|
||||
|
||||
/** Client profile to use: "qbittorrent" | "transmission" */
|
||||
clientProfile: z.enum(["qbittorrent", "transmission"]).default("qbittorrent"),
|
||||
|
||||
/** Directory to watch for .torrent files */
|
||||
torrentsDir: z.string().default("./torrents"),
|
||||
|
||||
/** Autoload all .torrent files from torrentsDir on startup */
|
||||
autoLoad: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export type Config = z.infer<typeof ConfigSchema>;
|
||||
|
||||
const DEFAULT_CONFIG_PATH = join(import.meta.dir, "config.default.yml");
|
||||
|
||||
export function loadConfig(configPath?: string): Config {
|
||||
let raw: unknown = {};
|
||||
|
||||
// Try user config first
|
||||
const userPath = configPath ?? "./config/config.yml";
|
||||
if (existsSync(userPath)) {
|
||||
raw = load(readFileSync(userPath, "utf8")) ?? {};
|
||||
} else if (existsSync(DEFAULT_CONFIG_PATH)) {
|
||||
raw = load(readFileSync(DEFAULT_CONFIG_PATH, "utf8")) ?? {};
|
||||
}
|
||||
|
||||
const result = ConfigSchema.safeParse(raw);
|
||||
if (!result.success) {
|
||||
console.error("Invalid config:", z.treeifyError(result.error));
|
||||
throw new Error("Config validation failed");
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
/**
|
||||
* Bencode decoder
|
||||
* Spec: https://wiki.theory.org/BitTorrentSpecification#Bencoding
|
||||
*
|
||||
* Types:
|
||||
* integers → i<decimal>e e.g. i42e
|
||||
* strings → <len>:<bytes> e.g. 4:spam
|
||||
* lists → l<items>e
|
||||
* dicts → d<key><value>...e (keys must be byte strings, sorted)
|
||||
*
|
||||
* Binary strings (info_hash raw bytes) are returned as Buffer, not string.
|
||||
* All dict keys are returned as string (UTF-8).
|
||||
*/
|
||||
|
||||
export type BencodeValue = number | Buffer | BencodeList | BencodeDict;
|
||||
export type BencodeList = BencodeValue[];
|
||||
export type BencodeDict = { [key: string]: BencodeValue };
|
||||
|
||||
interface DecodeResult {
|
||||
value: BencodeValue;
|
||||
/** byte offset after the decoded value */
|
||||
end: number;
|
||||
}
|
||||
|
||||
export function decode(data: Buffer | Uint8Array): BencodeValue {
|
||||
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||
const result = decodeAt(buf, 0);
|
||||
return result.value;
|
||||
}
|
||||
|
||||
export function decodeAt(buf: Buffer, offset: number): DecodeResult {
|
||||
const byte = buf[offset];
|
||||
|
||||
if (byte === 0x69) {
|
||||
// 'i' — integer
|
||||
return decodeInteger(buf, offset);
|
||||
}
|
||||
|
||||
if (byte === 0x6c) {
|
||||
// 'l' — list
|
||||
return decodeList(buf, offset);
|
||||
}
|
||||
|
||||
if (byte === 0x64) {
|
||||
// 'd' — dict
|
||||
return decodeDict(buf, offset);
|
||||
}
|
||||
|
||||
if (byte >= 0x30 && byte <= 0x39) {
|
||||
// '0'-'9' — byte string
|
||||
return decodeString(buf, offset);
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected byte 0x${byte.toString(16)} at offset ${offset}`);
|
||||
}
|
||||
|
||||
function decodeInteger(buf: Buffer, offset: number): DecodeResult {
|
||||
// format: i<decimal>e
|
||||
const end = buf.indexOf(0x65, offset + 1); // 'e'
|
||||
if (end === -1) throw new Error(`Unterminated integer at offset ${offset}`);
|
||||
const str = buf.subarray(offset + 1, end).toString("ascii");
|
||||
if (str === "-0") throw new Error("Invalid integer: -0");
|
||||
const value = Number(str);
|
||||
if (!Number.isFinite(value)) throw new Error(`Invalid integer: ${str}`);
|
||||
return { value, end: end + 1 };
|
||||
}
|
||||
|
||||
function decodeString(buf: Buffer, offset: number): DecodeResult {
|
||||
const colonIdx = buf.indexOf(0x3a, offset); // ':'
|
||||
if (colonIdx === -1) throw new Error(`Malformed string at offset ${offset}`);
|
||||
const len = Number(buf.subarray(offset, colonIdx).toString("ascii"));
|
||||
if (!Number.isInteger(len) || len < 0) {
|
||||
throw new Error(`Invalid string length at offset ${offset}`);
|
||||
}
|
||||
const start = colonIdx + 1;
|
||||
const value = Buffer.from(buf.subarray(start, start + len));
|
||||
return { value, end: start + len };
|
||||
}
|
||||
|
||||
function decodeList(buf: Buffer, offset: number): DecodeResult {
|
||||
const list: BencodeList = [];
|
||||
let pos = offset + 1; // skip 'l'
|
||||
|
||||
while (buf[pos] !== 0x65) {
|
||||
// 'e'
|
||||
if (pos >= buf.length) throw new Error(`Unterminated list at offset ${offset}`);
|
||||
const item = decodeAt(buf, pos);
|
||||
list.push(item.value);
|
||||
pos = item.end;
|
||||
}
|
||||
|
||||
return { value: list, end: pos + 1 };
|
||||
}
|
||||
|
||||
function decodeDict(buf: Buffer, offset: number): DecodeResult {
|
||||
const dict: BencodeDict = {};
|
||||
let pos = offset + 1; // skip 'd'
|
||||
|
||||
while (buf[pos] !== 0x65) {
|
||||
// 'e'
|
||||
if (pos >= buf.length) throw new Error(`Unterminated dict at offset ${offset}`);
|
||||
|
||||
const keyResult = decodeString(buf, pos);
|
||||
const key = (keyResult.value as Buffer).toString("utf8");
|
||||
pos = keyResult.end;
|
||||
|
||||
const valResult = decodeAt(buf, pos);
|
||||
dict[key] = valResult.value;
|
||||
pos = valResult.end;
|
||||
}
|
||||
|
||||
return { value: dict, end: pos + 1 };
|
||||
}
|
||||
|
||||
/** Convenience: return the raw Buffer slice for a specific dict key path.
|
||||
* Used to extract the raw bencoded 'info' dict for SHA-1 hashing.
|
||||
* Walks the top-level dict properly instead of using indexOf, which would
|
||||
* produce false matches when a value's bytes happen to contain the key pattern. */
|
||||
export function rawSlice(buf: Buffer, key: string): Buffer | null {
|
||||
if (buf[0] !== 0x64) return null; // top-level must be a dict ('d')
|
||||
let pos = 1; // skip 'd'
|
||||
|
||||
while (pos < buf.length && buf[pos] !== 0x65 /* 'e' */) {
|
||||
const keyResult = decodeString(buf, pos);
|
||||
const k = (keyResult.value as Buffer).toString("utf8");
|
||||
pos = keyResult.end;
|
||||
|
||||
const valueStart = pos;
|
||||
const valueResult = decodeAt(buf, valueStart);
|
||||
|
||||
if (k === key) {
|
||||
return buf.subarray(valueStart, valueResult.end);
|
||||
}
|
||||
|
||||
pos = valueResult.end;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import { randomBytes } from "node:crypto";
|
||||
|
||||
export interface ClientProfile {
|
||||
/** Client identifier prefix, e.g. "-qB4520-" (8 chars) */
|
||||
idPrefix: string;
|
||||
/** 20-byte peer ID Buffer (generated once per torrent session) */
|
||||
peerId: Buffer;
|
||||
/** HTTP User-Agent header value */
|
||||
userAgent: string;
|
||||
/** Random key sent in HTTP announces for session continuity */
|
||||
key: string;
|
||||
/** Human-readable client name */
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a 20-byte peer ID using Azureus-style encoding:
|
||||
* -<XX><VVVV>-<12 random bytes as printable chars>
|
||||
*
|
||||
* Example: -qB4520-<random12>
|
||||
*/
|
||||
export function generatePeerId(prefix: string): Buffer {
|
||||
if (prefix.length !== 8) {
|
||||
throw new Error(`Peer ID prefix must be exactly 8 chars, got: "${prefix}"`);
|
||||
}
|
||||
// 12 random alphanumeric chars to fill the remaining bytes
|
||||
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
||||
let random = "";
|
||||
const rand = randomBytes(12);
|
||||
for (let i = 0; i < 12; i++) {
|
||||
random += chars[rand[i] % chars.length];
|
||||
}
|
||||
return Buffer.from(`${prefix}${random}`, "ascii");
|
||||
}
|
||||
|
||||
/** Generate a random 8-hex-char key for announces */
|
||||
export function generateKey(): string {
|
||||
return [...randomBytes(4)]
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { type ClientProfile, generateKey, generatePeerId } from "../ClientProfile.js";
|
||||
|
||||
/**
|
||||
* Mimics qBittorrent 4.5.2
|
||||
* Peer ID format: -qB4520-<random12>
|
||||
*/
|
||||
export function createQbittorrentProfile(): ClientProfile {
|
||||
return {
|
||||
name: "qBittorrent 4.5.2",
|
||||
idPrefix: "-qB4520-",
|
||||
peerId: generatePeerId("-qB4520-"),
|
||||
userAgent: "qBittorrent/4.5.2",
|
||||
key: generateKey(),
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { type ClientProfile, generateKey, generatePeerId } from "../ClientProfile.js";
|
||||
|
||||
/**
|
||||
* Mimics Transmission 3.00
|
||||
* Peer ID format: -TR3000-<random12>
|
||||
*/
|
||||
export function createTransmissionProfile(): ClientProfile {
|
||||
return {
|
||||
name: "Transmission 3.00",
|
||||
idPrefix: "-TR3000-",
|
||||
peerId: generatePeerId("-TR3000-"),
|
||||
userAgent: "Transmission/3.00",
|
||||
key: generateKey(),
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,248 @@
|
|||
import { EventEmitter } from "node:events";
|
||||
import type { Config } from "@config/Config";
|
||||
import type { ClientProfile } from "../client/ClientProfile.js";
|
||||
import type { TorrentFileInfo } from "../torrent/TorrentFile.js";
|
||||
import { HttpTracker } from "../tracker/HttpTracker.js";
|
||||
import type { ITracker } from "../tracker/ITracker.js";
|
||||
import type { TrackerResponse } from "../tracker/TrackerResponse.js";
|
||||
import { UdpTracker } from "../tracker/UdpTracker.js";
|
||||
import { SpeedSimulator } from "./SpeedSimulator.js";
|
||||
|
||||
export type SeederStatus = "idle" | "running" | "stopping" | "stopped" | "error";
|
||||
|
||||
export interface SeederState {
|
||||
infoHashHex: string;
|
||||
name: string;
|
||||
trackerUrl: string;
|
||||
uploaded: number;
|
||||
downloaded: number;
|
||||
left: number;
|
||||
status: SeederStatus;
|
||||
lastAnnounce: Date | null;
|
||||
lastInterval: number;
|
||||
seeders: number;
|
||||
leechers: number;
|
||||
peers: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface AnnounceRecord {
|
||||
timestamp: Date;
|
||||
trackerUrl: string;
|
||||
uploaded: number;
|
||||
seeders?: number;
|
||||
leechers?: number;
|
||||
peers: number;
|
||||
event: string;
|
||||
}
|
||||
|
||||
export class FakeSeeder extends EventEmitter {
|
||||
// Typed on() overloads — avoids unsafe declaration merging
|
||||
on(event: "announce", listener: (record: AnnounceRecord) => void): this;
|
||||
on(event: "stopped", listener: () => void): this;
|
||||
on(event: "stateChange", listener: (state: SeederState) => void): this;
|
||||
on(event: string, listener: (...args: unknown[]) => void): this {
|
||||
return super.on(event, listener);
|
||||
}
|
||||
private readonly torrent: TorrentFileInfo;
|
||||
private readonly profile: ClientProfile;
|
||||
private readonly config: Config;
|
||||
private readonly trackers: ITracker[];
|
||||
private readonly speedSim: SpeedSimulator;
|
||||
|
||||
private status: SeederStatus = "idle";
|
||||
private uploaded = 0;
|
||||
private readonly downloaded = 0;
|
||||
private left: number;
|
||||
private lastAnnounce: Date | null = null;
|
||||
private lastInterval = 1800;
|
||||
private lastSeeder = 0;
|
||||
private lastLeecher = 0;
|
||||
private lastPeers = 0;
|
||||
private error: string | null = null;
|
||||
private announceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private tickTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private lastTickTime = Date.now();
|
||||
|
||||
constructor(torrent: TorrentFileInfo, profile: ClientProfile, config: Config) {
|
||||
super();
|
||||
this.torrent = torrent;
|
||||
this.profile = profile;
|
||||
this.config = config;
|
||||
this.left = 0; // We're a seeder — we have the full file
|
||||
this.speedSim = new SpeedSimulator({
|
||||
minUploadRateBytesPerSec: config.minUploadRateBytesPerSec,
|
||||
maxUploadRateBytesPerSec: config.maxUploadRateBytesPerSec,
|
||||
});
|
||||
this.trackers = buildTrackers(torrent.announceList);
|
||||
}
|
||||
|
||||
get infoHashHex(): string {
|
||||
return this.torrent.infoHashHex;
|
||||
}
|
||||
|
||||
getState(): SeederState {
|
||||
return {
|
||||
infoHashHex: this.torrent.infoHashHex,
|
||||
name: this.torrent.name,
|
||||
trackerUrl: this.torrent.announce,
|
||||
uploaded: this.uploaded,
|
||||
downloaded: this.downloaded,
|
||||
left: this.left,
|
||||
status: this.status,
|
||||
lastAnnounce: this.lastAnnounce,
|
||||
lastInterval: this.lastInterval,
|
||||
seeders: this.lastSeeder,
|
||||
leechers: this.lastLeecher,
|
||||
peers: this.lastPeers,
|
||||
error: this.error,
|
||||
};
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.status === "running") return;
|
||||
this.setStatus("running");
|
||||
|
||||
// Start speed tick
|
||||
this.lastTickTime = Date.now();
|
||||
this.tickTimer = setInterval(() => {
|
||||
const now = Date.now();
|
||||
const delta = now - this.lastTickTime;
|
||||
this.lastTickTime = now;
|
||||
this.uploaded += this.speedSim.tick(delta);
|
||||
this.emit("stateChange", this.getState());
|
||||
}, 5_000);
|
||||
|
||||
// First announce: started
|
||||
await this.doAnnounce("started");
|
||||
this.scheduleNextAnnounce();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (this.status === "stopped" || this.status === "stopping") return;
|
||||
this.setStatus("stopping");
|
||||
|
||||
if (this.announceTimer) {
|
||||
clearTimeout(this.announceTimer);
|
||||
this.announceTimer = null;
|
||||
}
|
||||
if (this.tickTimer) {
|
||||
clearInterval(this.tickTimer);
|
||||
this.tickTimer = null;
|
||||
}
|
||||
|
||||
// Best-effort stopped announce to all trackers
|
||||
await Promise.allSettled(
|
||||
this.trackers.map((t) =>
|
||||
t.stop({
|
||||
infoHash: this.torrent.infoHash,
|
||||
peerId: this.profile.peerId,
|
||||
port: this.config.announcePort,
|
||||
uploaded: this.uploaded,
|
||||
downloaded: this.downloaded,
|
||||
left: this.left,
|
||||
userAgent: this.profile.userAgent,
|
||||
key: this.profile.key,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
this.setStatus("stopped");
|
||||
this.emit("stopped");
|
||||
}
|
||||
|
||||
private scheduleNextAnnounce(): void {
|
||||
const intervalMs = this.lastInterval * 1000;
|
||||
this.announceTimer = setTimeout(async () => {
|
||||
await this.doAnnounce("");
|
||||
if (this.status === "running") {
|
||||
this.scheduleNextAnnounce();
|
||||
}
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
private async doAnnounce(event: "started" | "stopped" | "completed" | ""): Promise<void> {
|
||||
const params = {
|
||||
infoHash: this.torrent.infoHash,
|
||||
peerId: this.profile.peerId,
|
||||
port: this.config.announcePort,
|
||||
uploaded: this.uploaded,
|
||||
downloaded: this.downloaded,
|
||||
left: this.left,
|
||||
event,
|
||||
userAgent: this.profile.userAgent,
|
||||
compact: true,
|
||||
numWant: 50,
|
||||
key: this.profile.key,
|
||||
};
|
||||
|
||||
// Announce to all trackers, collect results
|
||||
const results = await Promise.allSettled(this.trackers.map((t) => t.announce(params)));
|
||||
|
||||
let bestInterval = this.lastInterval;
|
||||
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const result = results[i];
|
||||
const tracker = this.trackers[i];
|
||||
|
||||
if (result.status === "fulfilled") {
|
||||
const res: TrackerResponse = result.value;
|
||||
this.lastAnnounce = new Date();
|
||||
this.lastSeeder = res.seeders ?? this.lastSeeder;
|
||||
this.lastLeecher = res.leechers ?? this.lastLeecher;
|
||||
this.lastPeers = res.peers.length;
|
||||
bestInterval = Math.min(bestInterval, res.interval);
|
||||
this.error = null; // clear any previous error
|
||||
|
||||
const record: AnnounceRecord = {
|
||||
timestamp: this.lastAnnounce,
|
||||
trackerUrl: tracker.url,
|
||||
uploaded: this.uploaded,
|
||||
seeders: res.seeders,
|
||||
leechers: res.leechers,
|
||||
peers: res.peers.length,
|
||||
event,
|
||||
};
|
||||
this.emit("announce", record);
|
||||
} else {
|
||||
this.error = (result.reason as Error).message;
|
||||
// Fallback: scrape to at least get current seeder/leecher counts
|
||||
if (tracker.scrape) {
|
||||
tracker.scrape(this.torrent.infoHash).then((stats) => {
|
||||
if (stats) {
|
||||
this.lastSeeder = stats.seeders;
|
||||
this.lastLeecher = stats.leechers;
|
||||
this.emit("stateChange", this.getState());
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.lastInterval = bestInterval;
|
||||
this.emit("stateChange", this.getState());
|
||||
}
|
||||
|
||||
private setStatus(s: SeederStatus): void {
|
||||
this.status = s;
|
||||
this.emit("stateChange", this.getState());
|
||||
}
|
||||
}
|
||||
|
||||
function buildTrackers(urls: string[]): ITracker[] {
|
||||
return urls
|
||||
.map((url) => {
|
||||
try {
|
||||
if (url.startsWith("http://") || url.startsWith("https://")) {
|
||||
return new HttpTracker(url);
|
||||
}
|
||||
if (url.startsWith("udp://")) {
|
||||
return new UdpTracker(url);
|
||||
}
|
||||
} catch {
|
||||
// skip malformed URLs
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((t): t is ITracker => t !== null);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import { describe, expect, it } from "bun:test";
|
||||
import { decode } from "../bencode/decoder.js";
|
||||
import { type EncodableDict, encode } from "../bencode/encoder.js";
|
||||
import { parseTorrentBuffer } from "./TorrentFile.js";
|
||||
|
||||
describe("bencode round-trip", () => {
|
||||
it("encodes and decodes integers", () => {
|
||||
const buf = encode(42);
|
||||
expect(buf.toString("ascii")).toBe("i42e");
|
||||
expect(decode(buf)).toBe(42);
|
||||
});
|
||||
|
||||
it("encodes and decodes negative integers", () => {
|
||||
const buf = encode(-7);
|
||||
expect(buf.toString("ascii")).toBe("i-7e");
|
||||
expect(decode(buf)).toBe(-7);
|
||||
});
|
||||
|
||||
it("encodes and decodes strings", () => {
|
||||
const buf = encode("spam");
|
||||
expect(buf.toString("ascii")).toBe("4:spam");
|
||||
const result = decode(buf) as Buffer;
|
||||
expect(result.toString("utf8")).toBe("spam");
|
||||
});
|
||||
|
||||
it("encodes and decodes lists", () => {
|
||||
const buf = encode([1, "two"]);
|
||||
expect(buf.toString("ascii")).toBe("li1e3:twoe");
|
||||
const result = decode(buf) as [number, Buffer];
|
||||
expect(result[0]).toBe(1);
|
||||
expect((result[1] as Buffer).toString()).toBe("two");
|
||||
});
|
||||
|
||||
it("encodes and decodes dicts", () => {
|
||||
const buf = encode({ z: 1, a: "hello" });
|
||||
// keys sorted lexicographically: a → "hello", z → 1
|
||||
expect(buf.toString("ascii")).toBe("d1:a5:hello1:zi1ee");
|
||||
// wait, z value is 1, not... let me just decode
|
||||
const result = decode(buf) as Record<string, unknown>;
|
||||
expect(result.z).toBe(1);
|
||||
expect((result.a as Buffer).toString()).toBe("hello");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseTorrentBuffer", () => {
|
||||
/** Build a minimal synthetic .torrent buffer */
|
||||
function makeTorrent(overrides?: Record<string, unknown>) {
|
||||
const infoPieces = Buffer.alloc(20); // 1 fake piece hash
|
||||
const info: EncodableDict = {
|
||||
name: Buffer.from("test-file.txt"),
|
||||
length: 1024,
|
||||
"piece length": 512,
|
||||
pieces: infoPieces,
|
||||
};
|
||||
const torrent: EncodableDict = {
|
||||
announce: Buffer.from("http://tracker.example.com/announce"),
|
||||
info,
|
||||
...(overrides as EncodableDict),
|
||||
};
|
||||
return encode(torrent);
|
||||
}
|
||||
|
||||
it("parses name and size", () => {
|
||||
const buf = makeTorrent();
|
||||
const t = parseTorrentBuffer(buf);
|
||||
expect(t.name).toBe("test-file.txt");
|
||||
expect(t.totalSize).toBe(1024);
|
||||
});
|
||||
|
||||
it("extracts announce URL", () => {
|
||||
const buf = makeTorrent();
|
||||
const t = parseTorrentBuffer(buf);
|
||||
expect(t.announce).toBe("http://tracker.example.com/announce");
|
||||
expect(t.announceList).toContain("http://tracker.example.com/announce");
|
||||
});
|
||||
|
||||
it("produces a 20-byte info_hash", () => {
|
||||
const buf = makeTorrent();
|
||||
const t = parseTorrentBuffer(buf);
|
||||
expect(t.infoHash.length).toBe(20);
|
||||
expect(t.infoHashHex).toMatch(/^[0-9a-f]{40}$/);
|
||||
});
|
||||
|
||||
it("single-file torrent has one file entry", () => {
|
||||
const buf = makeTorrent();
|
||||
const t = parseTorrentBuffer(buf);
|
||||
expect(t.isMultiFile).toBe(false);
|
||||
expect(t.files).toHaveLength(1);
|
||||
expect(t.files[0].size).toBe(1024);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
import { type BencodeDict, type BencodeList, decode } from "../bencode/decoder.js";
|
||||
import type { AnnounceParams, ITracker, ScrapeResult } from "./ITracker.js";
|
||||
import type { TrackerPeer, TrackerResponse } from "./TrackerResponse.js";
|
||||
|
||||
/**
|
||||
* HTTP/HTTPS tracker client.
|
||||
*
|
||||
* Key correctness concern: info_hash and peer_id are raw binary — they must be
|
||||
* percent-encoded byte-by-byte (not hex, not base64). We build the query string
|
||||
* manually instead of using URLSearchParams to avoid double-encoding.
|
||||
*/
|
||||
export class HttpTracker implements ITracker {
|
||||
readonly url: string;
|
||||
private trackerId?: string;
|
||||
private userAgent = "";
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
async announce(params: AnnounceParams): Promise<TrackerResponse> {
|
||||
this.userAgent = params.userAgent;
|
||||
const qs = buildQueryString(params, this.trackerId);
|
||||
const sep = this.url.includes("?") ? "&" : "?";
|
||||
const fullUrl = `${this.url}${sep}${qs}`;
|
||||
|
||||
const res = await fetch(fullUrl, {
|
||||
headers: {
|
||||
"User-Agent": params.userAgent,
|
||||
"Accept-Encoding": "gzip",
|
||||
},
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP tracker returned ${res.status} for ${this.url}`);
|
||||
}
|
||||
|
||||
const body = Buffer.from(await res.arrayBuffer());
|
||||
const decoded = decode(body) as BencodeDict;
|
||||
|
||||
if (decoded["failure reason"]) {
|
||||
throw new Error(`Tracker failure: ${(decoded["failure reason"] as Buffer).toString("utf8")}`);
|
||||
}
|
||||
|
||||
if (decoded["tracker id"]) {
|
||||
this.trackerId = (decoded["tracker id"] as Buffer).toString("utf8");
|
||||
}
|
||||
|
||||
return parseResponse(decoded);
|
||||
}
|
||||
|
||||
async stop(params: Omit<AnnounceParams, "event">): Promise<void> {
|
||||
try {
|
||||
await this.announce({ ...params, event: "stopped" });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrape the tracker for current seeder/leecher counts without announcing.
|
||||
* Derives the scrape URL by replacing the last `/announce` path segment with `/scrape`.
|
||||
* Returns null if the tracker doesn't support scrape or the request fails.
|
||||
*/
|
||||
async scrape(infoHash: Buffer): Promise<ScrapeResult | null> {
|
||||
// Only supported if announce URL contains /announce in the path
|
||||
const scrapeUrl = this.url.replace(/(\/announce)([^/]*)$/, "/scrape$2");
|
||||
if (scrapeUrl === this.url) return null;
|
||||
|
||||
try {
|
||||
const scrapeQSep = scrapeUrl.includes("?") ? "&" : "?";
|
||||
const fullUrl = `${scrapeUrl}${scrapeQSep}info_hash=${percentEncode(infoHash)}`;
|
||||
const res = await fetch(fullUrl, {
|
||||
headers: { "User-Agent": this.userAgent || "Mozilla/5.0" },
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
|
||||
const body = Buffer.from(await res.arrayBuffer());
|
||||
const decoded = decode(body) as BencodeDict;
|
||||
if (!decoded.files) return null;
|
||||
|
||||
const entries = Object.values(decoded.files as BencodeDict);
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
const entry = entries[0] as BencodeDict;
|
||||
return {
|
||||
seeders: (entry.complete as number) ?? 0,
|
||||
leechers: (entry.incomplete as number) ?? 0,
|
||||
completed: (entry.downloaded as number) ?? 0,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildQueryString(params: AnnounceParams, trackerId?: string): string {
|
||||
const parts: string[] = [
|
||||
`info_hash=${percentEncode(params.infoHash)}`,
|
||||
`peer_id=${percentEncode(params.peerId)}`,
|
||||
`port=${params.port}`,
|
||||
`uploaded=${params.uploaded}`,
|
||||
`downloaded=${params.downloaded}`,
|
||||
`left=${params.left}`,
|
||||
`compact=${params.compact !== false ? 1 : 0}`,
|
||||
`no_peer_id=1`,
|
||||
];
|
||||
|
||||
if (params.event) {
|
||||
parts.push(`event=${params.event}`);
|
||||
}
|
||||
|
||||
if (params.numWant !== undefined) {
|
||||
parts.push(`numwant=${params.numWant}`);
|
||||
}
|
||||
|
||||
if (params.key) {
|
||||
parts.push(`key=${encodeURIComponent(params.key)}`);
|
||||
}
|
||||
|
||||
if (trackerId) {
|
||||
parts.push(`trackerid=${encodeURIComponent(trackerId)}`);
|
||||
}
|
||||
|
||||
return parts.join("&");
|
||||
}
|
||||
|
||||
/**
|
||||
* Percent-encode each byte of a Buffer individually.
|
||||
* e.g. 0x1a → %1A (uppercase hex, safe chars left as-is... but for binary data
|
||||
* we encode everything non-alphanumeric to be safe).
|
||||
*/
|
||||
function percentEncode(buf: Buffer): string {
|
||||
let result = "";
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
const byte = buf[i];
|
||||
// safe chars: A-Z a-z 0-9 - _ . ~
|
||||
if (
|
||||
(byte >= 0x41 && byte <= 0x5a) || // A-Z
|
||||
(byte >= 0x61 && byte <= 0x7a) || // a-z
|
||||
(byte >= 0x30 && byte <= 0x39) || // 0-9
|
||||
byte === 0x2d || // -
|
||||
byte === 0x5f || // _
|
||||
byte === 0x2e || // .
|
||||
byte === 0x7e // ~
|
||||
) {
|
||||
result += String.fromCharCode(byte);
|
||||
} else {
|
||||
result += `%${byte.toString(16).padStart(2, "0").toUpperCase()}`;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseResponse(dict: BencodeDict): TrackerResponse {
|
||||
const interval = (dict.interval as number) ?? 1800;
|
||||
const minInterval = dict["min interval"] as number | undefined;
|
||||
const seeders = dict.complete as number | undefined;
|
||||
const leechers = dict.incomplete as number | undefined;
|
||||
const warning = dict["warning message"]
|
||||
? (dict["warning message"] as Buffer).toString("utf8")
|
||||
: undefined;
|
||||
const trackerId = dict["tracker id"]
|
||||
? (dict["tracker id"] as Buffer).toString("utf8")
|
||||
: undefined;
|
||||
|
||||
const peers = parsePeers(dict.peers);
|
||||
|
||||
return { interval, minInterval, seeders, leechers, peers, warning, trackerId };
|
||||
}
|
||||
|
||||
function parsePeers(raw: unknown): TrackerPeer[] {
|
||||
if (!raw) return [];
|
||||
|
||||
// Compact format: 6 bytes per peer (4 IP + 2 port)
|
||||
if (Buffer.isBuffer(raw)) {
|
||||
const peers: TrackerPeer[] = [];
|
||||
for (let i = 0; i + 5 < raw.length; i += 6) {
|
||||
const ip = `${raw[i]}.${raw[i + 1]}.${raw[i + 2]}.${raw[i + 3]}`;
|
||||
const port = raw.readUInt16BE(i + 4);
|
||||
peers.push({ ip, port });
|
||||
}
|
||||
return peers;
|
||||
}
|
||||
|
||||
// Dict format (older trackers)
|
||||
if (Array.isArray(raw)) {
|
||||
return (raw as BencodeList).map((p) => {
|
||||
const pd = p as BencodeDict;
|
||||
return {
|
||||
ip: (pd.ip as Buffer).toString("ascii"),
|
||||
port: pd.port as number,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import type { AnnounceEvent, TrackerResponse } from "./TrackerResponse.js";
|
||||
|
||||
export interface AnnounceParams {
|
||||
infoHash: Buffer;
|
||||
peerId: Buffer;
|
||||
port: number;
|
||||
uploaded: number;
|
||||
downloaded: number;
|
||||
left: number;
|
||||
event: AnnounceEvent;
|
||||
/** HTTP User-Agent header — should match the spoofed client profile */
|
||||
userAgent: string;
|
||||
/** compact=1 for HTTP trackers */
|
||||
compact?: boolean;
|
||||
numWant?: number;
|
||||
key?: string;
|
||||
trackerId?: string;
|
||||
}
|
||||
|
||||
export interface ScrapeResult {
|
||||
seeders: number;
|
||||
leechers: number;
|
||||
completed: number;
|
||||
}
|
||||
|
||||
export interface ITracker {
|
||||
readonly url: string;
|
||||
announce(params: AnnounceParams): Promise<TrackerResponse>;
|
||||
/** Send stopped event and clean up resources */
|
||||
stop(params: Omit<AnnounceParams, "event">): Promise<void>;
|
||||
/** Fetch current seeder/leecher counts without announcing (optional) */
|
||||
scrape?(infoHash: Buffer): Promise<ScrapeResult | null>;
|
||||
}
|
||||
|
|
@ -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" | "";
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
import { createSocket, type Socket } from "node:dgram";
|
||||
import type { AnnounceParams, ITracker } from "./ITracker.js";
|
||||
import type { TrackerPeer, TrackerResponse } from "./TrackerResponse.js";
|
||||
|
||||
/**
|
||||
* UDP tracker protocol (BEP 15)
|
||||
* https://www.bittorrent.org/beps/bep_0015.html
|
||||
*
|
||||
* Flow:
|
||||
* 1. Connect request → action=0, magic connection_id
|
||||
* 2. Connect response → server returns connection_id (valid ~1 min)
|
||||
* 3. Announce request → action=1 with connection_id
|
||||
* 4. Announce response → peers, interval, seeders, leechers
|
||||
*/
|
||||
|
||||
const CONNECT_MAGIC = BigInt("0x41727101980");
|
||||
const CONNECT_ACTION = 0;
|
||||
const ANNOUNCE_ACTION = 1;
|
||||
const TIMEOUT_MS = 10_000;
|
||||
const MAX_RETRIES = 3;
|
||||
|
||||
export class UdpTracker implements ITracker {
|
||||
readonly url: string;
|
||||
private readonly host: string;
|
||||
private readonly port: number;
|
||||
private connectionId: bigint | null = null;
|
||||
private connectionExpiry = 0;
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
const parsed = new URL(url);
|
||||
this.host = parsed.hostname;
|
||||
this.port = Number(parsed.port) || 80;
|
||||
}
|
||||
|
||||
async announce(params: AnnounceParams): Promise<TrackerResponse> {
|
||||
const socket = createSocket("udp4");
|
||||
|
||||
try {
|
||||
const connId = await this.getConnectionId(socket);
|
||||
return await this.sendAnnounce(socket, connId, params);
|
||||
} finally {
|
||||
socket.close();
|
||||
}
|
||||
}
|
||||
|
||||
async stop(params: Omit<AnnounceParams, "event">): Promise<void> {
|
||||
try {
|
||||
await this.announce({ ...params, event: "stopped" });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
private async getConnectionId(socket: Socket): Promise<bigint> {
|
||||
if (this.connectionId && Date.now() < this.connectionExpiry) {
|
||||
return this.connectionId;
|
||||
}
|
||||
const connId = await this.connect(socket);
|
||||
this.connectionId = connId;
|
||||
this.connectionExpiry = Date.now() + 60_000; // valid ~60s
|
||||
return connId;
|
||||
}
|
||||
|
||||
private connect(socket: Socket): Promise<bigint> {
|
||||
return retry(MAX_RETRIES, async () => {
|
||||
const transactionId = crypto.getRandomValues(new Uint32Array(1))[0];
|
||||
const req = buildConnectRequest(transactionId);
|
||||
|
||||
const resp = await sendAndReceive(socket, req, this.host, this.port, TIMEOUT_MS);
|
||||
return parseConnectResponse(resp, transactionId);
|
||||
});
|
||||
}
|
||||
|
||||
private sendAnnounce(
|
||||
socket: Socket,
|
||||
connectionId: bigint,
|
||||
params: AnnounceParams
|
||||
): Promise<TrackerResponse> {
|
||||
return retry(MAX_RETRIES, async () => {
|
||||
const transactionId = crypto.getRandomValues(new Uint32Array(1))[0];
|
||||
const req = buildAnnounceRequest(connectionId, transactionId, params);
|
||||
|
||||
const resp = await sendAndReceive(socket, req, this.host, this.port, TIMEOUT_MS);
|
||||
return parseAnnounceResponse(resp, transactionId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Packet builders ──────────────────────────────────────────────────────────
|
||||
|
||||
function buildConnectRequest(transactionId: number): Buffer {
|
||||
const buf = Buffer.allocUnsafe(16);
|
||||
buf.writeBigUInt64BE(CONNECT_MAGIC, 0);
|
||||
buf.writeUInt32BE(CONNECT_ACTION, 8);
|
||||
buf.writeUInt32BE(transactionId, 12);
|
||||
return buf;
|
||||
}
|
||||
|
||||
function buildAnnounceRequest(
|
||||
connectionId: bigint,
|
||||
transactionId: number,
|
||||
p: AnnounceParams
|
||||
): Buffer {
|
||||
const buf = Buffer.allocUnsafe(98);
|
||||
buf.writeBigUInt64BE(connectionId, 0);
|
||||
buf.writeUInt32BE(ANNOUNCE_ACTION, 8);
|
||||
buf.writeUInt32BE(transactionId, 12);
|
||||
p.infoHash.copy(buf, 16); // 20 bytes
|
||||
p.peerId.copy(buf, 36); // 20 bytes
|
||||
buf.writeBigUInt64BE(BigInt(p.downloaded), 56);
|
||||
buf.writeBigUInt64BE(BigInt(p.left), 64);
|
||||
buf.writeBigUInt64BE(BigInt(p.uploaded), 72);
|
||||
buf.writeUInt32BE(eventToInt(p.event ?? ""), 80);
|
||||
buf.writeUInt32BE(0, 84); // IP address (0 = default)
|
||||
buf.writeUInt32BE(0, 88); // key (use 0 if not provided)
|
||||
buf.writeInt32BE(p.numWant ?? -1, 92);
|
||||
buf.writeUInt16BE(p.port, 96);
|
||||
return buf;
|
||||
}
|
||||
|
||||
function eventToInt(event: string): number {
|
||||
switch (event) {
|
||||
case "completed":
|
||||
return 1;
|
||||
case "started":
|
||||
return 2;
|
||||
case "stopped":
|
||||
return 3;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Packet parsers ───────────────────────────────────────────────────────────
|
||||
// Use Uint8Array + DataView for reads — avoids Buffer<ArrayBuffer> (NonSharedBuffer)
|
||||
// type issues while being portable and spec-correct.
|
||||
|
||||
function parseConnectResponse(data: Uint8Array, expectedTxId: number): bigint {
|
||||
if (data.length < 16) throw new Error("Connect response too short");
|
||||
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
||||
const action = view.getUint32(0);
|
||||
const txId = view.getUint32(4);
|
||||
if (action !== CONNECT_ACTION) throw new Error(`Expected action 0, got ${action}`);
|
||||
if (txId !== expectedTxId) throw new Error("Transaction ID mismatch in connect response");
|
||||
return view.getBigUint64(8);
|
||||
}
|
||||
|
||||
function parseAnnounceResponse(data: Uint8Array, expectedTxId: number): TrackerResponse {
|
||||
if (data.length < 20) throw new Error("Announce response too short");
|
||||
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
||||
const action = view.getUint32(0);
|
||||
const txId = view.getUint32(4);
|
||||
if (action !== ANNOUNCE_ACTION) throw new Error(`Expected action 1, got ${action}`);
|
||||
if (txId !== expectedTxId) throw new Error("Transaction ID mismatch in announce response");
|
||||
|
||||
const interval = view.getUint32(8);
|
||||
const leechers = view.getUint32(12);
|
||||
const seeders = view.getUint32(16);
|
||||
|
||||
const peers: TrackerPeer[] = [];
|
||||
for (let i = 20; i + 5 < data.length; i += 6) {
|
||||
const ip = `${data[i]}.${data[i + 1]}.${data[i + 2]}.${data[i + 3]}`;
|
||||
const port = view.getUint16(i + 4);
|
||||
peers.push({ ip, port });
|
||||
}
|
||||
|
||||
return { interval, leechers, seeders, peers };
|
||||
}
|
||||
|
||||
// ─── UDP helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function sendAndReceive(
|
||||
socket: Socket,
|
||||
msg: Buffer,
|
||||
host: string,
|
||||
port: number,
|
||||
timeoutMs: number
|
||||
): Promise<Uint8Array> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
reject(new Error(`UDP timeout after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
socket.once("message", (data) => {
|
||||
clearTimeout(timer);
|
||||
resolve(data); // Buffer<ArrayBuffer> extends Uint8Array — no copy needed
|
||||
});
|
||||
|
||||
socket.once("error", (err) => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
socket.send(msg, port, host, (err) => {
|
||||
if (err) {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function retry<T>(times: number, fn: () => Promise<T>): Promise<T> {
|
||||
let lastErr: Error = new Error("No attempts made");
|
||||
for (let i = 0; i < times; i++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
lastErr = e as Error;
|
||||
}
|
||||
}
|
||||
throw lastErr;
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import { readdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { SeederRegistry } from "@api/SeederRegistry";
|
||||
import { startServer } from "@api/server";
|
||||
import { loadConfig } from "@config/Config";
|
||||
|
||||
const config = loadConfig();
|
||||
const registry = new SeederRegistry(config);
|
||||
|
||||
// Autoload .torrent files from torrentsDir on startup
|
||||
if (config.autoLoad) {
|
||||
try {
|
||||
const files = await readdir(config.torrentsDir);
|
||||
const torrents = files.filter((f) => f.endsWith(".torrent"));
|
||||
|
||||
if (torrents.length > 0) {
|
||||
console.log(`Loading ${torrents.length} torrent(s) from ${config.torrentsDir}…`);
|
||||
for (const file of torrents) {
|
||||
const path = join(config.torrentsDir, file);
|
||||
const buf = Buffer.from(await Bun.file(path).arrayBuffer());
|
||||
try {
|
||||
const state = await registry.addTorrent(buf);
|
||||
console.log(` ✓ ${state.name} [${state.infoHashHex.slice(0, 8)}]`);
|
||||
} catch (err) {
|
||||
console.warn(` ✗ Failed to load ${file}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// torrentsDir doesn't exist yet — that's fine
|
||||
}
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
async function shutdown() {
|
||||
console.log("\nShutting down — sending stopped announces…");
|
||||
await registry.stopAll();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
|
||||
startServer(registry, config, config.port);
|
||||
|
|
@ -0,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"]
|
||||
}
|
||||
|
|
@ -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=="],
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Torrent Faker</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { listTorrents, addTorrent, connectSSE } from "./lib/api.js";
|
||||
import type { TorrentState, GlobalStats } from "./lib/api.js";
|
||||
import StatsPanel from "./components/StatsPanel.svelte";
|
||||
import TorrentList from "./components/TorrentList.svelte";
|
||||
import ConfigModal from "./components/ConfigModal.svelte";
|
||||
|
||||
let torrents: TorrentState[] = [];
|
||||
let stats: GlobalStats | null = null;
|
||||
let es: EventSource | null = null;
|
||||
let uploading = false;
|
||||
let uploadError = "";
|
||||
let dragOver = false;
|
||||
let showConfig = false;
|
||||
|
||||
onMount(async () => {
|
||||
torrents = await listTorrents();
|
||||
|
||||
es = connectSSE(
|
||||
(updated) => {
|
||||
const idx = torrents.findIndex((t) => t.infoHashHex === updated.infoHashHex);
|
||||
if (idx >= 0) {
|
||||
torrents = [...torrents.slice(0, idx), updated, ...torrents.slice(idx + 1)];
|
||||
}
|
||||
},
|
||||
(s) => { stats = s; },
|
||||
(all) => { torrents = all; }
|
||||
);
|
||||
});
|
||||
|
||||
onDestroy(() => es?.close());
|
||||
|
||||
async function handleFiles(files: FileList | null) {
|
||||
if (!files || files.length === 0) return;
|
||||
uploading = true;
|
||||
uploadError = "";
|
||||
try {
|
||||
for (const file of Array.from(files)) {
|
||||
if (!file.name.endsWith(".torrent")) continue;
|
||||
const state = await addTorrent(file);
|
||||
if (!torrents.find((t) => t.infoHashHex === state.infoHashHex)) {
|
||||
torrents = [...torrents, state];
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
uploadError = (e as Error).message;
|
||||
} finally {
|
||||
uploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dragOver = false;
|
||||
handleFiles(e.dataTransfer?.files ?? null);
|
||||
}
|
||||
|
||||
function onFileInput(e: Event) {
|
||||
handleFiles((e.target as HTMLInputElement).files);
|
||||
}
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<header>
|
||||
<div>
|
||||
<h1>Torrent Faker</h1>
|
||||
<p class="subtitle">Fake seeding stats reporter</p>
|
||||
</div>
|
||||
<button class="settings-btn" on:click={() => showConfig = true} aria-label="Settings" title="Settings">
|
||||
⚙
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if showConfig}
|
||||
<ConfigModal on:close={() => showConfig = false} />
|
||||
{/if}
|
||||
|
||||
<StatsPanel {stats} />
|
||||
|
||||
<!-- Drop zone -->
|
||||
<div
|
||||
class="dropzone"
|
||||
class:active={dragOver}
|
||||
on:dragover|preventDefault={() => { dragOver = true; }}
|
||||
on:dragleave={() => { dragOver = false; }}
|
||||
on:drop={onDrop}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
on:keydown={(e) => e.key === "Enter" && document.getElementById("file-input")?.click()}
|
||||
aria-label="Drop torrent file or click to browse"
|
||||
>
|
||||
{#if uploading}
|
||||
<span>Adding torrent…</span>
|
||||
{:else}
|
||||
<span>Drop <code>.torrent</code> files here or <label class="browse" for="file-input">browse</label></span>
|
||||
<input id="file-input" type="file" accept=".torrent" multiple on:change={onFileInput} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if uploadError}
|
||||
<p class="error">{uploadError}</p>
|
||||
{/if}
|
||||
|
||||
<section>
|
||||
<TorrentList bind:torrents />
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
:global(*, *::before, *::after) {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: #0f0f1a;
|
||||
color: #d0d0f0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.settings-btn {
|
||||
background: #1e1e38;
|
||||
border: 1px solid #3a3a5a;
|
||||
color: #8888aa;
|
||||
font-size: 1.2rem;
|
||||
width: 2.2rem;
|
||||
height: 2.2rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.settings-btn:hover {
|
||||
color: #a78bfa;
|
||||
border-color: #a78bfa;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.8rem;
|
||||
color: #a78bfa;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0.25rem 0 0;
|
||||
color: #8888aa;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.dropzone {
|
||||
border: 2px dashed #3a3a5a;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
color: #8888aa;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
margin-bottom: 1.5rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.dropzone.active {
|
||||
border-color: #a78bfa;
|
||||
background: #a78bfa11;
|
||||
color: #a78bfa;
|
||||
}
|
||||
|
||||
.dropzone input[type="file"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.browse {
|
||||
color: #a78bfa;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #f87171;
|
||||
font-size: 0.875rem;
|
||||
margin: -1rem 0 1rem;
|
||||
}
|
||||
|
||||
section {
|
||||
background: #13132a;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,300 @@
|
|||
<script lang="ts">
|
||||
import { onMount, createEventDispatcher } from "svelte";
|
||||
import { getConfig, updateConfig } from "../lib/api.js";
|
||||
import type { AppConfig } from "../lib/api.js";
|
||||
|
||||
const dispatch = createEventDispatcher<{ close: void; saved: AppConfig }>();
|
||||
|
||||
let config: AppConfig | null = null;
|
||||
let saving = false;
|
||||
let error = "";
|
||||
|
||||
// Form fields (KB/s for display, bytes internally)
|
||||
let minKBs = 512;
|
||||
let maxKBs = 2048;
|
||||
let clientProfile: "qbittorrent" | "transmission" = "qbittorrent";
|
||||
let announcePort = 6881;
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
config = await getConfig();
|
||||
minKBs = Math.round(config.minUploadRateBytesPerSec / 1024);
|
||||
maxKBs = Math.round(config.maxUploadRateBytesPerSec / 1024);
|
||||
clientProfile = config.clientProfile;
|
||||
announcePort = config.announcePort;
|
||||
} catch (e) {
|
||||
error = "Failed to load config";
|
||||
}
|
||||
});
|
||||
|
||||
async function save() {
|
||||
error = "";
|
||||
if (minKBs <= 0 || maxKBs <= 0) {
|
||||
error = "Speed values must be positive";
|
||||
return;
|
||||
}
|
||||
if (minKBs > maxKBs) {
|
||||
error = "Min speed must be ≤ max speed";
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
try {
|
||||
const updated = await updateConfig({
|
||||
minUploadRateBytesPerSec: minKBs * 1024,
|
||||
maxUploadRateBytesPerSec: maxKBs * 1024,
|
||||
clientProfile,
|
||||
announcePort,
|
||||
});
|
||||
dispatch("saved", updated);
|
||||
dispatch("close");
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onBackdrop(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) dispatch("close");
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") dispatch("close");
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={onKeydown} />
|
||||
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div class="backdrop" on:click={onBackdrop} role="dialog" aria-modal="true" aria-label="Settings">
|
||||
<div class="modal">
|
||||
<header>
|
||||
<h2>Settings</h2>
|
||||
<button class="close" on:click={() => dispatch("close")} aria-label="Close">✕</button>
|
||||
</header>
|
||||
|
||||
{#if !config}
|
||||
<p class="loading">Loading…</p>
|
||||
{:else}
|
||||
<form on:submit|preventDefault={save}>
|
||||
|
||||
<fieldset>
|
||||
<legend>Upload Speed</legend>
|
||||
<p class="hint">
|
||||
Each torrent picks a random base rate in this range on start.<br>
|
||||
Changes apply to newly added torrents.
|
||||
</p>
|
||||
|
||||
<div class="row">
|
||||
<label for="min-speed">Min (KB/s)</label>
|
||||
<input
|
||||
id="min-speed"
|
||||
type="number"
|
||||
min="1"
|
||||
max={maxKBs}
|
||||
bind:value={minKBs}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label for="max-speed">Max (KB/s)</label>
|
||||
<input
|
||||
id="max-speed"
|
||||
type="number"
|
||||
min={minKBs}
|
||||
bind:value={maxKBs}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="range-display">
|
||||
{minKBs} – {maxKBs} KB/s
|
||||
({(minKBs / 1024).toFixed(1)} – {(maxKBs / 1024).toFixed(1)} MB/s)
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Client</legend>
|
||||
|
||||
<div class="row">
|
||||
<label for="client-profile">Impersonate</label>
|
||||
<select id="client-profile" bind:value={clientProfile}>
|
||||
<option value="qbittorrent">qBittorrent</option>
|
||||
<option value="transmission">Transmission</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label for="announce-port">Announce Port</label>
|
||||
<input
|
||||
id="announce-port"
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
bind:value={announcePort}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{#if error}
|
||||
<p class="error">{error}</p>
|
||||
{/if}
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="btn-secondary" on:click={() => dispatch("close")}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn-primary" disabled={saving}>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a4a;
|
||||
border-radius: 10px;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
color: #a78bfa;
|
||||
}
|
||||
|
||||
.close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #8888aa;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
.close:hover { color: #d0d0f0; }
|
||||
|
||||
fieldset {
|
||||
border: 1px solid #2a2a4a;
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
legend {
|
||||
color: #8888aa;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.8rem;
|
||||
color: #6666aa;
|
||||
margin: 0 0 0.75rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 0.875rem;
|
||||
color: #9999bb;
|
||||
width: 120px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
input[type="number"],
|
||||
select {
|
||||
flex: 1;
|
||||
background: #0f0f1a;
|
||||
border: 1px solid #3a3a5a;
|
||||
border-radius: 5px;
|
||||
color: #d0d0f0;
|
||||
padding: 0.4rem 0.6rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
input[type="number"]:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: #a78bfa;
|
||||
}
|
||||
|
||||
.range-display {
|
||||
font-size: 0.8rem;
|
||||
color: #a78bfa;
|
||||
margin: 0.5rem 0 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #f87171;
|
||||
font-size: 0.85rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
color: #8888aa;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary {
|
||||
padding: 0.5rem 1.25rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #7c3aed;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) { background: #6d28d9; }
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.btn-secondary {
|
||||
background: #2a2a4a;
|
||||
color: #d0d0f0;
|
||||
}
|
||||
.btn-secondary:hover { background: #3a3a5a; }
|
||||
</style>
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
<script lang="ts">
|
||||
import { formatBytes, formatUptime } from "../lib/api.js";
|
||||
import type { GlobalStats } from "../lib/api.js";
|
||||
|
||||
export let stats: GlobalStats | null = null;
|
||||
</script>
|
||||
|
||||
<div class="stats-panel">
|
||||
<div class="stat">
|
||||
<span class="label">Active Seeders</span>
|
||||
<span class="value">{stats?.activeSeeders ?? "—"}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="label">Total Torrents</span>
|
||||
<span class="value">{stats?.totalTorrents ?? "—"}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="label">Total Uploaded</span>
|
||||
<span class="value">{stats ? formatBytes(stats.totalUploaded) : "—"}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="label">Uptime</span>
|
||||
<span class="value">{stats ? formatUptime(stats.uptimeSeconds) : "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.stats-panel {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
padding: 1rem 1.5rem;
|
||||
background: #1a1a2e;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.75rem;
|
||||
color: #8888aa;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
color: #e0e0ff;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
<script lang="ts">
|
||||
import { formatBytes } from "../lib/api.js";
|
||||
import { removeTorrent } from "../lib/api.js";
|
||||
import type { TorrentState } from "../lib/api.js";
|
||||
|
||||
export let torrents: TorrentState[] = [];
|
||||
|
||||
let removing = new Set<string>();
|
||||
|
||||
async function handleRemove(hash: string) {
|
||||
removing = new Set([...removing, hash]);
|
||||
try {
|
||||
await removeTorrent(hash);
|
||||
torrents = torrents.filter((t) => t.infoHashHex !== hash);
|
||||
} finally {
|
||||
removing.delete(hash);
|
||||
removing = removing;
|
||||
}
|
||||
}
|
||||
|
||||
function statusColor(status: TorrentState["status"]): string {
|
||||
switch (status) {
|
||||
case "running": return "#4ade80";
|
||||
case "stopping": return "#facc15";
|
||||
case "stopped": return "#94a3b8";
|
||||
case "error": return "#f87171";
|
||||
default: return "#94a3b8";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if torrents.length === 0}
|
||||
<p class="empty">No active torrents. Drop a .torrent file above to start seeding.</p>
|
||||
{:else}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Tracker</th>
|
||||
<th>Hash</th>
|
||||
<th>Status</th>
|
||||
<th>Uploaded</th>
|
||||
<th>Seeders</th>
|
||||
<th>Leechers</th>
|
||||
<th>Last Announce</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each torrents as t (t.infoHashHex)}
|
||||
<tr>
|
||||
<td class="name" title={t.name}>{t.name}</td>
|
||||
<td class="hash" title={t.trackerUrl}>{new URL(t.trackerUrl).hostname}</td>
|
||||
<td class="hash" title={t.infoHashHex}>{t.infoHashHex.slice(0, 8)}…</td>
|
||||
<td>
|
||||
<span class="badge" style="color: {statusColor(t.status)}">{t.status}</span>
|
||||
{#if t.error}<span class="err-icon" title={t.error}>⚠</span>{/if}
|
||||
</td>
|
||||
<td class="num">{formatBytes(t.uploaded)}</td>
|
||||
<td class="num">{t.seeders ?? "?"}</td>
|
||||
<td class="num">{t.leechers ?? "?"}</td>
|
||||
<td class="time">
|
||||
{t.lastAnnounce ? new Date(t.lastAnnounce).toLocaleTimeString() : "—"}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
class="remove"
|
||||
disabled={removing.has(t.infoHashHex)}
|
||||
on:click={() => handleRemove(t.infoHashHex)}
|
||||
>
|
||||
{removing.has(t.infoHashHex) ? "…" : "✕"}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.empty {
|
||||
color: #8888aa;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 0.5rem 0.75rem;
|
||||
color: #8888aa;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border-bottom: 1px solid #2a2a4a;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-bottom: 1px solid #1a1a2e;
|
||||
color: #d0d0f0;
|
||||
}
|
||||
|
||||
td.name {
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td.hash, td.time {
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
color: #8888aa;
|
||||
}
|
||||
|
||||
td.num {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.err-icon {
|
||||
margin-left: 0.4rem;
|
||||
color: #f87171;
|
||||
font-size: 0.8rem;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
button.remove {
|
||||
background: none;
|
||||
border: 1px solid #f87171;
|
||||
color: #f87171;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
button.remove:hover {
|
||||
background: #f8717122;
|
||||
}
|
||||
|
||||
button.remove:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
const BASE = "/api";
|
||||
|
||||
export interface TorrentState {
|
||||
infoHashHex: string;
|
||||
name: string;
|
||||
trackerUrl: string;
|
||||
uploaded: number;
|
||||
downloaded: number;
|
||||
left: number;
|
||||
status: "idle" | "running" | "stopping" | "stopped" | "error";
|
||||
lastAnnounce: string | null;
|
||||
lastInterval: number;
|
||||
seeders: number;
|
||||
leechers: number;
|
||||
peers: number;
|
||||
error: string | null;
|
||||
addedAt: string;
|
||||
}
|
||||
|
||||
export interface GlobalStats {
|
||||
activeSeeders: number;
|
||||
totalTorrents: number;
|
||||
totalUploaded: number;
|
||||
uptimeSeconds: number;
|
||||
startedAt: string;
|
||||
}
|
||||
|
||||
export async function listTorrents(): Promise<TorrentState[]> {
|
||||
const res = await fetch(`${BASE}/torrents`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function addTorrent(file: File): Promise<TorrentState> {
|
||||
const form = new FormData();
|
||||
form.append("torrent", file);
|
||||
const res = await fetch(`${BASE}/torrents`, { method: "POST", body: form });
|
||||
if (!res.ok) {
|
||||
const err = await res.json() as { error: string };
|
||||
throw new Error(err.error);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function removeTorrent(hash: string): Promise<void> {
|
||||
await fetch(`${BASE}/torrents/${hash}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
port: number;
|
||||
announcePort: number;
|
||||
minUploadRateBytesPerSec: number;
|
||||
maxUploadRateBytesPerSec: number;
|
||||
clientProfile: "qbittorrent" | "transmission";
|
||||
torrentsDir: string;
|
||||
autoLoad: boolean;
|
||||
}
|
||||
|
||||
export async function getConfig(): Promise<AppConfig> {
|
||||
const res = await fetch(`${BASE}/config`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateConfig(patch: Partial<Pick<AppConfig, "minUploadRateBytesPerSec" | "maxUploadRateBytesPerSec" | "clientProfile" | "announcePort">>): Promise<AppConfig> {
|
||||
const res = await fetch(`${BASE}/config`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json() as { error: string };
|
||||
throw new Error(typeof err.error === "string" ? err.error : JSON.stringify(err.error));
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getStats(): Promise<GlobalStats> {
|
||||
const res = await fetch(`${BASE}/status`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function connectSSE(
|
||||
onTorrent: (state: TorrentState) => void,
|
||||
onStats: (stats: GlobalStats) => void,
|
||||
onTorrents: (torrents: TorrentState[]) => void,
|
||||
): EventSource {
|
||||
const es = new EventSource(`${BASE}/status/stream`);
|
||||
|
||||
es.addEventListener("torrent", (e) => {
|
||||
onTorrent(JSON.parse(e.data));
|
||||
});
|
||||
|
||||
es.addEventListener("stats", (e) => {
|
||||
onStats(JSON.parse(e.data));
|
||||
});
|
||||
|
||||
es.addEventListener("torrents", (e) => {
|
||||
onTorrents(JSON.parse(e.data));
|
||||
});
|
||||
|
||||
return es;
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
|
||||
return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
export function formatUptime(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
return `${h}h ${m}m ${s}s`;
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { mount } from "svelte";
|
||||
import App from "./App.svelte";
|
||||
|
||||
const app = mount(App, {
|
||||
target: document.getElementById("app")!,
|
||||
});
|
||||
|
||||
export default app;
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
export default {
|
||||
preprocess: vitePreprocess(),
|
||||
};
|
||||
|
|
@ -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",
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue