tarr is a small automation service for qBittorrent. It applies tracker- and
category-specific retention rules, removes torrents that are no longer useful,
and keeps a configurable amount of disk space free. Run it once for a single
reconciliation pass or leave it running as a daemon.
The service talks to qBittorrent through its WebUI API. The optional
cleanup_empty_dirs job also needs direct access to the download filesystem.
Configuration is a strictly validated YAML file;
unknown fields and invalid limits stop startup instead of being silently ignored.
Each job is independent and can be enabled or disabled:
| Job | Behavior |
|---|---|
remove_unregistered |
Deletes a torrent and its content after a tracker continuously reports it as unregistered for a configured delay. |
set_seed_limits |
Reconciles qBittorrent ratio, seed-time, limit action, and related share-limit settings for explicitly managed categories. |
remove_stopped |
Removes completed, stopped torrents after a delay only when an active torrent seed-time or ratio limit is met, optionally keeping their downloaded content. Actively seeding torrents are untouched. |
maintain_free_space |
When disk space is below the target, deletes eligible torrents and their content, rechecking qBittorrent's reported free space after each deletion. |
cleanup_empty_dirs |
Prunes old, empty directories under explicitly configured roots, excluding paths referenced by any current torrent. |
Jobs run in the order listed above. set_seed_limits runs before remove_stopped,
which fetches the updated active limits even on the first pass.
The delayed jobs keep their first-seen timestamps in memory. Restarting tarr
resets those timers, and delays greater than zero are only useful in daemon mode.
Use --dry-run to inspect planned deletions and share-limit changes without
mutating qBittorrent or the filesystem.
Requirements are Python 3.12 or newer and a reachable qBittorrent WebUI. Copy the annotated example, enter the WebUI credentials, and start with a dry run:
cp config.example.yaml config.yaml
uv sync
uv run tarr --config config.yaml --dry-runRun a real pass by removing --dry-run, or poll continuously:
uv run tarr --config config.yaml --daemonThe config path defaults to config.yaml. It can also be supplied with -c /
--config or through the CONFIG_FILE environment variable. In daemon mode,
qbittorrent.interval_seconds controls the polling interval and a failed pass is
logged before the next one is attempted.
See config.example.yaml for the complete, documented
schema. Its three top-level sections are:
logging: log level, output format, and optional rotating file output.qbittorrent: WebUI connection, polling interval, and the five job settings.trackers: tracker hostnames, minimum seed-time and ratio rules, and deletion priorities.
The three removal jobs share include/exclude filters. categories: null includes
all categories; otherwise only exact names in the list are included.
ignore_categories always wins over categories. Use YAML null in either list
for torrents that have no category.
set_seed_limits is intentionally opt-in: only exact category names listed under
that job are changed. A category named null manages uncategorized torrents.
Category names are case-sensitive and must be unique.
A torrent is associated with the first configured tracker whose hosts entry
matches an announce hostname or its subdomain. Tracker policies provide a
seed_time_minutes and ratio value. For free-space cleanup, a torrent becomes
eligible after satisfying either value; -1 makes that dimension unlimited, so
it can never make the torrent eligible.
remove_stopped requires a completed download in pausedUP or stoppedUP,
including manually stopped torrents, and at least one satisfied active torrent
seed-time or ratio limit. It uses qBittorrent's resolved max_seeding_time and
max_ratio, including inherited defaults, rather than looking up tracker policies.
This respects limits applied by set_seed_limits as well as manually defined
limits; no matching configured tracker is required. Unlimited limits cannot make
a torrent eligible, and torrents with both limits unlimited are kept.
Its delay starts when the completed torrent is first observed stopped and resets
if it resumes or is no longer completed. A passed delay never bypasses the limits.
When free-space cleanup is needed, unmanaged trackers and excluded categories are skipped.
Eligible torrents are ordered by their tracker's delete_priority, highest first,
then by total swarm seeder count, highest first. Each tracker defaults to
delete_priority: 0; positive and negative integers are supported. Priority only
controls deletion order, never eligibility. With equal priorities, the existing
most-seeded-first behavior is preserved. Torrents are deleted with their content
until qBittorrent reports enough free space or no eligible torrents remain. Free space is re-read after each deletion, so removing a hard link
is not assumed to reclaim the torrent's size while other links remain. Dry runs
still estimate reclaimed space from torrent sizes because nothing is deleted.
For each managed category, seed time and ratio are resolved independently in this order:
- An explicit category value.
- The matching tracker value when
use_tracker_limitsis enabled. - The category's
default_*value. - The job's global
default_*value.
An omitted value continues down that chain. An explicit null preserves the
torrent's current value, while -1 sets an unlimited value. Zero and positive
numbers are applied as limits. The limit action can be Default, Stop,
Remove, RemoveWithContent, or EnableSuperSeeding, with an optional override
per category.
The job also sets the inactive-seeding limit to unlimited and uses MatchAny
semantics. qBittorrent versions before 5.3 already use those semantics implicitly.
No API update is sent when a torrent already matches the resolved state.
qBittorrent's RemoveWithContent action can leave the parent directories that
qui creates for cross-seeds. Enable this independent job to clean them up,
including leftovers from deletions outside tarr:
qbittorrent:
# alongside the connection and other job settings
cleanup_empty_dirs:
enabled: true
directories:
- /downloads/cross-seed
name_pattern: "*--????????"
min_age_minutes: 60The job runs each polling pass, after the other jobs. It only considers immediate
children of the configured roots; it never recursively deletes content or removes
the roots themselves. The default glob matches qui's name--<8-character hash prefix> layout. Customize name_pattern for other naming schemes, or use "*"
to consider every immediate child.
A candidate must be empty and its modification time must be at least
min_age_minutes old. Symlink roots and children are skipped. Every current
torrent's save and content paths are protected, including stopped or downloading
torrents and torrents excluded from other jobs. If the torrent listing fails or
contains missing/relative save paths, no cleanup is performed. Removal uses only
rmdir, so newly added content prevents deletion. --dry-run logs candidates
without removing them; missing mounts and permission failures are logged.
Each entry in directories supports either:
/downloads/cross-seed: the same path in qBittorrent andtarr./downloads/cross-seed:/data/torrents/cross-seed: qBittorrent path first, tarr-local path second. Only the local side is scanned and modified.
Mappings translate torrent save and content paths before checking protection.
Matches use complete path components (not similar string prefixes), and the most
specific source prefix wins when mappings overlap. A torrent saved above a mapped
root conservatively protects that entire local root. Plain and mapped entries
can be mixed. Both sides must be absolute, non-root paths without ..; malformed
mappings and conflicting destinations for the same source are rejected.
tarr must have the storage mounted locally and permission to remove directories,
but the mount paths may differ from qBittorrent's when a mapping is provided.
This job is disabled by default and requires at least one directory when enabled.
It is not suitable for a remote qBittorrent instance whose storage is not mounted
locally.
The included compose.yaml runs both qBittorrent and tarr.
Create config.yaml, change its qBittorrent host to
http://qbittorrent:8080, review the credentials and volume settings, then run:
docker compose up -dThe tarr image runs as UID/GID 999 and reads the mounted configuration from
/config/config.yaml. The compose file builds the local source by default;
remove build if you only want to use ghcr.io/wthueb/tarr:latest.
For directory cleanup, mount the same storage in both containers, and run
tarr with a UID/GID that can remove the folders. Different container paths are
supported. For example, add these settings to the respective services (retaining
their existing mounts):
services:
qbittorrent:
volumes:
- /path/to/downloads:/downloads
tarr:
user: "1000:1000" # match the download directory's ownership/permissions
volumes:
- /path/to/downloads:/data/torrentsFor these mounts, configure tarr with:
qbittorrent:
cleanup_empty_dirs:
enabled: true
directories:
- "/downloads/cross-seed:/data/torrents/cross-seed"The included compose file does not mount downloads by default; enabling cleanup without adding this shared mount will only log a missing-directory warning.
Logs go to stdout in logfmt format. Set logging.format: json for JSON, and set
logging.file to additionally write 10 MiB rotating files; file_count controls
how many are retained. Events include stable ts, level, logger, src, and
msg fields plus job, torrent, limits, and deletion context where relevant.
The repository uses uv for dependency locking and Nix for a reproducible
development shell. nix develop syncs and activates the virtual environment.
The usual checks are:
uv run pytest
uv run ruff check .
uv run ruff format --check .
uv run ty checkSource lives in tarr/, with configuration models in config.py, job
orchestration in main.py, filesystem cleanup in cleanup.py, and structured
logging in logging.py. Tests live in
tests/. Releases are packaged from pyproject.toml and the container
build is defined in Dockerfile.
tarr is available under the MIT License.