- Go 95.8%
- Nix 4.2%
Debugging the "stuck at 50% with no logs" incident exposed how
little the shim says about what it's doing. The reconciler would
iterate hundreds of torrents and emit at most a handful of debug
skip lines; the transfer worker stayed silent between receiving
a job and "transfer complete" minutes later; the HTTP layer said
nothing about what *arr was asking for.
Add the following, all at sensible levels:
INFO (always on):
* "reconcile: enqueue" — per torrent accepted for pulling
(hash, name, size, remote_path, relpath). Low-rate, happens
once per new torrent — makes it obvious at a glance which
torrents the shim has decided to pull.
* "transfer start" — when the worker dequeues and spawns rclone.
Includes pid so operators can strace/kill the child.
DEBUG (log-level=debug):
* "reconcile: skipping incomplete torrent" — previously silent.
Now visible so you can tell when qbit hasn't finished yet.
* "reconcile: skipping already-completed torrent" — previously
silent. Visible so you can tell when a local marker exists.
* "reconcile: tick done" — per-tick counters (upstream count,
enqueued, each skip bucket). Turns the stream of per-torrent
debug lines into a scoreboard.
* "transfer progress" — every ~5s during an active rclone run
(bytes, total, pct, speed, elapsed). Lets you see a long
transfer is actually moving, and diagnose stalls that aren't
ending in "rclone failed".
* "http" — per-request line (method, path, status, bytes,
duration, remote). *arr polls /torrents/info hot, so this
is debug-only by design.
|
||
|---|---|---|
| cmd/qbit-bridge | ||
| docs | ||
| internal | ||
| nix | ||
| .envrc | ||
| .gitignore | ||
| flake.lock | ||
| flake.nix | ||
| go.mod | ||
| go.sum | ||
| HANDOVER.md | ||
| README.md | ||
| renovate.json | ||
qbit-bridge
A qBittorrent Web API shim that sits between *arr apps (Sonarr, Radarr, …)
and a qBittorrent running on a remote seedbox. It proxies the API calls
*arr makes, pulls completed torrents to the local host over SFTP
(replacing sshfs / syncthing / rclone-mount), and rewrites /torrents/info
so *arr sees one honest progress bar covering both the seedbox
download and the seedbox→local transfer. No more "100% on seedbox,
missing locally" gap.
Single-operator, single-seedbox tool. No web UI, no multi-user support.
Quick start
Add this flake as an input in your NixOS config:
# flake.nix of your system config
{
inputs.qbit-bridge.url = "git+https://git.matt.you/matt/qbit-bridge";
inputs.qbit-bridge.inputs.nixpkgs.follows = "nixpkgs";
# ...
}
Enable the service on the host that will run the shim:
# host module
{
imports = [ inputs.qbit-bridge.nixosModules.default ];
services.qbit-bridge = {
enable = true;
upstreamUrl = "https://seedbox.example.com/qbittorrent";
seedbox = {
host = "seedbox.example.com";
user = "matt";
identityFile = "/run/secrets/qbit-bridge-ssh-key"; # sops-nix / whatever
};
outputDir = "/mnt/media/downloads";
};
}
Point *arr at http://127.0.0.1:46881 instead of the seedbox directly.
Enter any username/password — the shim accepts anything and forwards to
upstream using upstreamUrl. See docs/configuration.md
for the full option reference, *arr-side setup, and troubleshooting.
Outputs
packages.${system}.default— the shim binary.nixosModules.default— the NixOS module. Auto-wires the package; just setservices.qbit-bridge.enable = true.checks.${system}.tests— runs the Go unit suite undernix flake check.
Local dev
nix develop
go test -race ./...
Integration test (requires Docker or Podman):
# with podman:
export DOCKER_HOST=unix:///run/user/$(id -u)/podman/podman.sock
export TESTCONTAINERS_RYUK_DISABLED=true
go test -tags=integration -race ./internal/integration/...
Design
docs/specs/2026-04-19-internals.md— spec.docs/plans/2026-04-19-implementation.md— implementation plan.docs/configuration.md— operator reference.