qBittorrent Automation
Two scripts against the same qBittorrent WebUI, sharing one config:
tracker_remover.py— the daemon. Polls qBittorrent and strips trackers from active downloads.torrent_stopper.py— the backstop. Stops any download that reaches 2% with a tracker still attached and announcing, because that is a strip that did not take. See Stopping downloads that still announce.
Everything between here and that section describes the daemon.
| File | Purpose |
|---|---|
tracker_remover.py | The daemon |
torrent_stopper.py | The backstop sweep |
install.sh | Installer — renders the daemon’s unit and wires secrets |
qbt-tracker-remover.service | systemd unit template (placeholder tokens) |
qbt-torrent-stopper.service | systemd unit template for the sweep |
qbt-torrent-stopper.timer | Timer that drives the sweep |
requirements.txt | Python dependencies |
.env.example | Reference for every supported variable |
The tracker remover
Section titled “The tracker remover”A daemon that polls qBittorrent and strips trackers from active downloads. It posts a Discord alert when the connection to qBittorrent drops and another when it returns.
By default it removes every tracker from a download once it is past 1%, because maintaining a blocklist by name was whack-a-mole — the same trackers reappear under new domains. DHT, PeX and LSD are left alone; qBittorrent lists them alongside real trackers but they are not removable entries. Swarm discovery therefore continues over DHT/PeX after a strip.
Two escapes from the default:
QB_KEEP_TRACKERS— patterns never removed. Set this if you use private trackers: dropping their announce URL breaks the torrent and stops your ratio being reported.QB_REMOVE_ALL_TRACKERS=false— go back to removing only the namedQB_UNWANTED_TRACKERS.
Alerts are edge-triggered: one red embed on the drop, silence across every retry, one green embed with the outage duration on recovery. Per-torrent processing errors are logged but never sent to Discord.
Configuration
Section titled “Configuration”All configuration is environment variables. Only QB_PASSWORD is required — the script
exits 1 with a printed reference if it is missing.
| Variable | Required | Default |
|---|---|---|
QB_PASSWORD | yes | — |
QB_DISCORD_WEBHOOK | no | unset → notifications disabled, service still runs |
QB_HOST | no | 192.168.1.29 |
QB_PORT | no | 21287 |
QB_USERNAME | no | admin |
QB_CHECK_INTERVAL | no | 2 (seconds) |
QB_REMOVE_ALL_TRACKERS | no | true — strip every tracker |
QB_KEEP_TRACKERS | no | empty — nothing is exempt |
QB_UNWANTED_TRACKERS | no | the trackers in .env.example; used only when QB_REMOVE_ALL_TRACKERS=false |
QB_LOG_LEVEL | no | INFO (DEBUG adds per-torrent detail) |
QB_LOG_DIR | no | ~/Automation/logs (the .deb sets /var/log/…) |
Both tracker lists are matched as substrings anywhere in the tracker URL, and
QB_KEEP_TRACKERS is checked first. The mode in effect is logged at startup, so
journalctl -u qbt-tracker-remover shows which one a running service is using.
Logs go to journald and a file under QB_LOG_DIR, which follows the service user’s
home rather than WorkingDirectory. The script rotates its own file — 10 MB × 5, capped
around 60 MB — so no logrotate config is involved. At INFO it logs only real events plus
a 5-minute heartbeat; per-poll detail is at DEBUG.
Install
Section titled “Install”Runs on any Linux host with python3 and systemd — it only needs to reach
QB_HOST:QB_PORT. Prefer apt unless you are developing the script.
From the Nexus APT repo
Section titled “From the Nexus APT repo”sudo apt update && sudo apt install qbt-tracker-removersudo qbt-tracker-remover configureconfigure prompts for each setting, writes the env file and offers to start the service;
re-run it any time. Prompting lives in a command rather than postinst because dpkg
maintainer scripts cannot prompt — a script waiting on stdin would hold the dpkg lock. For
scripted installs, write /etc/qbt-tracker-remover/env directly; it is a conffile, so it
survives upgrades.
The package runs as its own system user with a venv under /usr/lib/qbt-tracker-remover,
logs to /var/log/qbt-tracker-remover, and is enabled but not started until a
credential exists. Source is in
packages/debian-ubuntu/qbt-tracker-remover/.
From a checkout with install.sh
Section titled “From a checkout with install.sh”The same daemon run in place from a checkout, as whichever user you choose. If a host has
both, the installer’s unit in /etc/systemd/system shadows the packaged one in
/usr/lib/systemd/system. Pick one per host:
sudo systemctl disable --now qbt-tracker-removersudo rm /etc/systemd/system/qbt-tracker-remover.servicesudo systemctl daemon-reload && sudo systemctl enable --now qbt-tracker-removerClone the repo, then preview and install. --dry-run writes nothing and prints the unit
and env file it would create, with secrets masked:
cd <checkout>/utils/qbittorrent-automation./install.sh --dry-runBWS_ACCESS_TOKEN='<token>' ./install.sh # or: ./install.sh --secrets envfileThat resolves the service user and checkout path itself, creates a venv outside the
checkout, writes /etc/qbt-tracker-remover/env as 640 root:<user>, validates the unit,
then enables and starts it. On an EU Bitwarden account add
--bws-server-base https://vault.bitwarden.eu — the default US endpoint answers an
EU-issued token with 400 invalid_client.
Never edit tracker_remover.py on the host; changes flow commit → push → git pull →
restart.
Options
Section titled “Options”| Flag | Default |
|---|---|
--user NAME | current user (id -un) |
--repo-dir PATH | git rev-parse --show-toplevel |
--unit-name NAME | qbt-tracker-remover |
--unit-dir PATH | /etc/systemd/system |
--config-dir PATH | /etc/<unit-name> |
--secrets bws|envfile | bws |
--bws-bin PATH | /usr/bin/bws |
--bws-server-base URL | https://vault.bitwarden.eu |
--venv PATH | <service-home>/.venvs/<unit-name> |
--system-python | off (a venv is created) |
--python PATH | /usr/bin/python3, implies --system-python |
--skip-deps | off |
--qb-host, --qb-port, --qb-username, --check-interval | keep the script’s default |
--remove-all-trackers, --keep-trackers, --unwanted-trackers | keep the script’s default |
--discord-webhook URL | unset (alerts disabled) |
--dry-run | off |
--status | report unit state and exit |
--uninstall | remove the unit |
--no-start | enable without starting |
--force | skip all prompts, including the settings ones |
Secrets are never flags — a flag is visible in ps and lands in shell history. They
come from BWS_ACCESS_TOKEN, QB_PASSWORD and QB_DISCORD_WEBHOOK in the environment or
from a prompt. --discord-webhook is the exception, a webhook being a capability rather
than a credential.
On a terminal the installer prompts for each application setting with the value in effect
today in brackets, read from the env file or from tracker_remover.py itself. Enter
writes nothing — an unconfigured key stays out of the env file so the script’s default
keeps applying. Re-running is safe: it merges only the keys you supply and backs up any
existing unit to <unit>.service.backup.
Verify
Section titled “Verify”./install.sh --status # resolved config, read back from the installed unitjournalctl -u qbt-tracker-remover -fA clean first connect posts nothing to Discord — it is not a “recovery”. To test
alerting end to end, point QB_HOST at 127.0.0.1 with systemctl edit, restart, expect
one red embed, then systemctl revert and restart for the green one.
Update
Section titled “Update”cd <checkout> && git pullsudo systemctl restart qbt-tracker-removerThe service runs the old code until the restart. If the pull changed
qbt-tracker-remover.service, re-run ./install.sh instead — the unit is copied into
/etc at install time and never read from the checkout.
Uninstall
Section titled “Uninstall”./install.sh --uninstall stops, disables and removes the unit, then asks before deleting
the config directory. The checkout, venv and logs are left alone.
Stopping downloads that still announce
Section titled “Stopping downloads that still announce”torrent_stopper.py exists because the strip can fail quietly. If the daemon is down,
wedged, or lost its connection at the wrong moment, a torrent sails past 1% with its
trackers intact and keeps announcing to them for the whole download. The sweep catches
that: any torrent in the downloading state at or past 2% that still has an
active tracker is stopped.
Three things are deliberately not treated as active trackers:
- the
** [DHT] **/** [PeX] **/** [LSD] **rows — qBittorrent lists them beside real trackers, but they carry no announce URL; - anything matching
QB_KEEP_TRACKERS— those are kept on purpose, so a private torrent is never stopped for having the announce URL it needs; - trackers in status
4(contacted and failing) — attached, but not announcing.
So a torrent the daemon stripped cleanly shows only DHT/PeX and is left alone. Stopping
is the whole action: nothing is deleted, and the torrent resumes with
qbittorrent-cli/the WebUI once you have dealt with the tracker.
Configuration
Section titled “Configuration”Everything in the table at the top of this file still applies — the endpoint,
QB_PASSWORD, QB_KEEP_TRACKERS, QB_DISCORD_WEBHOOK, QB_LOG_LEVEL and QB_LOG_DIR
are read by both scripts. On top of those:
| Variable | Default | Meaning |
|---|---|---|
QB_STOP_PROGRESS_PERCENT | 2 | Stop at or past this percentage |
QB_STOP_ACTIVE_TRACKER_STATUSES | 1,2,3 | WebUI tracker statuses that count as active |
QB_STOP_CHECK_INTERVAL | 0 | 0 = one sweep per run; any positive value polls as a daemon |
QB_STOP_DRY_RUN | false | Report what would be stopped, change nothing |
Tracker statuses are qBittorrent’s own: 0 disabled (the pseudo-rows), 1 not
contacted, 2 working, 3 updating, 4 not working.
--progress-percent, --interval, --once, --dry-run and --log-level override the
environment for a single run; torrent_stopper.py --help lists them.
Logs go to torrent_stopper.log beside tracker_remover.log, rotating the same way.
Every stop is logged at WARNING with the offending tracker URLs, so a quiet log means
the daemon is doing its job. DEBUG adds a line per torrent skipped and per tracker
ignored.
Run it by hand
Section titled “Run it by hand”From the checkout, with the same secrets the daemon uses. Start with --dry-run —
this script pauses live downloads:
cd <checkout>/utils/qbittorrent-automationbws run --access-token "$BWS_ACCESS_TOKEN" -- python3 torrent_stopper.py --dry-runbws run --access-token "$BWS_ACCESS_TOKEN" -- python3 torrent_stopper.pyExit status is 0 for a completed sweep, 1 for a configuration error, 2 when
qBittorrent could not be reached after three attempts ten seconds apart.
Install the timer
Section titled “Install the timer”install.sh renders the daemon’s unit only; the sweep’s two units are filled in by
hand. They take the same placeholder tokens, and the values are already in the installed
daemon unit — read them back with ./install.sh --status or
systemctl cat qbt-tracker-remover:
cd <checkout>/utils/qbittorrent-automationsed -e "s|@SERVICE_USER@|$(id -un)|" \ -e "s|@REPO_DIR@|$(git rev-parse --show-toplevel)|" \ -e "s|@ENV_FILE@|/etc/qbt-tracker-remover/env|" \ -e "s|@EXEC_START@|/usr/bin/bws run -- $HOME/.venvs/qbt-tracker-remover/bin/python torrent_stopper.py|" \ qbt-torrent-stopper.service | sudo tee /etc/systemd/system/qbt-torrent-stopper.service >/dev/nullsudo cp qbt-torrent-stopper.timer /etc/systemd/system/sudo systemd-analyze verify /etc/systemd/system/qbt-torrent-stopper.servicesudo systemctl daemon-reloadsudo systemctl enable --now qbt-torrent-stopper.timer@EXEC_START@ must match how the daemon gets its secrets: drop the bws run -- prefix
if the env file holds QB_PASSWORD directly. The service is driven by the timer — enable
the timer, not the service, or the sweep runs once at boot and never again.
Verify, then keep it honest for a few cycles before trusting it:
systemctl list-timers qbt-torrent-stopper.timersudo systemctl start qbt-torrent-stopper.service # force one sweep nowjournalctl -u qbt-torrent-stopper -fTo watch without acting, add Environment=QB_STOP_DRY_RUN=true via
sudo systemctl edit qbt-torrent-stopper.service and systemctl revert when you are
satisfied.
As a daemon instead
Section titled “As a daemon instead”If a timer is not wanted, set QB_STOP_CHECK_INTERVAL to a positive number and run the
script under a Type=simple unit like the tracker remover’s. In that mode it also does
the daemon’s edge-triggered Discord alerting on connection loss and recovery; one-shot
runs skip it, because a run holds no state across invocations and would alert on every
tick of an outage. Systemd carries that signal instead, through the failed unit.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Cause |
|---|---|
Required environment variable 'QB_PASSWORD' is not set, unit failed after 5 tries | Secrets never reached the process. Check --status for the real EnvironmentFile, and that the BWS secret is named exactly QB_PASSWORD. |
status=200/CHDIR | WorkingDirectory wrong or unreadable by the service user. Re-run ./install.sh with the right --repo-dir. |
Failed to determine user credentials | The unit names a user that does not exist. Re-run with --user <name>. |
status=203/EXEC | /usr/bin/bws or /usr/bin/python3 not at that path — check command -v bws python3. |
ModuleNotFoundError: No module named 'qbittorrentapi' | Installed for the wrong user. Re-run without --skip-deps. |
ensurepip is not available | python3.<minor>-venv missing and no apt-get to install it. Install it, or use --system-python. |
| Journal minutes behind | Environment=PYTHONUNBUFFERED=1 missing from the unit — re-run ./install.sh. |
Error during processing repeatedly, no Discord alert | Correct: the connection is alive, so this is a processing fault. Read the journal for the exception. |
| No alert during a real outage | QB_DISCORD_WEBHOOK unset, or the alert already fired earlier in the same outage — one per outage by design. |
EnvironmentFile ... Permission denied | Not readable by the service user; re-run ./install.sh, which sets 640 root:<user>. |
400 invalid_client from bws | EU-issued token on the US endpoint. Re-run with --bws-server-base https://vault.bitwarden.eu. |
| A download stalls right after its trackers are stripped | Private torrent — it has no DHT/PeX to fall back on. Add its announce host to QB_KEEP_TRACKERS and re-add the tracker in qBittorrent. |
Failed to remove trackers from … | Per-torrent fault (tracker or torrent gone between the read and the removal). Logged and skipped; the next poll retries. |
| The sweep stops a private torrent | Its announce host is not in QB_KEEP_TRACKERS. Add it — the same list exempts a tracker from both the strip and the stop. |
| The sweep stops nothing while trackers are visibly attached | They are all in status 4, or below QB_STOP_PROGRESS_PERCENT. Re-run with --log-level DEBUG, which names every tracker it ignored. |
qbt-torrent-stopper.service is inactive (dead) and never runs | The timer was not enabled. systemctl enable --now qbt-torrent-stopper.timer; the service is Type=oneshot and dead between sweeps. |
Sweep exits 2 every time | qBittorrent unreachable after three attempts. Same causes as a daemon outage — check the endpoint in the env file the unit points at. |
Security note
Section titled “Security note”The qBittorrent password was once hardcoded in tracker_remover.py and is still in git
history at commit b81076c. Rotate it in qBittorrent and store the new value in BWS —
removing it from the working tree does not remediate the exposure.