Auto-thanks bot for private trackers, driven by Radarr/Sonarr webhooks and a daily scan of qBittorrent. When a new torrent is grabbed, the bot logs into the tracker site and calls its "thanks" button over HTTP, without a browser.
- Webhook-driven: receives
Grabevents from Radarr and Sonarr, looks up the torrent's comment in qBittorrent, parses the tracker URL, and thanks the upload. - Daily scan: at a configurable hour, walks every torrent in qBittorrent and thanks any that haven't been thanked yet.
- Per-site session reuse: one cookie jar per tracker, so login happens once and stays cached on disk.
- Per-site serial queue: concurrent webhook bursts are serialized per tracker so two grabs never log in at once.
- Prometheus metrics on
/metrics. - Graceful shutdown: in-flight thank tasks finish before exit (30 s timeout).
- Node.js ≥ 26.8.1 (or Docker).
- A running qBittorrent instance (WebUI enabled).
- Radarr and/or Sonarr to send webhooks (optional — the daily scan works on its own).
- An account on each tracker you want to thank.
All configuration is via environment variables. See .env.example.
| Variable | Required | Default | Description |
|---|---|---|---|
WEBHOOK_PORT |
no | 3000 |
HTTP port for the webhook server. |
WEBHOOK_SECRET |
recommended | — | If set, POST /webhook/* requires header X-Webhook-Secret: <value>. If unset, the endpoints are unauthenticated and a warning is logged at startup. |
QBIT_URL |
yes | — | qBittorrent WebUI base URL (e.g. http://qbit:8080). |
QBIT_API_KEY |
preferred (qBit ≥ 5.2) | — | API key. If set, takes precedence over username/password. |
QBIT_USERNAME |
if no API key | — | qBittorrent WebUI username. |
QBIT_PASSWORD |
if no API key | — | qBittorrent WebUI password. |
<ID>_USERNAME |
per site | — | Tracker username, where <ID> is the Site id from sites.json uppercased with - replaced by _ (see Sites). |
<ID>_PASSWORD |
per site | — | Tracker password (same convention as above). |
SITES_CONFIG_PATH |
no | ./config/sites.json (source) / /app/config/sites.json (Docker) |
Path to the Sites config file. |
CACHE_DIR |
no | ./.cache |
Where each Site's session cookies are stored. |
SCAN_ENABLED |
no | true |
Run the daily scan. Set to false to disable. |
SCAN_HOUR |
no | 3 |
Hour (0–23) at which the daily scan runs. An out-of-range value aborts startup. |
SCAN_ON_START |
no | false |
Run a scan immediately on startup. |
SCAN_DELAY_MS |
no | 1000 |
Pause between consecutive calls to a Site during a scan. Keeps a full scan from arriving as one burst, which a private tracker may read as abuse. |
The Sites the bot operates on are defined in an operator-supplied
sites.json. Source code does not ship with any Site identifier.
{
"sites": [
{
"id": "example",
"base_url": "https://tracker.example.com"
}
]
}| Field | Required | Default | Notes |
|---|---|---|---|
id |
yes | — | Must match ^[a-z][a-z0-9-]{0,31}$. Cannot be a reserved word (serve, scan, help, version, init, list, add, remove, login, test). Used as cache directory name, metrics label, log prefix, and credential env var prefix. |
base_url |
yes | — | Full URL of the Site. Normalized at load (host lowercased, trailing slash stripped). Two Sites cannot share the same normalized base_url. |
| Mode | Path |
|---|---|
| Docker (default) | /app/config/sites.json (mount your file or volume here) |
| From source (default) | <repo>/config/sites.json |
| Override (any mode) | Set SITES_CONFIG_PATH |
A starter file is provided at config/sites.example.json.
For each Site id, set <ID>_USERNAME and <ID>_PASSWORD environment
variables, where <ID> is the id uppercased with - replaced by _. For
example, id: my-tracker requires MY_TRACKER_USERNAME and
MY_TRACKER_PASSWORD. Missing credentials abort startup.
docker run -d --name tracker-thanks-bot \
-p 3000:3000 \
-v tracker-thanks-cache:/app/.cache \
-v /path/to/your/sites.json:/app/config/sites.json:ro \
-e WEBHOOK_SECRET=your-shared-secret \
-e QBIT_URL=http://qbittorrent:8080 \
-e QBIT_API_KEY=your-qbit-api-key \
-e <ID>_USERNAME=... -e <ID>_PASSWORD=... \
ghcr.io/alorle/tracker-thanks-bot:latestReplace <ID> with whatever you named each Site in sites.json (uppercased,
- → _). Repeat the username/password pair for every Site.
Images are published to GHCR on every push to main.
nvm use # uses .node-version (26.8.1)
npm ci
npm run build
node dist/index.js serveThank a specific torrent without running the server:
node dist/index.js <site> <torrentId> [<torrentId> ...]Where <site> is a Site id defined in your sites.json.
Run a one-shot scan and exit:
node dist/index.js scanIn Docker, run these one-off commands with docker run --init: only serve
handles SIGTERM and SIGINT itself.
In each app, go to Settings → Connect → Add → Webhook:
- URL:
http://<host>:3000/webhook/radarr(or/webhook/sonarr). - Method: POST.
- Triggers: enable On Grab only.
- Headers: add
X-Webhook-Secret=<your WEBHOOK_SECRET>if you set one (strongly recommended if the server is reachable beyond your LAN).
| Method | Path | Description |
|---|---|---|
POST |
/webhook/radarr |
Radarr Grab webhook. Requires X-Webhook-Secret if WEBHOOK_SECRET is set. |
POST |
/webhook/sonarr |
Sonarr Grab webhook. Same auth as above. |
GET |
/health |
Liveness probe. Returns 200 {"status":"healthy"}. |
GET |
/metrics |
Prometheus metrics. |
Key metrics exposed (all prefixed with tracker_):
webhooks_received_total{source,event_type}webhook_processing_duration_seconds{source,site}torrents_thanked_total{site},torrents_skipped_total{site,reason},torrents_errored_total{site}—reasonis one ofno_button,already_thanked,quota_exhausted,not_eligible,rejectedthank_duration_seconds{site}scans_completed_total{status},scan_duration_seconds,scan_last_torrents_processed{result}qbittorrent_api_duration_seconds{endpoint},qbittorrent_api_errors_total{endpoint}logins_total{site,status}- Plus the default Node.js process metrics.
A Site can take the thanks, or turn it down with the button still on offer. It says so in its own prose, in whatever language it is installed in, and the bot has to decide what that prose meant before it can act on it.
Two of those refusals come from Livewire rather than from the Site — Component payload was altered! and Wrong component! — and mean the bot built a bad request. Those are matched literally, with no network call, and counted as errors rather than skips.
The rest are the Site's own words, and are recorded as rejected.