Skip to content

Architecture Internals

Stephen Cox edited this page Sep 14, 2026 · 5 revisions

This page maps the codebase itself — which module does what, and how a job crosses process boundaries from HTTP request to qBittorrent API call. If you're looking for the user-facing condition/action evaluation model instead, see Rules Architecture.

Module Map

Module Responsibility
cli.py Entry point. Parses args and dispatches to either server mode (--serve) or client mode (default)
arguments.py Centralized argparse setup shared by both CLI modes
server.py Flask HTTP API (run under Gunicorn) — job submission, status, health, stats endpoints; API-key auth (see HTTP API Reference); also registers the read-only /dashboard* routes (see Dashboard) and, when enabled, /metrics (see Metrics)
templates/ Jinja2 templates for the /dashboard* routes — server-rendered HTML, no frontend build step
metrics.py Optional Prometheus instrumentation (see Metrics) — always safe to import even when prometheus_client isn't installed, since the actual dependency is only ever imported lazily, deferred until metrics.init() runs
worker.py Background thread that dequeues jobs and executes them via the engine; graceful shutdown handling
scheduler.py Background thread that enqueues a job when a configured cron entry fires — see Scheduling
queue_manager.py Abstract job-queue interface (JobStatus constants, enqueue/dequeue/status contract)
queue_backends/ Concrete implementations of that interface — SQLite (default) and Redis
engine.py RulesEngine (orchestrator), ConditionEvaluator, and ActionExecutor — the actual rule evaluation and action dispatch logic
resolver.py RuleResolver — expands $ref: and substitutes ${vars.x} before a rule reaches the engine
config.py Loads and validates config.yml/rules.yml; environment variable + _FILE secret resolution
api.py Thin wrapper around the qbittorrent-api package — multi-version qBittorrent support
errors.py User-facing exception types with actionable messages (no raw stack traces surfaced to users)
logging.py Shared logging setup used by every other module
utils.py Small stateless helpers: parse_tags, is_older_than/is_newer_than, is_larger_than/is_smaller_than
__version__.py Single source of truth for the package version — see Release Process

Process Boundaries

There's one binary (qbt-rules) with two roles, chosen by how it's invoked:

  • Server mode (--serve): starts server.py's Flask app under Gunicorn, plus worker.py and scheduler.py background threads in the same process. This is what runs continuously inside the Docker container.
  • Client mode (the default, no --serve): submits a job to a running server over HTTP and, if given a --client-server-url/--client-api-key, optionally waits for and prints the result (--wait). This is what runs when you docker exec qbt-rules qbt-rules --context X --wait, or when a script hits the HTTP API with curl directly instead.

Nothing in client mode touches qBittorrent directly — it only talks to the server's HTTP API. All qBittorrent access happens inside the server process, via the worker.

When metrics are enabled, metrics.py runs into the exact same Gunicorn preload_app/fork consideration scheduler.py already had to solve (see Scheduling) — a naive in-process counter would be silently forked into server.workers independent, unsynchronized copies. See Metrics for how it's handled.

Request Lifecycle

  1. A job arrives at server.py (POST /api/execute) or via the CLI client, which is really just an HTTP request under the hood — or scheduler.py enqueues one directly on a cron tick, bypassing the HTTP layer entirely (no API key check needed, since it's not going over the network). The API key is checked for every other submission path before anything else happens.
  2. Either way, the job (context, optional hash) reaches queue_manager.py, which persists it through whichever backend is configured (queue_backends/sqlite_queue.py or redis_queue.py) with status pending.
  3. worker.py's background thread dequeues the job, marks it processing, and loads rules.yml through config.py — which also initializes resolver.py's RuleResolver if a refs: block is present.
  4. For each rule, the resolver expands any $ref:/${vars.x} before the rule reaches engine.py. The engine's ConditionEvaluator checks enabled, then context, then evaluates conditions: per torrent (fetched via api.py); matches are handed to ActionExecutor, which calls back into api.py to actually act on qBittorrent.
  5. The worker updates the job's status (completed/failed) and result summary back through queue_manager.py, which is what --job-status/--list-jobs (via the client) or GET /api/jobs read back.

Queue Backend Abstraction

queue_manager.py defines the contract; queue_backends/ provides SQLite and Redis implementations behind it. worker.py and server.py only ever talk to the abstract interface, so switching queue.backend between sqlite and redis in config.yml doesn't touch either of them — only which backend module gets instantiated at startup changes. Adding a third backend (e.g. Postgres) means implementing the same interface in a new module under queue_backends/ and wiring it into that instantiation point; no changes to worker.py/server.py should be needed.

🎯 qbt-rules

GitHub • Releases


📚 Getting Started

📖 Core Concepts

🔒 Security

🔧 Configuration

🆘 Help & Support

🧑‍💻 For Developers


🔑 Key Features

  • 🚀 Client-server architecture (HTTP API + worker)
  • 📦 Persistent job queue (SQLite or Redis)
  • ♻️ Reusable references ($ref, ${vars.x})
  • 🐳 Docker-first deployment

🔗 Quick Links

Clone this wiki locally