Skip to content

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.

FilePurpose
tracker_remover.pyThe daemon
torrent_stopper.pyThe backstop sweep
install.shInstaller — renders the daemon’s unit and wires secrets
qbt-tracker-remover.servicesystemd unit template (placeholder tokens)
qbt-torrent-stopper.servicesystemd unit template for the sweep
qbt-torrent-stopper.timerTimer that drives the sweep
requirements.txtPython dependencies
.env.exampleReference for every supported variable

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 named QB_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.

All configuration is environment variables. Only QB_PASSWORD is required — the script exits 1 with a printed reference if it is missing.

VariableRequiredDefault
QB_PASSWORDyes—
QB_DISCORD_WEBHOOKnounset → notifications disabled, service still runs
QB_HOSTno192.168.1.29
QB_PORTno21287
QB_USERNAMEnoadmin
QB_CHECK_INTERVALno2 (seconds)
QB_REMOVE_ALL_TRACKERSnotrue — strip every tracker
QB_KEEP_TRACKERSnoempty — nothing is exempt
QB_UNWANTED_TRACKERSnothe trackers in .env.example; used only when QB_REMOVE_ALL_TRACKERS=false
QB_LOG_LEVELnoINFO (DEBUG adds per-torrent detail)
QB_LOG_DIRno~/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.

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.

Terminal window
sudo apt update && sudo apt install qbt-tracker-remover
sudo qbt-tracker-remover configure

configure 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/.

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:

Terminal window
sudo systemctl disable --now qbt-tracker-remover
sudo rm /etc/systemd/system/qbt-tracker-remover.service
sudo systemctl daemon-reload && sudo systemctl enable --now qbt-tracker-remover

Clone the repo, then preview and install. --dry-run writes nothing and prints the unit and env file it would create, with secrets masked:

Terminal window
cd <checkout>/utils/qbittorrent-automation
./install.sh --dry-run
BWS_ACCESS_TOKEN='<token>' ./install.sh # or: ./install.sh --secrets envfile

That 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.

FlagDefault
--user NAMEcurrent user (id -un)
--repo-dir PATHgit rev-parse --show-toplevel
--unit-name NAMEqbt-tracker-remover
--unit-dir PATH/etc/systemd/system
--config-dir PATH/etc/<unit-name>
--secrets bws|envfilebws
--bws-bin PATH/usr/bin/bws
--bws-server-base URLhttps://vault.bitwarden.eu
--venv PATH<service-home>/.venvs/<unit-name>
--system-pythonoff (a venv is created)
--python PATH/usr/bin/python3, implies --system-python
--skip-depsoff
--qb-host, --qb-port, --qb-username, --check-intervalkeep the script’s default
--remove-all-trackers, --keep-trackers, --unwanted-trackerskeep the script’s default
--discord-webhook URLunset (alerts disabled)
--dry-runoff
--statusreport unit state and exit
--uninstallremove the unit
--no-startenable without starting
--forceskip 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.

Terminal window
./install.sh --status # resolved config, read back from the installed unit
journalctl -u qbt-tracker-remover -f

A 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.

Terminal window
cd <checkout> && git pull
sudo systemctl restart qbt-tracker-remover

The 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.

./install.sh --uninstall stops, disables and removes the unit, then asks before deleting the config directory. The checkout, venv and logs are left alone.

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.

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:

VariableDefaultMeaning
QB_STOP_PROGRESS_PERCENT2Stop at or past this percentage
QB_STOP_ACTIVE_TRACKER_STATUSES1,2,3WebUI tracker statuses that count as active
QB_STOP_CHECK_INTERVAL00 = one sweep per run; any positive value polls as a daemon
QB_STOP_DRY_RUNfalseReport 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.

From the checkout, with the same secrets the daemon uses. Start with --dry-run — this script pauses live downloads:

Terminal window
cd <checkout>/utils/qbittorrent-automation
bws run --access-token "$BWS_ACCESS_TOKEN" -- python3 torrent_stopper.py --dry-run
bws run --access-token "$BWS_ACCESS_TOKEN" -- python3 torrent_stopper.py

Exit 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.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:

Terminal window
cd <checkout>/utils/qbittorrent-automation
sed -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/null
sudo cp qbt-torrent-stopper.timer /etc/systemd/system/
sudo systemd-analyze verify /etc/systemd/system/qbt-torrent-stopper.service
sudo systemctl daemon-reload
sudo 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:

Terminal window
systemctl list-timers qbt-torrent-stopper.timer
sudo systemctl start qbt-torrent-stopper.service # force one sweep now
journalctl -u qbt-torrent-stopper -f

To 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.

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.

SymptomCause
Required environment variable 'QB_PASSWORD' is not set, unit failed after 5 triesSecrets never reached the process. Check --status for the real EnvironmentFile, and that the BWS secret is named exactly QB_PASSWORD.
status=200/CHDIRWorkingDirectory wrong or unreadable by the service user. Re-run ./install.sh with the right --repo-dir.
Failed to determine user credentialsThe 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 availablepython3.<minor>-venv missing and no apt-get to install it. Install it, or use --system-python.
Journal minutes behindEnvironment=PYTHONUNBUFFERED=1 missing from the unit — re-run ./install.sh.
Error during processing repeatedly, no Discord alertCorrect: the connection is alive, so this is a processing fault. Read the journal for the exception.
No alert during a real outageQB_DISCORD_WEBHOOK unset, or the alert already fired earlier in the same outage — one per outage by design.
EnvironmentFile ... Permission deniedNot readable by the service user; re-run ./install.sh, which sets 640 root:<user>.
400 invalid_client from bwsEU-issued token on the US endpoint. Re-run with --bws-server-base https://vault.bitwarden.eu.
A download stalls right after its trackers are strippedPrivate 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 torrentIts 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 attachedThey 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 runsThe timer was not enabled. systemctl enable --now qbt-torrent-stopper.timer; the service is Type=oneshot and dead between sweeps.
Sweep exits 2 every timeqBittorrent unreachable after three attempts. Same causes as a daemon outage — check the endpoint in the env file the unit points at.

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.