feat(dl): replace Transmission with qBittorrent
ci / web (push) Successful in 32s
ci / rust (push) Successful in 3m26s
e2e / e2e (push) Failing after 5m2s
ci / image (push) Successful in 4m1s

qBittorrent's add call answers `Ok.` and nothing else — no hash, no name,
no duplicate signal — so arr derives the v1 infohash from the magnet or the
`.torrent` bytes before the call and looks the torrent up by it. That also
drops Transmission's numeric torrent id: the hash is the only identity now.

The reaper needs "stopped because a share limit was reached", and
qBittorrent's state field cannot tell that apart from a hand-paused torrent.
So the ratio and idle counters are checked against the limits arr set, and a
torrent stopped by a global limit reads as still seeding rather than being
deleted.

The WebUI needs a login, so `ARR_QBITTORRENT_USERNAME` and
`ARR_QBITTORRENT_PASSWORD` join the env-only secrets; leaving both unset is
valid for an instance that whitelists arr's subnet.

Verified against qBittorrent 5 (WebAPI 2.15.1) in a container: it rejects
`setShareLimits` without `shareLimitAction`, which 4.x ignores, so it is
always sent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Miguel Palhas
2026-08-29 18:13:50 +01:00
co-authored by Claude Opus 5
parent afe167ca7b
commit ab4f95b166
42 changed files with 2008 additions and 1203 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ export interface Download {
}
/** §9.8: the live snapshot, degrading to nothing rather than breaking the
* page it decorates — Transmission or the daemon being down is a fact the
* page it decorates — qBittorrent or the daemon being down is a fact the
* signal chain already reports, not something this poll repeats. */
export async function fetchDownloads(): Promise<Download[]> {
try {
+1 -1
View File
@@ -28,7 +28,7 @@ export interface HealthReport {
status: "ok" | "degraded";
version: string;
prowlarr: Check;
transmission: Check;
qbit: Check;
tmdb: Check;
subtitles: SubtitleHealth;
}
+4 -4
View File
@@ -271,7 +271,7 @@ function main() {
const checks = {
tmdb: moduleRefs("tmdb"),
prowlarr: moduleRefs("prowlarr"),
transmission: moduleRefs("transmission"),
qbit: moduleRefs("qbit"),
} as const;
const traces = {
@@ -293,7 +293,7 @@ function main() {
}
// stagger order for the one power-up animation, upstream to downstream
const strikeOrder = [checks.tmdb, checks.prowlarr, master, checks.transmission];
const strikeOrder = [checks.tmdb, checks.prowlarr, master, checks.qbit];
strikeOrder.forEach((refs, index) => {
refs.lamp.style.setProperty("--strike", String(index));
});
@@ -331,7 +331,7 @@ function main() {
setModule(master, degraded ? "degraded" : "ok", degraded ? "warn" : "ok", report.status);
setTrace("master", degraded ? "warn" : "ok");
for (const name of ["tmdb", "prowlarr", "transmission"] as const) {
for (const name of ["tmdb", "prowlarr", "qbit"] as const) {
const check = report[name];
setModule(
checks[name],
@@ -340,7 +340,7 @@ function main() {
check.status,
check.detail,
);
if (name !== "transmission") {
if (name !== "qbit") {
setTrace(name, TONE_BY_STATUS[check.status]);
}
}
+1 -1
View File
@@ -129,7 +129,7 @@ export interface SeasonPackState {
* the release is blacklisted and every episode it covered reopens as a gap.
* Nothing on screen joined those facts, so the season read `0/10` as though
* no grab had ever been tried and the operator found out by opening
* Transmission.
* qBittorrent.
*/
export interface ImportFailure {
release: string;
+1 -1
View File
@@ -308,7 +308,7 @@ body {
grid-area: out;
}
.area-transmission {
.area-qbittorrent {
grid-area: sink;
}