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
+26 -10
View File
@@ -26,15 +26,6 @@ jobs:
e2e: e2e:
runs-on: ubuntu-latest runs-on: ubuntu-latest
services:
transmission:
image: linuxserver/transmission:latest
env:
PUID: "1000"
PGID: "1000"
ports:
- 9091:9091
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -81,6 +72,31 @@ jobs:
ln -sf "$HOME/.cargo/bin/alass-cli" "$HOME/.cargo/bin/alass" ln -sf "$HOME/.cargo/bin/alass-cli" "$HOME/.cargo/bin/alass"
} }
- name: qBittorrent
# Not a `services:` container: qBittorrent has no environment
# variable for the WebUI password and prints a random one per boot,
# so the only scriptable login is a config seeded before first start,
# which a service container cannot be given.
run: |
mkdir -p /tmp/qbt/config/qBittorrent
cat > /tmp/qbt/config/qBittorrent/qBittorrent.conf <<'CONF'
[Preferences]
WebUI\Port=8080
WebUI\AuthSubnetWhitelistEnabled=true
WebUI\AuthSubnetWhitelist=0.0.0.0/0
WebUI\CSRFProtection=false
WebUI\HostHeaderValidation=false
Downloads\SavePath=/downloads
CONF
docker run -d --name arr-e2e-qbittorrent --network host \
-e PUID=1000 -e PGID=1000 -e WEBUI_PORT=8080 \
-v /tmp/qbt/config:/config \
linuxserver/qbittorrent:latest
for _ in $(seq 1 60); do
curl -sf http://127.0.0.1:8080/api/v2/app/version && break
sleep 2
done
- name: build daemon - name: build daemon
# The harness spawns this binary; building it here keeps the compile # The harness spawns this binary; building it here keeps the compile
# out of the first test's boot window. `translate-command` is the # out of the first test's boot window. `translate-command` is the
@@ -89,5 +105,5 @@ jobs:
- name: e2e - name: e2e
env: env:
TRANSMISSION_RPC_URL: http://transmission:9091/transmission/rpc QBITTORRENT_URL: http://127.0.0.1:8080
run: cargo nextest run -p arr-e2e run: cargo nextest run -p arr-e2e
@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "SELECT g.target_id AS \"season_id!: i64\",\n r.name AS \"name!: String\",\n g.infohash AS \"infohash!: String\",\n g.failed_at,\n g.grabbed_at AS \"grabbed_at!: String\"\n FROM grabs g\n JOIN releases r ON r.id = g.release_id\n JOIN seasons s ON s.id = g.target_id\n WHERE g.target_kind = 'season'\n AND g.state = 'failed'\n AND s.series_id = ?\n AND EXISTS (\n SELECT 1 FROM episodes e\n WHERE e.season_id = s.id AND e.wanted\n AND NOT EXISTS (\n SELECT 1 FROM media_files f\n WHERE f.owner_kind = 'episode' AND f.owner_id = e.id\n )\n )\n -- A later attempt owns the headline while it is still in\n -- flight. Keep the failed grab recorded; it is simply no\n -- longer the current explanation for the gap.\n AND NOT EXISTS (\n SELECT 1 FROM grabs newer\n WHERE newer.target_kind = 'season'\n AND newer.target_id = g.target_id\n -- 'vanished' is not a live attempt either: the\n -- torrent left Transmission, so letting it take the\n -- headline would leave the season saying nothing at\n -- all about a gap that still exists.\n AND newer.state NOT IN ('failed', 'vanished')\n AND (newer.grabbed_at > coalesce(g.failed_at, g.grabbed_at)\n OR (newer.grabbed_at = coalesce(g.failed_at, g.grabbed_at)\n AND newer.id > g.id))\n )\n ORDER BY coalesce(g.failed_at, g.grabbed_at), g.id", "query": "SELECT g.target_id AS \"season_id!: i64\",\n r.name AS \"name!: String\",\n g.infohash AS \"infohash!: String\",\n g.failed_at,\n g.grabbed_at AS \"grabbed_at!: String\"\n FROM grabs g\n JOIN releases r ON r.id = g.release_id\n JOIN seasons s ON s.id = g.target_id\n WHERE g.target_kind = 'season'\n AND g.state = 'failed'\n AND s.series_id = ?\n AND EXISTS (\n SELECT 1 FROM episodes e\n WHERE e.season_id = s.id AND e.wanted\n AND NOT EXISTS (\n SELECT 1 FROM media_files f\n WHERE f.owner_kind = 'episode' AND f.owner_id = e.id\n )\n )\n -- A later attempt owns the headline while it is still in\n -- flight. Keep the failed grab recorded; it is simply no\n -- longer the current explanation for the gap.\n AND NOT EXISTS (\n SELECT 1 FROM grabs newer\n WHERE newer.target_kind = 'season'\n AND newer.target_id = g.target_id\n -- 'vanished' is not a live attempt either: the\n -- torrent left qBittorrent, so letting it take the\n -- headline would leave the season saying nothing at\n -- all about a gap that still exists.\n AND newer.state NOT IN ('failed', 'vanished')\n AND (newer.grabbed_at > coalesce(g.failed_at, g.grabbed_at)\n OR (newer.grabbed_at = coalesce(g.failed_at, g.grabbed_at)\n AND newer.id > g.id))\n )\n ORDER BY coalesce(g.failed_at, g.grabbed_at), g.id",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -70,5 +70,5 @@
false false
] ]
}, },
"hash": "594d1391c0e9d8a0dded5a354e0dc438bfdbac096ef23b5c3b816afdc03684ae" "hash": "0cfce0fe064cccb1dceb285a50fa77d990b116a95b987e604913cd8953da36ad"
} }
+4 -3
View File
@@ -16,7 +16,7 @@ crates/
├── arr-parse/ release name parsing (no IO) ├── arr-parse/ release name parsing (no IO)
├── arr-meta/ TMDB client ├── arr-meta/ TMDB client
├── arr-indexer/ Torznab via Prowlarr ├── arr-indexer/ Torznab via Prowlarr
├── arr-dl/ Transmission RPC ├── arr-dl/ qBittorrent WebUI API
├── arr-probe/ ffprobe wrapper ├── arr-probe/ ffprobe wrapper
├── arr-subs/ subtitle providers, translation, sync ├── arr-subs/ subtitle providers, translation, sync
├── arr-db/ sqlx + migrations ├── arr-db/ sqlx + migrations
@@ -244,8 +244,9 @@ mid-issue.
- **Prowlarr** — `prowlarr` container on the Dokploy host, port 9696. Owns - **Prowlarr** — `prowlarr` container on the Dokploy host, port 9696. Owns
tracker auth, FlareSolverr and the Cardigann definitions. Not replaced. tracker auth, FlareSolverr and the Cardigann definitions. Not replaced.
- **Transmission** — native in LXC 130 at `10.6.10.45:9091`, RPC - **qBittorrent** — `qbittorrent.n62.casa`, WebUI API v2, login required
unauthenticated. Download dir `/mnt/media/transmission/complete`. (`ARR_QBITTORRENT_USERNAME` / `ARR_QBITTORRENT_PASSWORD`). Download dir
`/mnt/media/qbittorrent/complete`.
- **Jellyfin** — native in LXC at `10.6.10.18:8096`. Library roots under - **Jellyfin** — native in LXC at `10.6.10.18:8096`. Library roots under
`/mnt/media-v2`. `/mnt/media-v2`.
- **Media** — ZFS, single dataset, bind-mounted as `/mnt/media`. Downloads and - **Media** — ZFS, single dataset, bind-mounted as `/mnt/media`. Downloads and
Generated
+15 -3
View File
@@ -93,7 +93,6 @@ dependencies = [
"arr-probe", "arr-probe",
"arr-subs", "arr-subs",
"axum", "axum",
"base64",
"chrono", "chrono",
"include_dir", "include_dir",
"mime_guess", "mime_guess",
@@ -129,10 +128,10 @@ dependencies = [
name = "arr-dl" name = "arr-dl"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"base64",
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"sha1 0.10.7",
"thiserror", "thiserror",
"tokio", "tokio",
"url", "url",
@@ -1543,6 +1542,7 @@ dependencies = [
"base64", "base64",
"bytes", "bytes",
"futures-core", "futures-core",
"futures-util",
"http", "http",
"http-body", "http-body",
"http-body-util", "http-body-util",
@@ -1551,6 +1551,7 @@ dependencies = [
"hyper-util", "hyper-util",
"js-sys", "js-sys",
"log", "log",
"mime_guess",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"quinn", "quinn",
@@ -1733,6 +1734,17 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "sha1"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]] [[package]]
name = "sha1" name = "sha1"
version = "0.11.0" version = "0.11.0"
@@ -1932,7 +1944,7 @@ dependencies = [
"log", "log",
"percent-encoding", "percent-encoding",
"serde", "serde",
"sha1", "sha1 0.11.0",
"sha2 0.11.0", "sha2 0.11.0",
"sqlx-core", "sqlx-core",
"thiserror", "thiserror",
+2 -2
View File
@@ -26,8 +26,7 @@ arr-subs = { path = "crates/arr-subs" }
axum = "0.8" axum = "0.8"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "net", "io-util", "fs", "signal", "process"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "net", "io-util", "fs", "signal", "process"] }
tower-http = { version = "0.6", features = ["trace"] } tower-http = { version = "0.6", features = ["trace"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls"] }
base64 = "0.22"
# Persistence # Persistence
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "sqlite", "macros", "migrate", "chrono", "json"] } sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "sqlite", "macros", "migrate", "chrono", "json"] }
@@ -52,6 +51,7 @@ chardetng = "1"
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
clap = { version = "4.5", features = ["derive", "env"] } clap = { version = "4.5", features = ["derive", "env"] }
encoding_rs = "0.8" encoding_rs = "0.8"
sha1 = "0.10"
thiserror = "2" thiserror = "2"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+44 -23
View File
@@ -27,7 +27,7 @@ allowed to be narrow.
Explicitly out of scope, permanently unless stated: Explicitly out of scope, permanently unless stated:
- **Authentication.** The perimeter is a VPN plus Authelia at the proxy. The - **Authentication.** The perimeter is a VPN plus Authelia at the proxy. The
service binds without auth, same trust model as the existing Transmission RPC. service binds without auth, same trust model as the rest of the stack.
- **Library migration or filesystem scan.** The service knows only what it put - **Library migration or filesystem scan.** The service knows only what it put
on disk. Adopting the pre-existing library, if ever wanted, is a one-off on disk. Adopting the pre-existing library, if ever wanted, is a one-off
script against both APIs, not a feature. script against both APIs, not a feature.
@@ -46,7 +46,7 @@ Explicitly out of scope, permanently unless stated:
``` ```
┌──────────────┐ ┌──────────────┐
TMDB ──────▶│ │ TMDB ──────▶│ │
│ arr │────▶ Transmission RPC 10.6.10.45:9091 │ arr │────▶ qBittorrent WebUI qbittorrent.n62.casa
Prowlarr ──────▶│ │ Prowlarr ──────▶│ │
(Torznab) │ (this) │────▶ ffprobe local subprocess (Torznab) │ (this) │────▶ ffprobe local subprocess
│ │ │ │
@@ -61,8 +61,9 @@ Everything already exists except `arr`. Prowlarr keeps owning tracker auth,
Cloudflare bypass via FlareSolverr, rate limiting and the Cardigann Cloudflare bypass via FlareSolverr, rate limiting and the Cardigann
definitions — replacing it buys nothing. definitions — replacing it buys nothing.
Transmission runs natively in its own LXC (VMID 130, `10.6.10.45`), RPC qBittorrent is reached at `qbittorrent.n62.casa`, WebUI API v2, download dir
unauthenticated, download dir `/mnt/media/transmission/complete`. `/mnt/media/qbittorrent/complete`. Its WebUI requires a login, so arr carries
credentials — the one upstream that does.
## 4. Domain model ## 4. Domain model
@@ -517,11 +518,20 @@ leaving RSS matching on.
## 7. Download and import ## 7. Download and import
### 7.1 Transmission ### 7.1 qBittorrent
RPC at `10.6.10.45:9091`, no credentials. Labels are `movies-main`, `tv-kids` WebUI API v2 at `qbittorrent.n62.casa`. Labels are qBittorrent **tags** —
and so on — enough to find things in Transmission's own UI, and distinct from `movies-main`, `tv-kids` and so on — enough to find things in qBittorrent's own
Radarr's existing `radarr`/`sonarr` labels so both stacks can run side by side. UI, and distinct from Radarr's existing `radarr`/`sonarr` labels so both stacks
can run side by side. Tags rather than a category because a category also
governs the save path, and arr owns that.
`torrents/add` answers `Ok.` and nothing else: no hash, no name, and no signal
that the torrent was already there. arr therefore derives the v1 infohash from
the magnet or the `.torrent` bytes before the call, and looks the torrent up by
it. That is also what makes an add idempotent across a restart (§8) — the same
release resolves to the same hash, and a second add is recognised as the
duplicate it is.
### 7.2 Hardlink ### 7.2 Hardlink
@@ -540,9 +550,16 @@ at 4K is 40-80 GB per title.
The torrent and the library entry are separate state machines. The torrent and the library entry are separate state machines.
Seeding obligation is **per tracker**, configured locally because Prowlarr does Seeding obligation is **per tracker**, configured locally because Prowlarr does
not expose tracker rules — `ratio` and `min_seed_time`. Set `seedRatioLimit` not expose tracker rules — `ratio` and `min_seed_time`. Set `ratioLimit` and
and `seedIdleLimit` on the torrent at add time and let Transmission enforce `inactiveSeedingTimeLimit` on the torrent at add time, with
them. A reaper deletes torrents Transmission reports as done seeding. `shareLimitAction` set to stop rather than delete, and let qBittorrent enforce
them. A reaper deletes torrents qBittorrent stopped on a limit arr set.
Stopped-on-a-limit, not merely stopped: a torrent the operator paused by hand
looks identical in the state field alone, and the reaper deletes data. So the
ratio and idle limits are checked against the torrent's own counters, and a
torrent stopped by a *global* limit reads as still seeding — the safe direction
to be wrong in.
Consequently a hard-failed release is blacklisted and never imported, but its Consequently a hard-failed release is blacklisted and never imported, but its
torrent keeps seeding until the obligation clears. Nothing is deleted early to torrent keeps seeding until the obligation clears. Nothing is deleted early to
@@ -599,11 +616,11 @@ state and act on the gap.
This is idempotent and crash-safe by construction. Kill the process mid-grab and This is idempotent and crash-safe by construction. Kill the process mid-grab and
the next tick recomputes the same gap and continues. A job table would need the next tick recomputes the same gap and continues. A job table would need
retry counts, dead-lettering and reconciliation against Transmission anyway, retry counts, dead-lettering and reconciliation against qBittorrent anyway,
because Transmission is an external system that changes underneath the app. because qBittorrent is an external system that changes underneath the app.
Transient state — a search in flight, download progress — is in memory and Transient state — a search in flight, download progress — is in memory and
rebuilt from Transmission on startup. Where that state is ever seen is §9.8: rebuilt from qBittorrent on startup. Where that state is ever seen is §9.8:
inline on the row that owns the item, never persisted. inline on the row that owns the item, never persisted.
Ticks are staggered: reconcile every 30 s, RSS every 10 min, metadata refresh Ticks are staggered: reconcile every 30 s, RSS every 10 min, metadata refresh
@@ -688,7 +705,7 @@ notifying on everything and being muted within a week.
- **Imported** → to the title's owners. The only good-news notification. - **Imported** → to the title's owners. The only good-news notification.
- **Needs a decision** → to the operator alone. Entered the no-PT-source queue, - **Needs a decision** → to the operator alone. Entered the no-PT-source queue,
or the needs-a-decision queue (§5.7). or the needs-a-decision queue (§5.7).
- **Broken** → to the operator alone. Prowlarr, Transmission or TMDB - **Broken** → to the operator alone. Prowlarr, qBittorrent or TMDB
unreachable, disk full. unreachable, disk full.
Not notified: grabs, searches, downloads starting or finishing, soft fails. Not notified: grabs, searches, downloads starting or finishing, soft fails.
@@ -742,7 +759,7 @@ inside the app, review text.
opens on. opens on.
**The signal chain is a settings section.** The four upstreams — tmdb, **The signal chain is a settings section.** The four upstreams — tmdb,
prowlarr, arr, transmission — and their lamps live inside `/settings`, and have prowlarr, arr, qbittorrent — and their lamps live inside `/settings`, and have
no route of their own. Per-upstream health is something you check when no route of their own. Per-upstream health is something you check when
something is wrong, not a homepage. something is wrong, not a homepage.
@@ -770,10 +787,10 @@ Alongside progress the same row carries the other states a torrent can be in:
**seeding** under §7.3's obligation, **stalled**, **errored**. **seeding** under §7.3's obligation, **stalled**, **errored**.
**A torrent arr did not grab is never shown.** §2 already rules the service **A torrent arr did not grab is never shown.** §2 already rules the service
knows only what it put on disk; Transmission's own UI lists the rest. knows only what it put on disk; qBittorrent's own UI lists the rest.
Nothing is persisted — no progress column, no new table. The snapshot comes Nothing is persisted — no progress column, no new table. The snapshot comes
from Transmission and dies with the process (§8), refreshed by the UI's normal from qBittorrent and dies with the process (§8), refreshed by the UI's normal
polling cadence at roughly 15 s rather than SSE. polling cadence at roughly 15 s rather than SSE.
At phone width only active-grab progress survives; seeding and stalled shed At phone width only active-grab progress survives; seeding and stalled shed
@@ -791,8 +808,10 @@ service.
Policy lives in the database, not a config file — size targets and DV rules get Policy lives in the database, not a config file — size targets and DV rules get
tuned by hand during testing and a restart-to-reload loop gets old immediately. tuned by hand during testing and a restart-to-reload loop gets old immediately.
Only bootstrap settings (bind address, Prowlarr URL, Transmission URL, TMDB key, Only bootstrap settings (bind address, Prowlarr URL, qBittorrent URL and
media root, TMDB response cache directory) come from config/env. login, TMDB key, media root, TMDB response cache directory) come from
config/env. The qBittorrent password is a secret, so it is env-only and has no
config-file field.
Backup is `sqlite3 .backup` on a timer. Backup is `sqlite3 .backup` on a timer.
@@ -806,7 +825,7 @@ arr-core domain types, policy engine, scoring no IO, no heavy deps
arr-parse release name parsing no IO arr-parse release name parsing no IO
arr-meta TMDB client arr-meta TMDB client
arr-indexer Torznab via Prowlarr arr-indexer Torznab via Prowlarr
arr-dl Transmission RPC arr-dl qBittorrent WebUI API
arr-probe ffprobe wrapper arr-probe ffprobe wrapper
arr-subs subtitle providers, translation, sync arr-subs subtitle providers, translation, sync
arr-db sqlx + migrations arr-db sqlx + migrations
@@ -863,8 +882,10 @@ and `target/`, keyed on `Cargo.lock` plus `rust-toolchain.toml`. Never build
- Prowlarr and TMDB — `wiremock` with recorded real responses as fixtures. - Prowlarr and TMDB — `wiremock` with recorded real responses as fixtures.
Never live: trackers rate-limit, and it would leak credentials into CI. Never live: trackers rate-limit, and it would leak credentials into CI.
- Transmission — a real container as a CI service. RPC semantics are the most - qBittorrent — a real container, started with a seeded config because the
likely source of surprise and it starts in a second. image prints a random WebUI password per boot. Its API semantics are the most
likely source of surprise: `torrents/add` reports nothing back, and required
parameters have changed between major versions.
- `ffprobe` — tiny committed clips, a few KB each. The Jellyfin LXC already has - `ffprobe` — tiny committed clips, a few KB each. The Jellyfin LXC already has
the right fixtures at `/srv/jellyfin-test`, including a real **DV Profile 5** the right fixtures at `/srv/jellyfin-test`, including a real **DV Profile 5**
clip. That is the test proving the policy engine rejects Profile 5 and accepts clip. That is the test proving the policy engine rejects Profile 5 and accepts
+26 -8
View File
@@ -31,20 +31,38 @@ deps:
test: test:
cargo nextest run --workspace --exclude arr-e2e --all-features cargo nextest run --workspace --exclude arr-e2e --all-features
# End-to-end tests. Needs a Transmission container (`just e2e-up`); not part # End-to-end tests. Needs a qBittorrent container (`just e2e-up`); not part
# of the push gate. Never point this at the production Transmission LXC. # of the push gate. Never point this at the production qBittorrent.
e2e: e2e:
cargo nextest run -p arr-e2e cargo nextest run -p arr-e2e
# Throwaway local Transmission for `just e2e`, mirroring the CI service. # Throwaway local qBittorrent for `just e2e`, mirroring the CI container.
#
# The config is seeded before first start because qBittorrent prints a random
# WebUI password per boot and has no environment variable to set one; the
# subnet whitelist is what lets the tests talk to it without credentials.
e2e-up: e2e-up:
docker run --rm -d --name arr-e2e-transmission \ #!/usr/bin/env bash
-e PUID=1000 -e PGID=1000 -p 9091:9091 \ set -euo pipefail
linuxserver/transmission:latest mkdir -p /tmp/arr-e2e-qbt/config/qBittorrent
cat > /tmp/arr-e2e-qbt/config/qBittorrent/qBittorrent.conf <<'CONF'
[Preferences]
WebUI\Port=8080
WebUI\AuthSubnetWhitelistEnabled=true
WebUI\AuthSubnetWhitelist=0.0.0.0/0
WebUI\CSRFProtection=false
WebUI\HostHeaderValidation=false
Downloads\SavePath=/downloads
CONF
docker run --rm -d --name arr-e2e-qbittorrent \
-e PUID=1000 -e PGID=1000 -e WEBUI_PORT=8080 -p 8080:8080 \
-v /tmp/arr-e2e-qbt/config:/config \
linuxserver/qbittorrent:latest
until curl -sf http://127.0.0.1:8080/api/v2/app/version >/dev/null; do sleep 1; done
# Stop and discard the local Transmission container. # Stop and discard the local qBittorrent container.
e2e-down: e2e-down:
docker stop arr-e2e-transmission docker stop arr-e2e-qbittorrent
# Frontend lint and typecheck. No-op until the SPA exists (issue #6). Probes # Frontend lint and typecheck. No-op until the SPA exists (issue #6). Probes
# for package.json, not the directory: `just gen-client` writes into web/ and # for package.json, not the directory: `just gen-client` writes into web/ and
+1 -1
View File
@@ -43,7 +43,7 @@ manual search buries the decision under the release name column.
- Self-hosted behind VPN + Authelia; the app itself has no auth layer. - Self-hosted behind VPN + Authelia; the app itself has no auth layer.
- API-first: the SPA is one client of the OpenAPI-described HTTP API, no - API-first: the SPA is one client of the OpenAPI-described HTTP API, no
privileged path. privileged path.
- Coexists with Prowlarr (indexers), Transmission (downloads), Jellyfin - Coexists with Prowlarr (indexers), qBittorrent (downloads), Jellyfin
(playback), Jellyseerr (requests), ntfy (notifications). (playback), Jellyseerr (requests), ntfy (notifications).
## Capabilities and Constraints ## Capabilities and Constraints
+1 -1
View File
@@ -12,7 +12,7 @@ This is that loop, once, in Rust.
- **Prowlarr** stays — it owns tracker auth, Cloudflare bypass and the indexer - **Prowlarr** stays — it owns tracker auth, Cloudflare bypass and the indexer
definitions, and replacing it buys nothing. definitions, and replacing it buys nothing.
- **Transmission** stays. - **qBittorrent** stays.
- **Jellyseerr** stays, talking to a thin Radarr-compatible shim. - **Jellyseerr** stays, talking to a thin Radarr-compatible shim.
## Status ## Status
+16 -30
View File
@@ -42,14 +42,12 @@ pub struct Download {
tag = "system", tag = "system",
responses( responses(
(status = 200, description = "Live downloads started by arr", body = [Download]), (status = 200, description = "Live downloads started by arr", body = [Download]),
(status = 503, description = "Transmission or database unavailable", body = crate::movies::ErrorBody) (status = 503, description = "qBittorrent or database unavailable", body = crate::movies::ErrorBody)
) )
)] )]
pub(crate) async fn list(State(state): State<AppState>) -> Result<Json<Vec<Download>>, ApiError> { pub(crate) async fn list(State(state): State<AppState>) -> Result<Json<Vec<Download>>, ApiError> {
let database = state.database().ok_or(ApiError::Unavailable)?; let database = state.database().ok_or(ApiError::Unavailable)?;
let transmission = state let qbit = state.qbit().ok_or(ApiError::Upstream("qbit"))?;
.transmission()
.ok_or(ApiError::Upstream("transmission"))?;
let torrents = { let torrents = {
let mut cache = state.download_snapshot().write().await; let mut cache = state.download_snapshot().write().await;
@@ -57,18 +55,18 @@ pub(crate) async fn list(State(state): State<AppState>) -> Result<Json<Vec<Downl
if at.elapsed() < SNAPSHOT_TTL { if at.elapsed() < SNAPSHOT_TTL {
torrents.clone() torrents.clone()
} else { } else {
let torrents = transmission let torrents = qbit
.list_torrents() .list_torrents()
.await .await
.map_err(|_| ApiError::Upstream("transmission"))?; .map_err(|_| ApiError::Upstream("qbit"))?;
*cache = Some((Instant::now(), torrents.clone())); *cache = Some((Instant::now(), torrents.clone()));
torrents torrents
} }
} else { } else {
let torrents = transmission let torrents = qbit
.list_torrents() .list_torrents()
.await .await
.map_err(|_| ApiError::Upstream("transmission"))?; .map_err(|_| ApiError::Upstream("qbit"))?;
*cache = Some((Instant::now(), torrents.clone())); *cache = Some((Instant::now(), torrents.clone()));
torrents torrents
} }
@@ -116,7 +114,7 @@ fn phase(torrent: &Torrent) -> DownloadPhase {
} else { } else {
match torrent.state { match torrent.state {
TorrentState::Seeding => DownloadPhase::Seeding, TorrentState::Seeding => DownloadPhase::Seeding,
// Transmission's own verdict, not a zero rate: a torrent between // qBittorrent's own verdict, not a zero rate: a torrent between
// peers reads zero for a poll or two and finishes fine, and // peers reads zero for a poll or two and finishes fine, and
// §9.8's stalled chip is for the case that never does. // §9.8's stalled chip is for the case that never does.
TorrentState::Downloading if torrent.is_stalled => DownloadPhase::Stalled, TorrentState::Downloading if torrent.is_stalled => DownloadPhase::Stalled,
@@ -133,7 +131,7 @@ mod tests {
use arr_db::Db; use arr_db::Db;
use axum::extract::State; use axum::extract::State;
use serde_json::json; use serde_json::json;
use wiremock::matchers::{body_partial_json, method}; use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate}; use wiremock::{Mock, MockServer, ResponseTemplate};
use super::{list, phase, DownloadPhase}; use super::{list, phase, DownloadPhase};
@@ -143,7 +141,6 @@ mod tests {
fn torrent_states_become_the_download_phases() { fn torrent_states_become_the_download_phases() {
let torrent = let torrent =
|state: arr_dl::TorrentState, rate: u64, error: Option<&str>| arr_dl::Torrent { |state: arr_dl::TorrentState, rate: u64, error: Option<&str>| arr_dl::Torrent {
id: 1,
name: "name".into(), name: "name".into(),
hash: "hash".into(), hash: "hash".into(),
state, state,
@@ -186,24 +183,13 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn endpoint_joins_only_arr_grabs_and_reuses_the_snapshot() { async fn endpoint_joins_only_arr_grabs_and_reuses_the_snapshot() {
let server = MockServer::start().await; let server = MockServer::start().await;
Mock::given(method("POST")) Mock::given(method("GET"))
.respond_with( .and(path("/api/v2/torrents/info"))
ResponseTemplate::new(409).insert_header("x-transmission-session-id", "session"), .respond_with(ResponseTemplate::new(200).set_body_json(json!([{
) "hash": "ABC", "name": "grabbed", "state": "downloading",
.up_to_n_times(1) "progress": 0.4, "dlspeed": 123, "eta": 60,
.mount(&server) "save_path": "/downloads", "tags": ""
.await; }])))
Mock::given(method("POST"))
.and(body_partial_json(json!({"method": "torrent-get"})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"result": "success",
"arguments": {"torrents": [{
"id": 1, "name": "grabbed", "hashString": "ABC",
"status": 4, "percentDone": 0.4, "rateDownload": 123,
"eta": 60, "errorString": "", "downloadDir": "/downloads",
"labels": [], "isFinished": false
}]}
})))
.expect(1) .expect(1)
.mount(&server) .mount(&server)
.await; .await;
@@ -231,7 +217,7 @@ mod tests {
let state = AppState::new(Upstreams::new("unused".into(), server.uri())) let state = AppState::new(Upstreams::new("unused".into(), server.uri()))
.expect("state") .expect("state")
.with_database(database) .with_database(database)
.with_transmission(arr_dl::TransmissionClient::new(&server.uri()).expect("client")); .with_qbit(arr_dl::QbitClient::new(&server.uri()).expect("client"));
let first = list(State(state.clone())).await.expect("first").0; let first = list(State(state.clone())).await.expect("first").0;
let second = list(State(state)).await.expect("second").0; let second = list(State(state)).await.expect("second").0;
assert_eq!(first.len(), 1); assert_eq!(first.len(), 1);
+14 -15
View File
@@ -1,7 +1,7 @@
//! `GET /api/health` — is each of the three upstreams answering. //! `GET /api/health` — is each of the three upstreams answering.
//! //!
//! The three probed here are the ones DESIGN.md §9.5 calls "Broken": without //! The three probed here are the ones DESIGN.md §9.5 calls "Broken": without
//! Prowlarr nothing is found, without Transmission nothing is fetched, and //! Prowlarr nothing is found, without qBittorrent nothing is fetched, and
//! without TMDB nothing is identified. The endpoint always answers `200` — //! without TMDB nothing is identified. The endpoint always answers `200` —
//! the body carries the verdict, so a degraded service can still explain //! the body carries the verdict, so a degraded service can still explain
//! itself to the UI instead of looking like a fourth outage. //! itself to the UI instead of looking like a fourth outage.
@@ -118,12 +118,12 @@ pub struct HealthReport {
#[schema(example = "0.1.0")] #[schema(example = "0.1.0")]
pub version: String, pub version: String,
pub prowlarr: Check, pub prowlarr: Check,
pub transmission: Check, pub qbit: Check,
pub tmdb: Check, pub tmdb: Check,
pub subtitles: SubtitleHealth, pub subtitles: SubtitleHealth,
} }
/// Report reachability of Prowlarr, Transmission and TMDB, plus the /// Report reachability of Prowlarr, qBittorrent and TMDB, plus the
/// subtitle upstreams (#200). /// subtitle upstreams (#200).
#[utoipa::path( #[utoipa::path(
get, get,
@@ -136,14 +136,14 @@ pub struct HealthReport {
pub async fn health(State(state): State<AppState>) -> Json<HealthReport> { pub async fn health(State(state): State<AppState>) -> Json<HealthReport> {
// Independent network probes; serialising them would make the endpoint // Independent network probes; serialising them would make the endpoint
// as slow as the sum of the timeouts. // as slow as the sum of the timeouts.
let (prowlarr, transmission, tmdb, subtitles) = tokio::join!( let (prowlarr, qbit, tmdb, subtitles) = tokio::join!(
probe_prowlarr(&state), probe_prowlarr(&state),
probe_transmission(&state), probe_qbit(&state),
probe_tmdb(&state), probe_tmdb(&state),
probe_subtitles(&state) probe_subtitles(&state)
); );
let status = if [prowlarr.status, transmission.status, tmdb.status] let status = if [prowlarr.status, qbit.status, tmdb.status]
.into_iter() .into_iter()
.chain(subtitles.statuses()) .chain(subtitles.statuses())
.all(|check| check == Status::Ok) .all(|check| check == Status::Ok)
@@ -157,7 +157,7 @@ pub async fn health(State(state): State<AppState>) -> Json<HealthReport> {
status, status,
version: env!("CARGO_PKG_VERSION").to_string(), version: env!("CARGO_PKG_VERSION").to_string(),
prowlarr, prowlarr,
transmission, qbit,
tmdb, tmdb,
subtitles, subtitles,
}) })
@@ -181,17 +181,16 @@ async fn probe_prowlarr(state: &AppState) -> Check {
} }
} }
/// Transmission answers an RPC call without a session id with `409` plus the /// A live `WebUI` answers `/api/v2/app/version`, and answers `403` when the
/// id to retry with. That is a live daemon, so it counts as reachable. /// probe carries no session. Both are a running daemon, which is all this
async fn probe_transmission(state: &AppState) -> Check { /// lamp claims; whether arr's credentials work shows up on the first grab.
let request = state async fn probe_qbit(state: &AppState) -> Check {
.http() let base = state.upstreams().qbittorrent_url.trim_end_matches('/');
.post(&state.upstreams().transmission_url) let request = state.http().get(format!("{base}/api/v2/app/version"));
.json(&serde_json::json!({ "method": "session-get" }));
match request.send().await { match request.send().await {
Ok(response) Ok(response)
if response.status().is_success() if response.status().is_success()
|| response.status() == reqwest::StatusCode::CONFLICT => || response.status() == reqwest::StatusCode::FORBIDDEN =>
{ {
Check::ok() Check::ok()
} }
+25 -44
View File
@@ -177,8 +177,8 @@ mod tests {
use wiremock::matchers::{method, path}; use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate}; use wiremock::{Mock, MockServer, ResponseTemplate};
/// A Prowlarr that answers `/ping`, and a Transmission that answers an /// A Prowlarr that answers `/ping`, and a qBittorrent that answers the
/// RPC call the way a real one does when it has no session id yet. /// version call the way a real one does for an unauthenticated caller.
async fn upstreams_up() -> (MockServer, MockServer) { async fn upstreams_up() -> (MockServer, MockServer) {
let prowlarr = MockServer::start().await; let prowlarr = MockServer::start().await;
Mock::given(method("GET")) Mock::given(method("GET"))
@@ -189,16 +189,14 @@ mod tests {
.mount(&prowlarr) .mount(&prowlarr)
.await; .await;
let transmission = MockServer::start().await; let qbit = MockServer::start().await;
Mock::given(method("POST")) Mock::given(method("GET"))
.and(path("/transmission/rpc")) .and(path("/api/v2/app/version"))
.respond_with( .respond_with(ResponseTemplate::new(403).set_body_string("Forbidden"))
ResponseTemplate::new(409).insert_header("X-Transmission-Session-Id", "abc"), .mount(&qbit)
)
.mount(&transmission)
.await; .await;
(prowlarr, transmission) (prowlarr, qbit)
} }
/// Serve the app on an ephemeral port and return its base URL. The server /// Serve the app on an ephemeral port and return its base URL. The server
@@ -225,7 +223,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn all_upstreams_up_is_ok() { async fn all_upstreams_up_is_ok() {
let (prowlarr, transmission) = upstreams_up().await; let (prowlarr, qbit) = upstreams_up().await;
let tmdb = MockServer::start().await; let tmdb = MockServer::start().await;
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/configuration")) .and(path("/configuration"))
@@ -234,12 +232,9 @@ mod tests {
.await; .await;
let state = AppState::new( let state = AppState::new(
Upstreams::new( Upstreams::new(prowlarr.uri(), qbit.uri())
prowlarr.uri(), .with_tmdb_url(tmdb.uri())
format!("{}/transmission/rpc", transmission.uri()), .with_tmdb_api_key(Some("key".into())),
)
.with_tmdb_url(tmdb.uri())
.with_tmdb_api_key(Some("key".into())),
) )
.expect("state") .expect("state")
// The subtitle binaries default to `PATH`; pin them to something // The subtitle binaries default to `PATH`; pin them to something
@@ -250,19 +245,15 @@ mod tests {
let body = report(state).await; let body = report(state).await;
assert_eq!(body["status"], "ok"); assert_eq!(body["status"], "ok");
assert_eq!(body["prowlarr"]["status"], "ok"); assert_eq!(body["prowlarr"]["status"], "ok");
assert_eq!(body["transmission"]["status"], "ok"); assert_eq!(body["qbit"]["status"], "ok");
assert_eq!(body["tmdb"]["status"], "ok"); assert_eq!(body["tmdb"]["status"], "ok");
assert_eq!(body["version"], env!("CARGO_PKG_VERSION")); assert_eq!(body["version"], env!("CARGO_PKG_VERSION"));
} }
#[tokio::test] #[tokio::test]
async fn a_missing_tmdb_key_is_unconfigured_not_an_outage() { async fn a_missing_tmdb_key_is_unconfigured_not_an_outage() {
let (prowlarr, transmission) = upstreams_up().await; let (prowlarr, qbit) = upstreams_up().await;
let state = AppState::new(Upstreams::new( let state = AppState::new(Upstreams::new(prowlarr.uri(), qbit.uri())).expect("state");
prowlarr.uri(),
format!("{}/transmission/rpc", transmission.uri()),
))
.expect("state");
let body = report(state).await; let body = report(state).await;
assert_eq!(body["tmdb"]["status"], "unconfigured"); assert_eq!(body["tmdb"]["status"], "unconfigured");
@@ -271,20 +262,17 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn an_unreachable_upstream_degrades_the_service() { async fn an_unreachable_upstream_degrades_the_service() {
let (_prowlarr, transmission) = upstreams_up().await; let (_prowlarr, qbit) = upstreams_up().await;
// Port 1 is privileged and nothing binds it, so the probe gets a // Port 1 is privileged and nothing binds it, so the probe gets a
// refused connection immediately instead of waiting out the timeout. // refused connection immediately instead of waiting out the timeout.
let state = AppState::new(Upstreams::new( let state =
"http://127.0.0.1:1".into(), AppState::new(Upstreams::new("http://127.0.0.1:1".into(), qbit.uri())).expect("state");
format!("{}/transmission/rpc", transmission.uri()),
))
.expect("state");
let body = report(state).await; let body = report(state).await;
assert_eq!(body["status"], "degraded"); assert_eq!(body["status"], "degraded");
assert_eq!(body["prowlarr"]["status"], "unreachable"); assert_eq!(body["prowlarr"]["status"], "unreachable");
assert_eq!(body["transmission"]["status"], "ok"); assert_eq!(body["qbit"]["status"], "ok");
} }
#[tokio::test] #[tokio::test]
@@ -295,13 +283,9 @@ mod tests {
.respond_with(ResponseTemplate::new(500)) .respond_with(ResponseTemplate::new(500))
.mount(&prowlarr) .mount(&prowlarr)
.await; .await;
let transmission = MockServer::start().await; let qbit = MockServer::start().await;
let state = AppState::new(Upstreams::new( let state = AppState::new(Upstreams::new(prowlarr.uri(), qbit.uri())).expect("state");
prowlarr.uri(),
format!("{}/transmission/rpc", transmission.uri()),
))
.expect("state");
let body = report(state).await; let body = report(state).await;
assert_eq!(body["prowlarr"]["status"], "unreachable"); assert_eq!(body["prowlarr"]["status"], "unreachable");
@@ -310,7 +294,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn a_failed_tmdb_probe_never_echoes_the_api_key() { async fn a_failed_tmdb_probe_never_echoes_the_api_key() {
let (prowlarr, transmission) = upstreams_up().await; let (prowlarr, qbit) = upstreams_up().await;
let tmdb = MockServer::start().await; let tmdb = MockServer::start().await;
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/configuration")) .and(path("/configuration"))
@@ -319,12 +303,9 @@ mod tests {
.await; .await;
let state = AppState::new( let state = AppState::new(
Upstreams::new( Upstreams::new(prowlarr.uri(), qbit.uri())
prowlarr.uri(), .with_tmdb_url(tmdb.uri())
format!("{}/transmission/rpc", transmission.uri()), .with_tmdb_api_key(Some("super-secret".into())),
)
.with_tmdb_url(tmdb.uri())
.with_tmdb_api_key(Some("super-secret".into())),
) )
.expect("state"); .expect("state");
+1 -1
View File
@@ -27,7 +27,7 @@ pub struct Root {
/// The payload for creating or replacing a root. /// The payload for creating or replacing a root.
#[derive(Debug, Clone, Deserialize, ToSchema)] #[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct RootInput { pub struct RootInput {
/// `movie` or `tv` — the Transmission label and layout prefix (§7.1, §7.4). /// `movie` or `tv` — the qBittorrent label and layout prefix (§7.1, §7.4).
pub kind: String, pub kind: String,
/// `main` or `kids`. /// `main` or `kids`.
pub audience: String, pub audience: String,
+3 -3
View File
@@ -1655,7 +1655,7 @@ pub async fn season_releases(
/// A grab for this season that downloaded in full and was then condemned at /// A grab for this season that downloaded in full and was then condemned at
/// import (#227, §5.7). /// import (#227, §5.7).
/// ///
/// The torrent stays at 100% in Transmission — §7.3 leaves that lifecycle to /// The torrent stays at 100% in qBittorrent — §7.3 leaves that lifecycle to
/// the reaper — the release is blacklisted and every episode it was covering /// the reaper — the release is blacklisted and every episode it was covering
/// reopens as a gap. Nothing on screen connected the two, so the season read /// reopens as a gap. Nothing on screen connected the two, so the season read
/// `0/10` as though no grab had ever been tried. Every fact is already /// `0/10` as though no grab had ever been tried. Every fact is already
@@ -1713,7 +1713,7 @@ async fn season_import_failures(
WHERE newer.target_kind = 'season' WHERE newer.target_kind = 'season'
AND newer.target_id = g.target_id AND newer.target_id = g.target_id
-- 'vanished' is not a live attempt either: the -- 'vanished' is not a live attempt either: the
-- torrent left Transmission, so letting it take the -- torrent left qBittorrent, so letting it take the
-- headline would leave the season saying nothing at -- headline would leave the season saying nothing at
-- all about a gap that still exists. -- all about a gap that still exists.
AND newer.state NOT IN ('failed', 'vanished') AND newer.state NOT IN ('failed', 'vanished')
@@ -1794,7 +1794,7 @@ pub struct SeasonPackState {
/// #227. The last pack that downloaded in full and was then condemned at /// #227. The last pack that downloaded in full and was then condemned at
/// import, while the season is still waiting for a file. A deck that /// import, while the season is still waiting for a file. A deck that
/// cannot say this leaves the season reading as though nothing was ever /// cannot say this leaves the season reading as though nothing was ever
/// tried, with the torrent still sitting at 100% in Transmission. /// tried, with the torrent still sitting at 100% in qBittorrent.
pub import_failure: Option<ImportFailure>, pub import_failure: Option<ImportFailure>,
} }
+11 -11
View File
@@ -6,7 +6,7 @@ use std::sync::{atomic::AtomicU64, Arc};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use arr_db::Db; use arr_db::Db;
use arr_dl::TransmissionClient; use arr_dl::QbitClient;
use arr_probe::Extractor; use arr_probe::Extractor;
use arr_subs::{Backend, OpenAiEndpoint, Provider, Syncer}; use arr_subs::{Backend, OpenAiEndpoint, Provider, Syncer};
use tokio::sync::mpsc; use tokio::sync::mpsc;
@@ -33,7 +33,7 @@ const PROBE_TIMEOUT: Duration = Duration::from_secs(3);
pub struct Upstreams { pub struct Upstreams {
pub prowlarr_url: String, pub prowlarr_url: String,
pub prowlarr_api_key: Option<String>, pub prowlarr_api_key: Option<String>,
pub transmission_url: String, pub qbittorrent_url: String,
pub tmdb_url: String, pub tmdb_url: String,
pub tmdb_api_key: Option<String>, pub tmdb_api_key: Option<String>,
} }
@@ -41,11 +41,11 @@ pub struct Upstreams {
impl Upstreams { impl Upstreams {
/// Every upstream at its documented default, no keys. /// Every upstream at its documented default, no keys.
#[must_use] #[must_use]
pub fn new(prowlarr_url: String, transmission_url: String) -> Self { pub fn new(prowlarr_url: String, qbittorrent_url: String) -> Self {
Self { Self {
prowlarr_url, prowlarr_url,
prowlarr_api_key: None, prowlarr_api_key: None,
transmission_url, qbittorrent_url,
tmdb_url: DEFAULT_TMDB_URL.to_string(), tmdb_url: DEFAULT_TMDB_URL.to_string(),
tmdb_api_key: None, tmdb_api_key: None,
} }
@@ -109,7 +109,7 @@ pub struct AppState {
extractor: Extractor, extractor: Extractor,
jellyfin: Option<JellyfinClient>, jellyfin: Option<JellyfinClient>,
syncer: Syncer, syncer: Syncer,
transmission: Option<TransmissionClient>, qbit: Option<QbitClient>,
download_snapshot: DownloadSnapshot, download_snapshot: DownloadSnapshot,
} }
@@ -189,7 +189,7 @@ impl AppState {
extractor: Extractor::default(), extractor: Extractor::default(),
jellyfin: None, jellyfin: None,
syncer: Syncer::default(), syncer: Syncer::default(),
transmission: None, qbit: None,
download_snapshot: Arc::new(RwLock::new(None)), download_snapshot: Arc::new(RwLock::new(None)),
}) })
} }
@@ -201,11 +201,11 @@ impl AppState {
self self
} }
/// Attach the shared Transmission client used by the API's live download /// Attach the shared qBittorrent client used by the API's live download
/// snapshot endpoint. /// snapshot endpoint.
#[must_use] #[must_use]
pub fn with_transmission(mut self, transmission: TransmissionClient) -> Self { pub fn with_qbit(mut self, qbit: QbitClient) -> Self {
self.transmission = Some(transmission); self.qbit = Some(qbit);
self self
} }
@@ -368,8 +368,8 @@ impl AppState {
self.database.as_ref() self.database.as_ref()
} }
pub(crate) fn transmission(&self) -> Option<&TransmissionClient> { pub(crate) fn qbit(&self) -> Option<&QbitClient> {
self.transmission.as_ref() self.qbit.as_ref()
} }
pub(crate) fn download_snapshot(&self) -> &DownloadSnapshot { pub(crate) fn download_snapshot(&self) -> &DownloadSnapshot {
+1 -1
View File
@@ -257,7 +257,7 @@ pub enum MediaState {
Missing, Missing,
Downloading, Downloading,
Available, Available,
/// #108: `wanted` was cleared after the grab vanished from Transmission. /// #108: `wanted` was cleared after the grab vanished from qBittorrent.
/// Distinct from `Missing` so it does not read as an open gap. /// Distinct from `Missing` so it does not read as an open gap.
Parked, Parked,
} }
-1
View File
@@ -47,7 +47,6 @@ tracing = { workspace = true }
tracing-subscriber = { workspace = true } tracing-subscriber = { workspace = true }
[dev-dependencies] [dev-dependencies]
base64 = { workspace = true }
tempfile = { workspace = true } tempfile = { workspace = true }
wiremock = { workspace = true } wiremock = { workspace = true }
+19 -19
View File
@@ -1,4 +1,4 @@
//! §9.5 *broken* → the operator alone: Prowlarr, Transmission or TMDB //! §9.5 *broken* → the operator alone: Prowlarr, qBittorrent or TMDB
//! unreachable, or a subtitle lamp failing (#200). //! unreachable, or a subtitle lamp failing (#200).
//! //!
//! Edge-triggered: notifies once when an upstream stops answering, and //! Edge-triggered: notifies once when an upstream stops answering, and
@@ -24,7 +24,7 @@ const PROBE_TIMEOUT: Duration = Duration::from_secs(5);
pub struct Upstreams { pub struct Upstreams {
pub prowlarr_url: String, pub prowlarr_url: String,
pub prowlarr_api_key: Option<String>, pub prowlarr_api_key: Option<String>,
pub transmission_url: String, pub qbittorrent_url: String,
pub tmdb_url: String, pub tmdb_url: String,
pub tmdb_api_key: Option<String>, pub tmdb_api_key: Option<String>,
} }
@@ -118,16 +118,16 @@ impl BrokenAction {
} }
async fn tick(&self, database: &Db) -> Vec<Outcome> { async fn tick(&self, database: &Db) -> Vec<Outcome> {
let (prowlarr, transmission, tmdb, subtitles) = tokio::join!( let (prowlarr, qbit, tmdb, subtitles) = tokio::join!(
self.probe_prowlarr(), self.probe_prowlarr(),
self.probe_transmission(), self.probe_qbit(),
self.probe_tmdb(), self.probe_tmdb(),
self.subtitles.probe(database), self.subtitles.probe(database),
); );
let mut outcomes = Vec::new(); let mut outcomes = Vec::new();
outcomes.extend(self.notify_transition("prowlarr", prowlarr).await); outcomes.extend(self.notify_transition("prowlarr", prowlarr).await);
outcomes.extend(self.notify_transition("transmission", transmission).await); outcomes.extend(self.notify_transition("qbit", qbit).await);
outcomes.extend(self.notify_transition("tmdb", tmdb).await); outcomes.extend(self.notify_transition("tmdb", tmdb).await);
for (name, reachable) in subtitles { for (name, reachable) in subtitles {
outcomes.extend(self.notify_transition(&name, reachable).await); outcomes.extend(self.notify_transition(&name, reachable).await);
@@ -178,13 +178,13 @@ impl BrokenAction {
matches!(request.send().await, Ok(response) if response.status().is_success()) matches!(request.send().await, Ok(response) if response.status().is_success())
} }
/// Transmission answers an RPC call without a session id with `409` plus /// qBittorrent answers an RPC call without a session id with `409` plus
/// the id to retry with. That is a live daemon, so it counts as /// the id to retry with. That is a live daemon, so it counts as
/// reachable. /// reachable.
async fn probe_transmission(&self) -> bool { async fn probe_qbit(&self) -> bool {
let request = self let request = self
.http .http
.post(&self.upstreams.transmission_url) .post(&self.upstreams.qbittorrent_url)
.timeout(PROBE_TIMEOUT) .timeout(PROBE_TIMEOUT)
.json(&serde_json::json!({ "method": "session-get" })); .json(&serde_json::json!({ "method": "session-get" }));
matches!( matches!(
@@ -235,11 +235,11 @@ mod tests {
use super::*; use super::*;
fn upstreams(prowlarr_url: String, transmission_url: String) -> Upstreams { fn upstreams(prowlarr_url: String, qbittorrent_url: String) -> Upstreams {
Upstreams { Upstreams {
prowlarr_url, prowlarr_url,
prowlarr_api_key: None, prowlarr_api_key: None,
transmission_url, qbittorrent_url,
tmdb_url: "http://127.0.0.1:1".to_string(), tmdb_url: "http://127.0.0.1:1".to_string(),
tmdb_api_key: None, tmdb_api_key: None,
} }
@@ -275,10 +275,10 @@ mod tests {
async fn an_unreachable_upstream_notifies_once_then_re_arms() { async fn an_unreachable_upstream_notifies_once_then_re_arms() {
let prowlarr = MockServer::start().await; let prowlarr = MockServer::start().await;
// No mock mounted: every request 404s, which counts as unreachable. // No mock mounted: every request 404s, which counts as unreachable.
let transmission = MockServer::start().await; let qbit = MockServer::start().await;
Mock::given(method("POST")) Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(409)) .respond_with(ResponseTemplate::new(409))
.mount(&transmission) .mount(&qbit)
.await; .await;
let ntfy = MockServer::start().await; let ntfy = MockServer::start().await;
@@ -289,7 +289,7 @@ mod tests {
let (_dir, db) = database().await; let (_dir, db) = database().await;
let action = BrokenAction::new( let action = BrokenAction::new(
upstreams(prowlarr.uri(), transmission.uri()), upstreams(prowlarr.uri(), qbit.uri()),
subtitles(), subtitles(),
Notifier::new(ntfy.uri()).unwrap(), Notifier::new(ntfy.uri()).unwrap(),
"operator-topic".to_string(), "operator-topic".to_string(),
@@ -337,14 +337,14 @@ mod tests {
.respond_with(ResponseTemplate::new(200)) .respond_with(ResponseTemplate::new(200))
.mount(&prowlarr) .mount(&prowlarr)
.await; .await;
let transmission = MockServer::start().await; let qbit = MockServer::start().await;
Mock::given(method("POST")) Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(409)) .respond_with(ResponseTemplate::new(409))
.mount(&transmission) .mount(&qbit)
.await; .await;
let action = BrokenAction::new( let action = BrokenAction::new(
upstreams(prowlarr.uri(), transmission.uri()), upstreams(prowlarr.uri(), qbit.uri()),
subtitles(), subtitles(),
Notifier::new(ntfy.uri()).unwrap(), Notifier::new(ntfy.uri()).unwrap(),
"operator-topic".to_string(), "operator-topic".to_string(),
@@ -381,10 +381,10 @@ mod tests {
.respond_with(ResponseTemplate::new(200)) .respond_with(ResponseTemplate::new(200))
.mount(&prowlarr) .mount(&prowlarr)
.await; .await;
let transmission = MockServer::start().await; let qbit = MockServer::start().await;
Mock::given(method("POST")) Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(409)) .respond_with(ResponseTemplate::new(409))
.mount(&transmission) .mount(&qbit)
.await; .await;
let subs = SubtitleUpstreams { let subs = SubtitleUpstreams {
@@ -392,7 +392,7 @@ mod tests {
..subtitles() ..subtitles()
}; };
let action = BrokenAction::new( let action = BrokenAction::new(
upstreams(prowlarr.uri(), transmission.uri()), upstreams(prowlarr.uri(), qbit.uri()),
subs, subs,
Notifier::new(ntfy.uri()).unwrap(), Notifier::new(ntfy.uri()).unwrap(),
"operator-topic".to_string(), "operator-topic".to_string(),
+54 -16
View File
@@ -20,7 +20,12 @@ pub const ENV_DATABASE_PATH: &str = "ARR_DATABASE_PATH";
pub const ENV_MEDIA_ROOT: &str = "ARR_MEDIA_ROOT"; pub const ENV_MEDIA_ROOT: &str = "ARR_MEDIA_ROOT";
pub const ENV_PROWLARR_URL: &str = "ARR_PROWLARR_URL"; pub const ENV_PROWLARR_URL: &str = "ARR_PROWLARR_URL";
pub const ENV_PROWLARR_API_KEY: &str = "ARR_PROWLARR_API_KEY"; pub const ENV_PROWLARR_API_KEY: &str = "ARR_PROWLARR_API_KEY";
pub const ENV_TRANSMISSION_URL: &str = "ARR_TRANSMISSION_URL"; pub const ENV_QBITTORRENT_URL: &str = "ARR_QBITTORRENT_URL";
// The WebUI password is a secret, so both halves are env-only (§10) and
// neither has a `ConfigFile` field. Leaving them unset is valid: it is the
// right shape for an instance that bypasses authentication for arr's address.
pub const ENV_QBITTORRENT_USERNAME: &str = "ARR_QBITTORRENT_USERNAME";
pub const ENV_QBITTORRENT_PASSWORD: &str = "ARR_QBITTORRENT_PASSWORD";
pub const ENV_DOWNLOAD_DIR: &str = "ARR_DOWNLOAD_DIR"; pub const ENV_DOWNLOAD_DIR: &str = "ARR_DOWNLOAD_DIR";
pub const ENV_SEED_RATIO_LIMIT: &str = "ARR_SEED_RATIO_LIMIT"; pub const ENV_SEED_RATIO_LIMIT: &str = "ARR_SEED_RATIO_LIMIT";
pub const ENV_SEED_IDLE_LIMIT_MINUTES: &str = "ARR_SEED_IDLE_LIMIT_MINUTES"; pub const ENV_SEED_IDLE_LIMIT_MINUTES: &str = "ARR_SEED_IDLE_LIMIT_MINUTES";
@@ -54,12 +59,12 @@ pub const DEFAULT_BIND_ADDR: &str = "0.0.0.0:7878";
pub const DEFAULT_DATABASE_PATH: &str = "arr.db"; pub const DEFAULT_DATABASE_PATH: &str = "arr.db";
pub const DEFAULT_MEDIA_ROOT: &str = "/mnt/media"; pub const DEFAULT_MEDIA_ROOT: &str = "/mnt/media";
pub const DEFAULT_PROWLARR_URL: &str = "http://localhost:9696"; pub const DEFAULT_PROWLARR_URL: &str = "http://localhost:9696";
pub const DEFAULT_TRANSMISSION_URL: &str = "http://localhost:9091/transmission/rpc"; pub const DEFAULT_QBITTORRENT_URL: &str = "http://localhost:8080";
/// Transmission's own view of the download directory (DESIGN.md §3). It /// qBittorrent's own view of the download directory (DESIGN.md §3). It
/// shares the media dataset with the library so hardlinks work (§7.2). /// shares the media dataset with the library so hardlinks work (§7.2).
pub const DEFAULT_DOWNLOAD_DIR: &str = "/mnt/media/transmission/complete"; pub const DEFAULT_DOWNLOAD_DIR: &str = "/mnt/media/qbittorrent/complete";
/// Seeding obligation defaults (§7.3), applied to every torrent at add time /// Seeding obligation defaults (§7.3), applied to every torrent at add time
/// and enforced by Transmission. Per-tracker rules are issue #25. /// and enforced by qBittorrent. Per-tracker rules are issue #25.
pub const DEFAULT_SEED_RATIO_LIMIT: f64 = 1.0; pub const DEFAULT_SEED_RATIO_LIMIT: f64 = 1.0;
pub const DEFAULT_SEED_IDLE_LIMIT_MINUTES: u64 = 4320; pub const DEFAULT_SEED_IDLE_LIMIT_MINUTES: u64 = 4320;
pub const DEFAULT_JELLYFIN_URL: &str = "http://localhost:8096"; pub const DEFAULT_JELLYFIN_URL: &str = "http://localhost:8096";
@@ -97,7 +102,7 @@ struct ConfigFile {
#[serde(default)] #[serde(default)]
prowlarr_url: Option<String>, prowlarr_url: Option<String>,
#[serde(default)] #[serde(default)]
transmission_url: Option<String>, qbittorrent_url: Option<String>,
#[serde(default)] #[serde(default)]
download_dir: Option<PathBuf>, download_dir: Option<PathBuf>,
#[serde(default)] #[serde(default)]
@@ -151,7 +156,9 @@ pub struct EnvOverrides {
pub media_root: Option<String>, pub media_root: Option<String>,
pub prowlarr_url: Option<String>, pub prowlarr_url: Option<String>,
pub prowlarr_api_key: Option<String>, pub prowlarr_api_key: Option<String>,
pub transmission_url: Option<String>, pub qbittorrent_url: Option<String>,
pub qbittorrent_username: Option<String>,
pub qbittorrent_password: Option<String>,
pub download_dir: Option<String>, pub download_dir: Option<String>,
pub seed_ratio_limit: Option<String>, pub seed_ratio_limit: Option<String>,
pub seed_idle_limit_minutes: Option<String>, pub seed_idle_limit_minutes: Option<String>,
@@ -183,7 +190,9 @@ impl EnvOverrides {
media_root: std::env::var(ENV_MEDIA_ROOT).ok(), media_root: std::env::var(ENV_MEDIA_ROOT).ok(),
prowlarr_url: std::env::var(ENV_PROWLARR_URL).ok(), prowlarr_url: std::env::var(ENV_PROWLARR_URL).ok(),
prowlarr_api_key: std::env::var(ENV_PROWLARR_API_KEY).ok(), prowlarr_api_key: std::env::var(ENV_PROWLARR_API_KEY).ok(),
transmission_url: std::env::var(ENV_TRANSMISSION_URL).ok(), qbittorrent_url: std::env::var(ENV_QBITTORRENT_URL).ok(),
qbittorrent_username: std::env::var(ENV_QBITTORRENT_USERNAME).ok(),
qbittorrent_password: std::env::var(ENV_QBITTORRENT_PASSWORD).ok(),
download_dir: std::env::var(ENV_DOWNLOAD_DIR).ok(), download_dir: std::env::var(ENV_DOWNLOAD_DIR).ok(),
seed_ratio_limit: std::env::var(ENV_SEED_RATIO_LIMIT).ok(), seed_ratio_limit: std::env::var(ENV_SEED_RATIO_LIMIT).ok(),
seed_idle_limit_minutes: std::env::var(ENV_SEED_IDLE_LIMIT_MINUTES).ok(), seed_idle_limit_minutes: std::env::var(ENV_SEED_IDLE_LIMIT_MINUTES).ok(),
@@ -216,8 +225,13 @@ pub struct Config {
pub media_root: PathBuf, pub media_root: PathBuf,
pub prowlarr_url: String, pub prowlarr_url: String,
pub prowlarr_api_key: Option<String>, pub prowlarr_api_key: Option<String>,
pub transmission_url: String, pub qbittorrent_url: String,
/// Where Transmission puts completed downloads, in Transmission's own /// The `WebUI` login. `None` for an instance that bypasses authentication
/// for arr's address; a username without a password (or the reverse) is
/// treated as no credentials at all.
pub qbittorrent_username: Option<String>,
pub qbittorrent_password: Option<String>,
/// Where qBittorrent puts completed downloads, in qBittorrent's own
/// namespace (§7.1). /// namespace (§7.1).
pub download_dir: PathBuf, pub download_dir: PathBuf,
pub seed_ratio_limit: f64, pub seed_ratio_limit: f64,
@@ -362,10 +376,12 @@ impl Config {
.or(file.prowlarr_url) .or(file.prowlarr_url)
.unwrap_or_else(|| DEFAULT_PROWLARR_URL.to_string()), .unwrap_or_else(|| DEFAULT_PROWLARR_URL.to_string()),
prowlarr_api_key: env.prowlarr_api_key, prowlarr_api_key: env.prowlarr_api_key,
transmission_url: env qbittorrent_url: env
.transmission_url .qbittorrent_url
.or(file.transmission_url) .or(file.qbittorrent_url)
.unwrap_or_else(|| DEFAULT_TRANSMISSION_URL.to_string()), .unwrap_or_else(|| DEFAULT_QBITTORRENT_URL.to_string()),
qbittorrent_username: env.qbittorrent_username,
qbittorrent_password: env.qbittorrent_password,
download_dir: env download_dir: env
.download_dir .download_dir
.map(PathBuf::from) .map(PathBuf::from)
@@ -429,6 +445,28 @@ fn parse_bind_addr(raw: &str, env: &'static str) -> Result<SocketAddr, ConfigErr
mod tests { mod tests {
use super::*; use super::*;
/// §10 keeps secrets out of the config file, so the `WebUI` login is
/// env-only and absent by default — which is the shape an instance that
/// bypasses authentication for arr's address wants.
#[test]
fn the_qbittorrent_login_is_env_only_and_optional() {
let config = Config::resolve(EnvOverrides::default()).unwrap();
assert_eq!(config.qbittorrent_username, None);
assert_eq!(config.qbittorrent_password, None);
let config = Config::resolve(EnvOverrides {
qbittorrent_username: Some("arr".into()),
qbittorrent_password: Some("secret".into()),
..EnvOverrides::default()
})
.unwrap();
assert_eq!(config.qbittorrent_username.as_deref(), Some("arr"));
let error = toml::from_str::<ConfigFile>("qbittorrent_password = \"secret\"")
.expect_err("the file has no field for it");
assert!(error.to_string().contains("qbittorrent_password"));
}
#[test] #[test]
fn empty_environment_resolves_to_defaults() { fn empty_environment_resolves_to_defaults() {
let config = Config::resolve(EnvOverrides::default()).unwrap(); let config = Config::resolve(EnvOverrides::default()).unwrap();
@@ -436,7 +474,7 @@ mod tests {
assert_eq!(config.database_path, PathBuf::from(DEFAULT_DATABASE_PATH)); assert_eq!(config.database_path, PathBuf::from(DEFAULT_DATABASE_PATH));
assert_eq!(config.media_root, PathBuf::from(DEFAULT_MEDIA_ROOT)); assert_eq!(config.media_root, PathBuf::from(DEFAULT_MEDIA_ROOT));
assert_eq!(config.prowlarr_url, DEFAULT_PROWLARR_URL); assert_eq!(config.prowlarr_url, DEFAULT_PROWLARR_URL);
assert_eq!(config.transmission_url, DEFAULT_TRANSMISSION_URL); assert_eq!(config.qbittorrent_url, DEFAULT_QBITTORRENT_URL);
assert_eq!(config.download_dir, PathBuf::from(DEFAULT_DOWNLOAD_DIR)); assert_eq!(config.download_dir, PathBuf::from(DEFAULT_DOWNLOAD_DIR));
assert!((config.seed_ratio_limit - DEFAULT_SEED_RATIO_LIMIT).abs() < f64::EPSILON); assert!((config.seed_ratio_limit - DEFAULT_SEED_RATIO_LIMIT).abs() < f64::EPSILON);
assert_eq!( assert_eq!(
@@ -491,7 +529,7 @@ prowlarr_url = "http://prowlarr.internal:9696"
assert_eq!(config.media_root, PathBuf::from("/tank/media")); assert_eq!(config.media_root, PathBuf::from("/tank/media"));
assert_eq!(config.prowlarr_url, "http://prowlarr.internal:9696"); assert_eq!(config.prowlarr_url, "http://prowlarr.internal:9696");
// Untouched fields still default. // Untouched fields still default.
assert_eq!(config.transmission_url, DEFAULT_TRANSMISSION_URL); assert_eq!(config.qbittorrent_url, DEFAULT_QBITTORRENT_URL);
} }
#[test] #[test]
+109 -228
View File
@@ -1,5 +1,5 @@
//! The grab pipeline: close the "wanted movie, no file" gap by searching, //! The grab pipeline: close the "wanted movie, no file" gap by searching,
//! scoring, picking a winner and sending it to Transmission. See DESIGN.md //! scoring, picking a winner and sending it to qBittorrent. See DESIGN.md
//! §5.4, §6.2, §7.1, §7.3 and §8. //! §5.4, §6.2, §7.1, §7.3 and §8.
//! //!
//! There is no grab delay and there is no job queue. The gap is recomputed //! There is no grab delay and there is no job queue. The gap is recomputed
@@ -7,7 +7,7 @@
//! restarting converges instead of double-grabbing: //! restarting converges instead of double-grabbing:
//! //!
//! - a title with a live `grabs` row is not a gap, so it is never re-searched; //! - a title with a live `grabs` row is not a gap, so it is never re-searched;
//! - Transmission's `torrent-add` is keyed on the infohash, so re-sending the //! - qBittorrent's `torrent-add` is keyed on the infohash, so re-sending the
//! same release returns the torrent that is already there rather than a //! same release returns the torrent that is already there rather than a
//! second one; //! second one;
//! - the `grabs` row is written from that response, so a crash between the add //! - the `grabs` row is written from that response, so a crash between the add
@@ -22,7 +22,7 @@ use arr_core::policy::{evaluate, Candidate};
use arr_core::score::{claimed_episode_count, score}; use arr_core::score::{claimed_episode_count, score};
use arr_core::{Language, Policy, TitleOverrides, Verdict}; use arr_core::{Language, Policy, TitleOverrides, Verdict};
use arr_db::{blacklist, Blacklist, Db, MoviePolicy}; use arr_db::{blacklist, Blacklist, Db, MoviePolicy};
use arr_dl::{AddTorrent, TorrentSource, TransmissionClient}; use arr_dl::{AddTorrent, QbitClient, TorrentSource};
use arr_indexer::{Download, ProwlarrClient, SearchRelease, SearchRequest}; use arr_indexer::{Download, ProwlarrClient, SearchRelease, SearchRequest};
use arr_meta::TmdbClient; use arr_meta::TmdbClient;
@@ -37,7 +37,7 @@ const MOVIES_PER_TICK: i64 = 5;
/// Seeding obligations, per tracker in principle (§7.3) and per install in /// Seeding obligations, per tracker in principle (§7.3) and per install in
/// practice until issue #25 gives them a home. Both are set on the torrent at /// practice until issue #25 gives them a home. Both are set on the torrent at
/// add time and enforced by Transmission. /// add time and enforced by qBittorrent.
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
pub struct SeedingLimits { pub struct SeedingLimits {
pub ratio: f64, pub ratio: f64,
@@ -77,8 +77,8 @@ pub enum GrabError {
Metadata(#[from] arr_meta::Error), Metadata(#[from] arr_meta::Error),
#[error("movie {0} has an invalid TMDB id")] #[error("movie {0} has an invalid TMDB id")]
InvalidTmdbId(i64), InvalidTmdbId(i64),
#[error("transmission: {0}")] #[error("qbit: {0}")]
Transmission(#[from] arr_dl::Error), Qbit(#[from] arr_dl::Error),
#[error("download link: {0}")] #[error("download link: {0}")]
Download(#[from] arr_indexer::DownloadError), Download(#[from] arr_indexer::DownloadError),
#[error("release {name}: {source}")] #[error("release {name}: {source}")]
@@ -102,13 +102,13 @@ impl GrabAction {
#[must_use] #[must_use]
pub fn new( pub fn new(
prowlarr: ProwlarrClient, prowlarr: ProwlarrClient,
transmission: TransmissionClient, qbit: QbitClient,
download_dir: PathBuf, download_dir: PathBuf,
seeding: SeedingRules, seeding: SeedingRules,
) -> Self { ) -> Self {
Self { Self {
indexers: IndexerDirectory::new(prowlarr.clone()), indexers: IndexerDirectory::new(prowlarr.clone()),
grabber: Grabber::new(prowlarr.clone(), transmission, download_dir, seeding), grabber: Grabber::new(prowlarr.clone(), qbit, download_dir, seeding),
prowlarr, prowlarr,
tmdb: None, tmdb: None,
} }
@@ -427,7 +427,7 @@ impl GrabAction {
/// The manual one-click grab (§9.3, issue #107): the release is already /// The manual one-click grab (§9.3, issue #107): the release is already
/// chosen, so this skips search and scoring and sends it straight to /// chosen, so this skips search and scoring and sends it straight to
/// Transmission. /// qBittorrent.
pub(crate) async fn grab_release_now( pub(crate) async fn grab_release_now(
&self, &self,
database: &Db, database: &Db,
@@ -460,17 +460,17 @@ impl GrabAction {
} }
} }
/// Sending a chosen release to Transmission and recording the grab. /// Sending a chosen release to qBittorrent and recording the grab.
/// ///
/// Targeted search and RSS (§6.2) differ in how a title is chosen and in /// Targeted search and RSS (§6.2) differ in how a title is chosen and in
/// whether a failed attempt counts toward a backoff; from the winning /// whether a failed attempt counts toward a backoff; from the winning
/// release onward they are the same writes, so they share this. /// release onward they are the same writes, so they share this.
#[derive(Debug)] #[derive(Debug)]
pub(crate) struct Grabber { pub(crate) struct Grabber {
/// Resolves the winner's indexer link before it is sent on: Transmission /// Resolves the winner's indexer link before it is sent on: qBittorrent
/// cannot reach Prowlarr and arr can (issue #100). /// cannot reach Prowlarr and arr can (issue #100).
prowlarr: ProwlarrClient, prowlarr: ProwlarrClient,
transmission: TransmissionClient, qbit: QbitClient,
download_dir: PathBuf, download_dir: PathBuf,
seeding: SeedingRules, seeding: SeedingRules,
} }
@@ -532,22 +532,22 @@ impl GrabScope {
impl Grabber { impl Grabber {
pub(crate) fn new( pub(crate) fn new(
prowlarr: ProwlarrClient, prowlarr: ProwlarrClient,
transmission: TransmissionClient, qbit: QbitClient,
download_dir: PathBuf, download_dir: PathBuf,
seeding: SeedingRules, seeding: SeedingRules,
) -> Self { ) -> Self {
Self { Self {
prowlarr, prowlarr,
transmission, qbit,
download_dir, download_dir,
seeding, seeding,
} }
} }
/// Move grabs Transmission reports as complete out of `sent`, whatever /// Move grabs qBittorrent reports as complete out of `sent`, whatever
/// they target. /// they target.
/// ///
/// Transmission is authoritative and its view is rebuilt on every tick /// qBittorrent is authoritative and its view is rebuilt on every tick
/// rather than cached (§8), so this is also what reconstructs in-flight /// rather than cached (§8), so this is also what reconstructs in-flight
/// state after a restart. Both grab actions call it; whichever runs first /// state after a restart. Both grab actions call it; whichever runs first
/// does the work and the other finds nothing. /// does the work and the other finds nothing.
@@ -564,7 +564,7 @@ impl Grabber {
} }
let torrents: HashMap<String, f64> = self let torrents: HashMap<String, f64> = self
.transmission .qbit
.list_torrents() .list_torrents()
.await? .await?
.into_iter() .into_iter()
@@ -603,7 +603,7 @@ impl Grabber {
Ok(outcomes) Ok(outcomes)
} }
/// §86/#108: a `sent` grab whose infohash Transmission no longer reports /// §86/#108: a `sent` grab whose infohash qBittorrent no longer reports
/// — removed by hand, not a policy failure. Marked `vanished` rather than /// — removed by hand, not a policy failure. Marked `vanished` rather than
/// `failed` so it does not feed the `needs_decision` queue (attention.rs). /// `failed` so it does not feed the `needs_decision` queue (attention.rs).
/// Nothing is blacklisted, since the release itself never failed policy, /// Nothing is blacklisted, since the release itself never failed policy,
@@ -624,10 +624,10 @@ impl Grabber {
grab_id, grab_id,
target_kind, target_kind,
target_id, target_id,
"torrent vanished from Transmission; target parked" "torrent vanished from qBittorrent; target parked"
); );
Ok(Outcome::new( Ok(Outcome::new(
format!("grab {grab_id} sent, torrent vanished from Transmission"), format!("grab {grab_id} sent, torrent vanished from qBittorrent"),
format!("parked {target_kind} {target_id}"), format!("parked {target_kind} {target_id}"),
)) ))
} }
@@ -646,12 +646,12 @@ impl Grabber {
} }
} }
/// Resolve the winner's indexer link and add it to Transmission. /// Resolve the winner's indexer link and add it to qBittorrent.
/// ///
/// The link is resolved here rather than passed on, because Transmission /// The link is resolved here rather than passed on, because qBittorrent
/// has no route to Prowlarr and cannot follow its redirect to a magnet /// has no route to Prowlarr and cannot follow its redirect to a magnet
/// (issue #100). /// (issue #100).
async fn send_to_transmission( async fn send_to_qbit(
&self, &self,
winner: &Eligible, winner: &Eligible,
loaded: &MoviePolicy, loaded: &MoviePolicy,
@@ -659,7 +659,7 @@ impl Grabber {
let seeding = self.seeding.for_indexer(winner.indexer_id); let seeding = self.seeding.for_indexer(winner.indexer_id);
let source = torrent_source(self.prowlarr.download(&winner.download_url).await?); let source = torrent_source(self.prowlarr.download(&winner.download_url).await?);
Ok(self Ok(self
.transmission .qbit
.add_torrent(AddTorrent { .add_torrent(AddTorrent {
source, source,
label: label(loaded), label: label(loaded),
@@ -670,10 +670,10 @@ impl Grabber {
.await?) .await?)
} }
/// Add the winning release to Transmission and record the grab. /// Add the winning release to qBittorrent and record the grab.
/// ///
/// The search still counts as an attempt (§6.2) on every exit that is /// The search still counts as an attempt (§6.2) on every exit that is
/// not a completed grab — a Transmission error or a blacklisted-infohash /// not a completed grab — a qBittorrent error or a blacklisted-infohash
/// drop must not leave the same release to repeat next tick with no /// drop must not leave the same release to repeat next tick with no
/// backoff. /// backoff.
pub(crate) async fn send_winner( pub(crate) async fn send_winner(
@@ -684,7 +684,7 @@ impl Grabber {
blacklist: &Blacklist, blacklist: &Blacklist,
winner: Eligible, winner: Eligible,
) -> Result<Option<Outcome>, GrabError> { ) -> Result<Option<Outcome>, GrabError> {
let added = match self.send_to_transmission(&winner, loaded).await { let added = match self.send_to_qbit(&winner, loaded).await {
Ok(added) => added, Ok(added) => added,
Err(error) => { Err(error) => {
self.record_attempt(database, target).await?; self.record_attempt(database, target).await?;
@@ -694,7 +694,7 @@ impl Grabber {
let infohash = added.hash.to_ascii_lowercase(); let infohash = added.hash.to_ascii_lowercase();
// §6.3's second key. A `.torrent` link hides its infohash until // §6.3's second key. A `.torrent` link hides its infohash until
// Transmission has fetched it, so the same blacklisted torrent can // qBittorrent has fetched it, so the same blacklisted torrent can
// reach here under a new name. // reach here under a new name.
if blacklist.blocks_infohash(&infohash) { if blacklist.blocks_infohash(&infohash) {
self.record_attempt(database, target).await?; self.record_attempt(database, target).await?;
@@ -782,7 +782,7 @@ impl Grabber {
/// Undo a grab whose infohash turned out to be blacklisted (§6.3). /// Undo a grab whose infohash turned out to be blacklisted (§6.3).
/// ///
/// The name is added to the blacklist so the next tick stops at the cheap /// The name is added to the blacklist so the next tick stops at the cheap
/// check instead of paying Transmission again, and no `grabs` row is /// check instead of paying qBittorrent again, and no `grabs` row is
/// written, which leaves the title a gap for the next candidate. /// written, which leaves the title a gap for the next candidate.
async fn drop_blacklisted_torrent( async fn drop_blacklisted_torrent(
&self, &self,
@@ -822,7 +822,7 @@ impl Grabber {
} else { } else {
// This tick added it seconds ago, so it carries no seeding // This tick added it seconds ago, so it carries no seeding
// obligation and has nothing on disk worth keeping. // obligation and has nothing on disk worth keeping.
self.transmission.remove_torrent(added.id, true).await?; self.qbit.remove_torrent(&added.hash, true).await?;
tracing::warn!( tracing::warn!(
title = target.title, title = target.title,
release = release_name, release = release_name,
@@ -1509,7 +1509,7 @@ pub(crate) async fn park_target(
} }
/// `movies-main`, `tv-kids` (§7.1). Distinct from Radarr's own labels, so /// `movies-main`, `tv-kids` (§7.1). Distinct from Radarr's own labels, so
/// both stacks can run against one Transmission. /// both stacks can run against one qBittorrent.
fn label(loaded: &MoviePolicy) -> String { fn label(loaded: &MoviePolicy) -> String {
label_for_root(&loaded.root_kind, &loaded.root_audience) label_for_root(&loaded.root_kind, &loaded.root_audience)
} }
@@ -1519,7 +1519,7 @@ pub(crate) fn label_for_root(kind: &str, audience: &str) -> String {
format!("{kind}-{audience}") format!("{kind}-{audience}")
} }
/// A resolved download, in the shape Transmission takes it. /// A resolved download, in the shape qBittorrent takes it.
fn torrent_source(download: Download) -> TorrentSource { fn torrent_source(download: Download) -> TorrentSource {
match download { match download {
Download::Magnet(uri) => TorrentSource::Magnet(uri), Download::Magnet(uri) => TorrentSource::Magnet(uri),
@@ -1572,6 +1572,18 @@ pub(crate) mod test_downloads {
feed.replace(links, &format!("{}{PREFIX}", server.uri())) feed.replace(links, &format!("{}{PREFIX}", server.uri()))
} }
/// The infohash the mock indexer's magnet for `name` carries.
///
/// Deterministic and distinct per release, so duplicates still collapse
/// and a test can blacklist a hash before the tick that would grab it.
pub(crate) fn infohash_for(name: &str) -> String {
let mut hash: u64 = 5381;
for byte in name.as_bytes() {
hash = hash.wrapping_mul(33) ^ u64::from(*byte);
}
format!("{hash:040x}")
}
/// Prowlarr's live behaviour: the download endpoint 302s to a magnet. /// Prowlarr's live behaviour: the download endpoint 302s to a magnet.
pub(crate) async fn mount(server: &MockServer) { pub(crate) async fn mount(server: &MockServer) {
Mock::given(method("GET")) Mock::given(method("GET"))
@@ -1592,16 +1604,9 @@ pub(crate) mod test_downloads {
.next() .next()
.unwrap_or_default() .unwrap_or_default()
.to_owned(); .to_owned();
// Deterministic stand-in for the infohash the tracker would give, let hash = super::test_downloads::infohash_for(&name);
// and distinct per release so duplicates still collapse. ResponseTemplate::new(302)
let mut hash: u64 = 5381; .insert_header("location", format!("magnet:?xt=urn:btih:{hash}&dn={name}"))
for byte in name.as_bytes() {
hash = hash.wrapping_mul(33) ^ u64::from(*byte);
}
ResponseTemplate::new(302).insert_header(
"location",
format!("magnet:?xt=urn:btih:{hash:040x}&dn={name}"),
)
} }
} }
} }
@@ -1609,127 +1614,16 @@ pub(crate) mod test_downloads {
#[cfg(test)] #[cfg(test)]
#[allow(clippy::unwrap_used)] #[allow(clippy::unwrap_used)]
mod tests { mod tests {
use base64::Engine as _;
use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
use serde_json::{json, Value}; use serde_json::json;
use wiremock::matchers::{method, path, query_param}; use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; use wiremock::{Mock, MockServer, ResponseTemplate};
use crate::qbit_fake::FakeQbit;
use super::*; use super::*;
/// A Transmission that dedupes on the infohash, like the real one: the
/// same source added twice is one torrent and a `torrent-duplicate`
/// response. That is the property the restart case leans on.
#[derive(Clone, Debug, Default)]
struct FakeTransmission {
torrents: Arc<Mutex<Vec<FakeTorrent>>>,
}
#[derive(Clone, Debug)]
struct FakeTorrent {
id: i64,
hash: String,
source: String,
labels: Vec<String>,
progress: f64,
}
impl FakeTransmission {
fn torrents(&self) -> Vec<FakeTorrent> {
self.torrents.lock().unwrap().clone()
}
fn complete_all(&self) {
for torrent in self.torrents.lock().unwrap().iter_mut() {
torrent.progress = 1.0;
}
}
fn add(&self, arguments: &Value) -> ResponseTemplate {
// A magnet arrives as `filename`, a torrent body as base64
// `metainfo` — either identifies the torrent for the fake.
let source = arguments["filename"]
.as_str()
.or_else(|| arguments["metainfo"].as_str())
.unwrap_or_default()
.to_owned();
let labels: Vec<String> = arguments["labels"]
.as_array()
.map(|values| {
values
.iter()
.filter_map(|value| value.as_str().map(ToOwned::to_owned))
.collect()
})
.unwrap_or_default();
let mut torrents = self.torrents.lock().unwrap();
if let Some(existing) = torrents.iter().find(|torrent| torrent.source == source) {
return success(&json!({"torrent-duplicate": {
"id": existing.id, "name": existing.source, "hashString": existing.hash
}}));
}
let id = i64::try_from(torrents.len()).unwrap() + 1;
// Deterministic stand-in for the real infohash, which likewise
// comes out the same for the same torrent.
let hash = format!("{:040x}", id * 7);
torrents.push(FakeTorrent {
id,
hash: hash.clone(),
source: source.clone(),
labels,
progress: 0.0,
});
success(&json!({"torrent-added": {"id": id, "name": source, "hashString": hash}}))
}
}
impl Respond for FakeTransmission {
fn respond(&self, request: &Request) -> ResponseTemplate {
let body: Value = serde_json::from_slice(&request.body).unwrap();
let arguments = &body["arguments"];
match body["method"].as_str().unwrap_or_default() {
"torrent-add" => self.add(arguments),
"torrent-get" => {
let torrents: Vec<Value> = self
.torrents()
.into_iter()
.map(|torrent| {
json!({
"id": torrent.id, "name": torrent.source,
"hashString": torrent.hash, "status": 4,
"percentDone": torrent.progress,
"downloadDir": "/downloads", "labels": torrent.labels
})
})
.collect();
success(&json!({"torrents": torrents}))
}
"torrent-remove" => {
let removed: Vec<i64> = arguments["ids"]
.as_array()
.map(|ids| ids.iter().filter_map(serde_json::Value::as_i64).collect())
.unwrap_or_default();
self.torrents
.lock()
.unwrap()
.retain(|torrent| !removed.contains(&torrent.id));
success(&json!({}))
}
_ => success(&json!({})),
}
}
}
fn success(arguments: &Value) -> ResponseTemplate {
ResponseTemplate::new(200).set_body_json(json!({
"result": "success", "arguments": arguments
}))
}
const RSS: &str = r#"<rss><channel> const RSS: &str = r#"<rss><channel>
<item> <item>
<title>Dune.Part.Two.2024.2160p.WEB-DL.DDP5.1.Atmos</title> <title>Dune.Part.Two.2024.2160p.WEB-DL.DDP5.1.Atmos</title>
@@ -1846,14 +1740,8 @@ mod tests {
server server
} }
async fn transmission() -> (MockServer, FakeTransmission) { async fn qbit() -> (MockServer, FakeQbit) {
let server = MockServer::start().await; FakeQbit::start().await
let fake = FakeTransmission::default();
Mock::given(method("POST"))
.respond_with(fake.clone())
.mount(&server)
.await;
(server, fake)
} }
async fn wanted_movie() -> (tempfile::TempDir, Db) { async fn wanted_movie() -> (tempfile::TempDir, Db) {
@@ -1871,11 +1759,11 @@ mod tests {
(dir, database) (dir, database)
} }
fn action(prowlarr: &MockServer, transmission: &MockServer) -> GrabAction { fn action(prowlarr: &MockServer, qbit: &MockServer) -> GrabAction {
GrabAction::new( GrabAction::new(
ProwlarrClient::new(prowlarr.uri(), "key").unwrap(), ProwlarrClient::new(prowlarr.uri(), "key").unwrap(),
TransmissionClient::new(&transmission.uri()).unwrap(), QbitClient::new(&qbit.uri()).unwrap(),
PathBuf::from("/mnt/media/transmission/complete"), PathBuf::from("/mnt/media/qbittorrent/complete"),
SeedingRules::new( SeedingRules::new(
SeedingLimits { SeedingLimits {
ratio: 1.5, ratio: 1.5,
@@ -1888,10 +1776,10 @@ mod tests {
fn action_with_tmdb( fn action_with_tmdb(
prowlarr: &MockServer, prowlarr: &MockServer,
transmission: &MockServer, qbit: &MockServer,
metadata: &MockServer, metadata: &MockServer,
) -> GrabAction { ) -> GrabAction {
action(prowlarr, transmission).with_tmdb(Arc::new( action(prowlarr, qbit).with_tmdb(Arc::new(
TmdbClient::builder("key") TmdbClient::builder("key")
.base_url(format!("{}/3/", metadata.uri())) .base_url(format!("{}/3/", metadata.uri()))
.build() .build()
@@ -1945,7 +1833,7 @@ mod tests {
async fn a_wanted_movie_ends_with_one_torrent_and_one_grab() { async fn a_wanted_movie_ends_with_one_torrent_and_one_grab() {
let (_dir, database) = wanted_movie().await; let (_dir, database) = wanted_movie().await;
let indexer = prowlarr().await; let indexer = prowlarr().await;
let (downloader, fake) = transmission().await; let (downloader, fake) = qbit().await;
let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap(); let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap();
@@ -1971,10 +1859,10 @@ mod tests {
async fn a_restart_mid_flight_does_not_grab_twice() { async fn a_restart_mid_flight_does_not_grab_twice() {
let (_dir, database) = wanted_movie().await; let (_dir, database) = wanted_movie().await;
let indexer = prowlarr().await; let indexer = prowlarr().await;
let (downloader, fake) = transmission().await; let (downloader, fake) = qbit().await;
action(&indexer, &downloader).tick(&database).await.unwrap(); action(&indexer, &downloader).tick(&database).await.unwrap();
// The crash: Transmission has the torrent, the database does not know. // The crash: qBittorrent has the torrent, the database does not know.
sqlx::query("DELETE FROM grabs") sqlx::query("DELETE FROM grabs")
.execute(database.pool()) .execute(database.pool())
.await .await
@@ -1996,7 +1884,7 @@ mod tests {
async fn a_second_tick_grabs_nothing_new() { async fn a_second_tick_grabs_nothing_new() {
let (_dir, database) = wanted_movie().await; let (_dir, database) = wanted_movie().await;
let indexer = prowlarr().await; let indexer = prowlarr().await;
let (downloader, fake) = transmission().await; let (downloader, fake) = qbit().await;
let action = action(&indexer, &downloader); let action = action(&indexer, &downloader);
action.tick(&database).await.unwrap(); action.tick(&database).await.unwrap();
@@ -2013,26 +1901,17 @@ mod tests {
async fn the_label_and_both_seed_limits_are_set_at_add_time() { async fn the_label_and_both_seed_limits_are_set_at_add_time() {
let (_dir, database) = wanted_movie().await; let (_dir, database) = wanted_movie().await;
let indexer = prowlarr().await; let indexer = prowlarr().await;
let (downloader, fake) = transmission().await; let (downloader, fake) = qbit().await;
action(&indexer, &downloader).tick(&database).await.unwrap(); action(&indexer, &downloader).tick(&database).await.unwrap();
assert_eq!(fake.torrents()[0].labels, vec!["movies-main".to_owned()]); let added = &fake.torrents()[0];
let add = downloader assert_eq!(added.labels, vec!["movies-main".to_owned()]);
.received_requests() assert!((added.ratio_limit - 1.5).abs() < f64::EPSILON);
.await assert_eq!(added.idle_limit_minutes, 60);
.unwrap()
.into_iter()
.filter_map(|request| serde_json::from_slice::<Value>(&request.body).ok())
.find(|body| body["method"] == "torrent-add")
.expect("torrent-add");
assert_eq!(add["arguments"]["seedRatioLimit"], json!(1.5));
assert_eq!(add["arguments"]["seedIdleLimit"], json!(60));
assert_eq!(add["arguments"]["seedRatioMode"], json!(1));
assert_eq!(add["arguments"]["seedIdleMode"], json!(1));
assert_eq!( assert_eq!(
add["arguments"]["download-dir"], added.save_path,
json!("/mnt/media/transmission/complete") String::from("/mnt/media/qbittorrent/complete")
); );
} }
@@ -2058,7 +1937,7 @@ mod tests {
async fn every_candidate_is_recorded_with_its_verdict() { async fn every_candidate_is_recorded_with_its_verdict() {
let (_dir, database) = wanted_movie().await; let (_dir, database) = wanted_movie().await;
let indexer = prowlarr().await; let indexer = prowlarr().await;
let (downloader, _fake) = transmission().await; let (downloader, _fake) = qbit().await;
action(&indexer, &downloader).tick(&database).await.unwrap(); action(&indexer, &downloader).tick(&database).await.unwrap();
@@ -2081,13 +1960,13 @@ mod tests {
assert_eq!(attempts, 0); assert_eq!(attempts, 0);
} }
/// Transmission is authoritative (§8): a completed torrent moves its grab /// qBittorrent is authoritative (§8): a completed torrent moves its grab
/// out of `sent` without the process having watched it happen. /// out of `sent` without the process having watched it happen.
#[tokio::test] #[tokio::test]
async fn a_completed_torrent_moves_its_grab_to_downloaded() { async fn a_completed_torrent_moves_its_grab_to_downloaded() {
let (_dir, database) = wanted_movie().await; let (_dir, database) = wanted_movie().await;
let indexer = prowlarr().await; let indexer = prowlarr().await;
let (downloader, fake) = transmission().await; let (downloader, fake) = qbit().await;
let action = action(&indexer, &downloader); let action = action(&indexer, &downloader);
action.tick(&database).await.unwrap(); action.tick(&database).await.unwrap();
@@ -2099,14 +1978,14 @@ mod tests {
} }
/// #108, overriding §86: a torrent removed by hand — gone from /// #108, overriding §86: a torrent removed by hand — gone from
/// Transmission before it finished — parks the movie instead of /// qBittorrent before it finished — parks the movie instead of
/// reopening the gap, and is marked `vanished` rather than `failed` so /// reopening the gap, and is marked `vanished` rather than `failed` so
/// it never counts toward the `needs_decision` queue (attention.rs). /// it never counts toward the `needs_decision` queue (attention.rs).
#[tokio::test] #[tokio::test]
async fn a_torrent_removed_by_hand_parks_the_movie() { async fn a_torrent_removed_by_hand_parks_the_movie() {
let (_dir, database) = wanted_movie().await; let (_dir, database) = wanted_movie().await;
let indexer = prowlarr().await; let indexer = prowlarr().await;
let (downloader, fake) = transmission().await; let (downloader, fake) = qbit().await;
action(&indexer, &downloader).tick(&database).await.unwrap(); action(&indexer, &downloader).tick(&database).await.unwrap();
fake.torrents.lock().unwrap().clear(); fake.torrents.lock().unwrap().clear();
@@ -2131,7 +2010,7 @@ mod tests {
async fn a_parked_movie_is_not_regrabbed() { async fn a_parked_movie_is_not_regrabbed() {
let (_dir, database) = wanted_movie().await; let (_dir, database) = wanted_movie().await;
let indexer = prowlarr().await; let indexer = prowlarr().await;
let (downloader, fake) = transmission().await; let (downloader, fake) = qbit().await;
let action = action(&indexer, &downloader); let action = action(&indexer, &downloader);
action.tick(&database).await.unwrap(); action.tick(&database).await.unwrap();
@@ -2154,7 +2033,7 @@ mod tests {
async fn a_rewanted_movie_can_regrab_the_same_infohash() { async fn a_rewanted_movie_can_regrab_the_same_infohash() {
let (_dir, database) = wanted_movie().await; let (_dir, database) = wanted_movie().await;
let indexer = prowlarr().await; let indexer = prowlarr().await;
let (downloader, fake) = transmission().await; let (downloader, fake) = qbit().await;
let action = action(&indexer, &downloader); let action = action(&indexer, &downloader);
action.tick(&database).await.unwrap(); action.tick(&database).await.unwrap();
@@ -2189,7 +2068,7 @@ mod tests {
.await .await
.unwrap(); .unwrap();
let indexer = prowlarr().await; let indexer = prowlarr().await;
let (downloader, fake) = transmission().await; let (downloader, fake) = qbit().await;
let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap(); let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap();
@@ -2212,7 +2091,7 @@ mod tests {
.await .await
.unwrap(); .unwrap();
let indexer = prowlarr().await; let indexer = prowlarr().await;
let (downloader, fake) = transmission().await; let (downloader, fake) = qbit().await;
action(&indexer, &downloader).tick(&database).await.unwrap(); action(&indexer, &downloader).tick(&database).await.unwrap();
@@ -2240,7 +2119,7 @@ mod tests {
.await .await
.unwrap(); .unwrap();
let indexer = prowlarr().await; let indexer = prowlarr().await;
let (downloader, _fake) = transmission().await; let (downloader, _fake) = qbit().await;
action(&indexer, &downloader).tick(&database).await.unwrap(); action(&indexer, &downloader).tick(&database).await.unwrap();
@@ -2253,23 +2132,24 @@ mod tests {
assert_eq!(rule.as_deref(), Some("blacklisted")); assert_eq!(rule.as_deref(), Some("blacklisted"));
} }
/// §6.3's second key. A `.torrent` link hides its infohash until /// §6.3's second key. The infohash is only known once the indexer link
/// Transmission has fetched it, so the blacklisted torrent is only /// has been resolved, which happens at grab time, so a blacklisted
/// recognised after the add — and must not leave a grab behind. /// torrent under a new release name is recognised after the add — and
/// must not leave a grab behind.
#[tokio::test] #[tokio::test]
async fn a_blacklisted_infohash_never_becomes_a_grab() { async fn a_blacklisted_infohash_never_becomes_a_grab() {
let (_dir, database) = wanted_movie().await; let (_dir, database) = wanted_movie().await;
// What the fake hands back for the first torrent it accepts. // The infohash the winning release's magnet carries.
blacklist::add( blacklist::add(
database.pool(), database.pool(),
Some(&format!("{:040x}", 7)), Some(&test_downloads::infohash_for("good.torrent")),
"Some.Older.Name.Of.The.Same.Torrent", "Some.Older.Name.Of.The.Same.Torrent",
"required_audio", "required_audio",
) )
.await .await
.unwrap(); .unwrap();
let indexer = prowlarr().await; let indexer = prowlarr().await;
let (downloader, fake) = transmission().await; let (downloader, fake) = qbit().await;
action(&indexer, &downloader).tick(&database).await.unwrap(); action(&indexer, &downloader).tick(&database).await.unwrap();
@@ -2279,7 +2159,7 @@ mod tests {
"a torrent added this tick and then found blacklisted is removed" "a torrent added this tick and then found blacklisted is removed"
); );
// Recorded under its new name, so the next tick stops before paying // Recorded under its new name, so the next tick stops before paying
// Transmission again. // qBittorrent again.
let blacklist = Blacklist::load(database.pool()).await.unwrap(); let blacklist = Blacklist::load(database.pool()).await.unwrap();
assert!(blacklist.blocks_name("Dune.Part.Two.2024.2160p.WEB-DL.DDP5.1.Atmos")); assert!(blacklist.blocks_name("Dune.Part.Two.2024.2160p.WEB-DL.DDP5.1.Atmos"));
// And the cached row stops reading eligible straight away, so §9.3's // And the cached row stops reading eligible straight away, so §9.3's
@@ -2329,7 +2209,7 @@ mod tests {
async fn a_manual_search_on_an_available_movie_refreshes_the_deck() { async fn a_manual_search_on_an_available_movie_refreshes_the_deck() {
let (_dir, database) = available_movie().await; let (_dir, database) = available_movie().await;
let indexer = prowlarr().await; let indexer = prowlarr().await;
let (downloader, fake) = transmission().await; let (downloader, fake) = qbit().await;
let outcome = action(&indexer, &downloader) let outcome = action(&indexer, &downloader)
.search_now(&database, 1) .search_now(&database, 1)
@@ -2351,7 +2231,7 @@ mod tests {
let (_dir, database) = available_movie().await; let (_dir, database) = available_movie().await;
let indexer = prowlarr().await; let indexer = prowlarr().await;
let metadata = tmdb(UNRELEASED_METADATA).await; let metadata = tmdb(UNRELEASED_METADATA).await;
let (downloader, _fake) = transmission().await; let (downloader, _fake) = qbit().await;
action_with_tmdb(&indexer, &downloader, &metadata) action_with_tmdb(&indexer, &downloader, &metadata)
.search_now(&database, 1) .search_now(&database, 1)
@@ -2367,7 +2247,7 @@ mod tests {
async fn a_manual_search_with_a_grab_in_flight_does_not_grab_again() { async fn a_manual_search_with_a_grab_in_flight_does_not_grab_again() {
let (_dir, database) = wanted_movie().await; let (_dir, database) = wanted_movie().await;
let indexer = prowlarr().await; let indexer = prowlarr().await;
let (downloader, fake) = transmission().await; let (downloader, fake) = qbit().await;
let action = action(&indexer, &downloader); let action = action(&indexer, &downloader);
action.tick(&database).await.unwrap(); action.tick(&database).await.unwrap();
assert_eq!(grabs(&database).await.len(), 1); assert_eq!(grabs(&database).await.len(), 1);
@@ -2384,7 +2264,7 @@ mod tests {
async fn a_manual_search_on_a_wanted_movie_still_grabs() { async fn a_manual_search_on_a_wanted_movie_still_grabs() {
let (_dir, database) = wanted_movie().await; let (_dir, database) = wanted_movie().await;
let indexer = prowlarr().await; let indexer = prowlarr().await;
let (downloader, fake) = transmission().await; let (downloader, fake) = qbit().await;
let outcome = action(&indexer, &downloader) let outcome = action(&indexer, &downloader)
.search_now(&database, 1) .search_now(&database, 1)
@@ -2405,7 +2285,7 @@ mod tests {
.await .await
.unwrap(); .unwrap();
let indexer = prowlarr().await; let indexer = prowlarr().await;
let (downloader, fake) = transmission().await; let (downloader, fake) = qbit().await;
let outcome = action(&indexer, &downloader) let outcome = action(&indexer, &downloader)
.search_now(&database, 1) .search_now(&database, 1)
@@ -2427,7 +2307,7 @@ mod tests {
.await .await
.unwrap(); .unwrap();
let indexer = prowlarr().await; let indexer = prowlarr().await;
let (downloader, fake) = transmission().await; let (downloader, fake) = qbit().await;
let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap(); let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap();
@@ -2441,7 +2321,7 @@ mod tests {
let (_dir, database) = wanted_movie().await; let (_dir, database) = wanted_movie().await;
let indexer = prowlarr().await; let indexer = prowlarr().await;
let metadata = tmdb(UNRELEASED_METADATA).await; let metadata = tmdb(UNRELEASED_METADATA).await;
let (downloader, _fake) = transmission().await; let (downloader, _fake) = qbit().await;
let action = action_with_tmdb(&indexer, &downloader, &metadata); let action = action_with_tmdb(&indexer, &downloader, &metadata);
for _ in 0..3 { for _ in 0..3 {
@@ -2461,7 +2341,7 @@ mod tests {
let (_dir, database) = wanted_movie().await; let (_dir, database) = wanted_movie().await;
let indexer = empty_prowlarr().await; let indexer = empty_prowlarr().await;
let metadata = tmdb(RELEASED_METADATA).await; let metadata = tmdb(RELEASED_METADATA).await;
let (downloader, _fake) = transmission().await; let (downloader, _fake) = qbit().await;
let action = action_with_tmdb(&indexer, &downloader, &metadata); let action = action_with_tmdb(&indexer, &downloader, &metadata);
action.tick(&database).await.unwrap(); action.tick(&database).await.unwrap();
@@ -2513,7 +2393,7 @@ mod tests {
let (_dir, database) = wanted_movie().await; let (_dir, database) = wanted_movie().await;
let indexer = empty_prowlarr().await; let indexer = empty_prowlarr().await;
let metadata = tmdb(RELEASED_METADATA).await; let metadata = tmdb(RELEASED_METADATA).await;
let (downloader, _fake) = transmission().await; let (downloader, _fake) = qbit().await;
action_with_tmdb(&indexer, &downloader, &metadata) action_with_tmdb(&indexer, &downloader, &metadata)
.tick(&database) .tick(&database)
@@ -2568,7 +2448,7 @@ mod tests {
"vote_average": 8.152,"#, "vote_average": 8.152,"#,
); );
let metadata = tmdb(&artwork_metadata).await; let metadata = tmdb(&artwork_metadata).await;
let (downloader, _fake) = transmission().await; let (downloader, _fake) = qbit().await;
action_with_tmdb(&indexer, &downloader, &metadata) action_with_tmdb(&indexer, &downloader, &metadata)
.tick(&database) .tick(&database)
@@ -2629,7 +2509,7 @@ mod tests {
"vote_average": 8.152,"#, "vote_average": 8.152,"#,
) )
}; };
let (downloader, _fake) = transmission().await; let (downloader, _fake) = qbit().await;
action_with_tmdb(&indexer, &downloader, &tmdb(&unrated()).await) action_with_tmdb(&indexer, &downloader, &tmdb(&unrated()).await)
.tick(&database) .tick(&database)
@@ -2690,7 +2570,7 @@ mod tests {
.unwrap(); .unwrap();
let indexer = empty_prowlarr().await; let indexer = empty_prowlarr().await;
let metadata = tmdb(&RELEASED_METADATA.replace("Dune Part Two", "Dune: Part Two")).await; let metadata = tmdb(&RELEASED_METADATA.replace("Dune Part Two", "Dune: Part Two")).await;
let (downloader, _fake) = transmission().await; let (downloader, _fake) = qbit().await;
action_with_tmdb(&indexer, &downloader, &metadata) action_with_tmdb(&indexer, &downloader, &metadata)
.tick(&database) .tick(&database)
@@ -2713,7 +2593,7 @@ mod tests {
async fn indexer_discovery_is_cached_across_ticks() { async fn indexer_discovery_is_cached_across_ticks() {
let (_dir, database) = wanted_movie().await; let (_dir, database) = wanted_movie().await;
let indexer = prowlarr().await; let indexer = prowlarr().await;
let (downloader, _fake) = transmission().await; let (downloader, _fake) = qbit().await;
let action = action(&indexer, &downloader); let action = action(&indexer, &downloader);
action.tick(&database).await.unwrap(); action.tick(&database).await.unwrap();
@@ -2744,7 +2624,7 @@ mod tests {
) )
.mount(&indexer) .mount(&indexer)
.await; .await;
let (downloader, fake) = transmission().await; let (downloader, fake) = qbit().await;
let mut action = action(&indexer, &downloader); let mut action = action(&indexer, &downloader);
action.indexers.discovery_timeout = Duration::from_millis(50); action.indexers.discovery_timeout = Duration::from_millis(50);
@@ -2761,7 +2641,7 @@ mod tests {
assert!(fake.torrents().is_empty()); assert!(fake.torrents().is_empty());
} }
/// §100: Transmission has no route to the indexer, so a download link /// §100: qBittorrent has no route to the indexer, so a download link
/// that answers with the torrent itself is forwarded as inline metainfo /// that answers with the torrent itself is forwarded as inline metainfo
/// and the link never leaves arr. /// and the link never leaves arr.
#[tokio::test] #[tokio::test]
@@ -2779,13 +2659,14 @@ mod tests {
) )
.mount(&indexer) .mount(&indexer)
.await; .await;
let (downloader, fake) = transmission().await; let (downloader, fake) = qbit().await;
action(&indexer, &downloader).tick(&database).await.unwrap(); action(&indexer, &downloader).tick(&database).await.unwrap();
let sent = base64::engine::general_purpose::STANDARD let sent = fake.torrents()[0]
.decode(&fake.torrents()[0].source) .metainfo
.expect("the torrent is sent as base64 metainfo"); .clone()
.expect("the torrent file is sent inline");
assert_eq!(sent, TORRENT); assert_eq!(sent, TORRENT);
let fetched_with_key = indexer let fetched_with_key = indexer
.received_requests() .received_requests()
@@ -2798,12 +2679,12 @@ mod tests {
} }
/// A link that redirects to a magnet — Prowlarr's usual answer — reaches /// A link that redirects to a magnet — Prowlarr's usual answer — reaches
/// Transmission as the magnet, which it can act on without the indexer. /// qBittorrent as the magnet, which it can act on without the indexer.
#[tokio::test] #[tokio::test]
async fn a_link_that_redirects_to_a_magnet_is_sent_as_the_magnet() { async fn a_link_that_redirects_to_a_magnet_is_sent_as_the_magnet() {
let (_dir, database) = wanted_movie().await; let (_dir, database) = wanted_movie().await;
let indexer = prowlarr().await; let indexer = prowlarr().await;
let (downloader, fake) = transmission().await; let (downloader, fake) = qbit().await;
action(&indexer, &downloader).tick(&database).await.unwrap(); action(&indexer, &downloader).tick(&database).await.unwrap();
+74 -77
View File
@@ -5,7 +5,7 @@
//! The torrent's own files are never moved, renamed or deleted — the torrent //! The torrent's own files are never moved, renamed or deleted — the torrent
//! and the library entry are separate lifecycles (§7.3). A hard-failed //! and the library entry are separate lifecycles (§7.3). A hard-failed
//! release is blacklisted and its grab marked failed, but the torrent keeps //! release is blacklisted and its grab marked failed, but the torrent keeps
//! seeding until Transmission's own limits clear it. //! seeding until qBittorrent's own limits clear it.
//! //!
//! Everything here is idempotent from domain rows (§8): a grab in //! Everything here is idempotent from domain rows (§8): a grab in
//! `downloaded` with no import recorded is the gap, and re-running any prefix //! `downloaded` with no import recorded is the gap, and re-running any prefix
@@ -22,7 +22,7 @@ use arr_core::layout;
use arr_core::policy::{evaluate, Candidate}; use arr_core::policy::{evaluate, Candidate};
use arr_core::{ProbedMedia, Rule, Source, Verdict}; use arr_core::{ProbedMedia, Rule, Source, Verdict};
use arr_db::Db; use arr_db::Db;
use arr_dl::TransmissionClient; use arr_dl::QbitClient;
use arr_probe::Prober; use arr_probe::Prober;
use crate::notify::Notifier; use crate::notify::Notifier;
@@ -36,8 +36,8 @@ pub enum ImportError {
Database(#[from] sqlx::Error), Database(#[from] sqlx::Error),
#[error("policy: {0}")] #[error("policy: {0}")]
Policy(#[from] arr_db::PolicyError), Policy(#[from] arr_db::PolicyError),
#[error("transmission: {0}")] #[error("qbit: {0}")]
Transmission(#[from] arr_dl::Error), Qbit(#[from] arr_dl::Error),
#[error("probe: {0}")] #[error("probe: {0}")]
Probe(#[from] arr_probe::Error), Probe(#[from] arr_probe::Error),
#[error("blocking task: {0}")] #[error("blocking task: {0}")]
@@ -73,7 +73,7 @@ enum ProbeOutcome {
/// the §7.4 layout. /// the §7.4 layout.
#[derive(Debug)] #[derive(Debug)]
pub struct ImportAction { pub struct ImportAction {
transmission: TransmissionClient, qbit: QbitClient,
prober: Prober, prober: Prober,
jellyfin: JellyfinClient, jellyfin: JellyfinClient,
notifier: Notifier, notifier: Notifier,
@@ -96,7 +96,7 @@ pub struct ImportAction {
probed: std::sync::Arc<tokio::sync::Mutex<HashMap<PathBuf, ProbeOutcome>>>, probed: std::sync::Arc<tokio::sync::Mutex<HashMap<PathBuf, ProbeOutcome>>>,
} }
/// A movie grab Transmission finished downloading, not yet imported. /// A movie grab qBittorrent finished downloading, not yet imported.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct PendingImport { struct PendingImport {
grab_id: i64, grab_id: i64,
@@ -112,14 +112,14 @@ struct PendingImport {
impl ImportAction { impl ImportAction {
#[must_use] #[must_use]
pub fn new( pub fn new(
transmission: TransmissionClient, qbit: QbitClient,
prober: Prober, prober: Prober,
jellyfin: JellyfinClient, jellyfin: JellyfinClient,
notifier: Notifier, notifier: Notifier,
operator_topic: Option<String>, operator_topic: Option<String>,
) -> Self { ) -> Self {
Self { Self {
transmission, qbit,
prober, prober,
jellyfin, jellyfin,
notifier, notifier,
@@ -388,7 +388,7 @@ impl ImportAction {
} }
} }
/// The torrent's files as safe local paths, or `None` when Transmission /// The torrent's files as safe local paths, or `None` when qBittorrent
/// no longer has the torrent. /// no longer has the torrent.
/// ///
/// Torrent-declared names are untrusted input: an absolute or /// Torrent-declared names are untrusted input: an absolute or
@@ -399,13 +399,13 @@ impl ImportAction {
grab_id: i64, grab_id: i64,
infohash: &str, infohash: &str,
) -> Result<Option<Vec<PathBuf>>, ImportError> { ) -> Result<Option<Vec<PathBuf>>, ImportError> {
let Some(content) = self.transmission.torrent_content(infohash).await? else { let Some(content) = self.qbit.torrent_content(infohash).await? else {
// Gone from Transmission — the caller marks the grab vanished // Gone from qBittorrent — the caller marks the grab vanished
// and parks the target (#108). // and parks the target (#108).
tracing::warn!( tracing::warn!(
grab_id, grab_id,
infohash, infohash,
"downloaded grab has no torrent in Transmission; not importing" "downloaded grab has no torrent in qBittorrent; not importing"
); );
return Ok(None); return Ok(None);
}; };
@@ -671,7 +671,7 @@ impl ImportAction {
)) ))
} }
/// §86/#108: a `downloaded` grab whose torrent Transmission no longer /// §86/#108: a `downloaded` grab whose torrent qBittorrent no longer
/// reports — removed by hand, not a policy failure. Marked `vanished` /// reports — removed by hand, not a policy failure. Marked `vanished`
/// rather than `failed` so it does not feed the `needs_decision` queue /// rather than `failed` so it does not feed the `needs_decision` queue
/// (attention.rs). Nothing is blacklisted, since the release itself /// (attention.rs). Nothing is blacklisted, since the release itself
@@ -692,11 +692,11 @@ impl ImportAction {
movie_id = pending.movie_id, movie_id = pending.movie_id,
title = pending.title, title = pending.title,
release = pending.release_name, release = pending.release_name,
"torrent vanished from Transmission; movie parked" "torrent vanished from qBittorrent; movie parked"
); );
Ok(Outcome::new( Ok(Outcome::new(
format!( format!(
"grab {} downloaded, torrent vanished from Transmission", "grab {} downloaded, torrent vanished from qBittorrent",
pending.grab_id pending.grab_id
), ),
format!("parked movie {}", pending.movie_id), format!("parked movie {}", pending.movie_id),
@@ -726,11 +726,11 @@ impl ImportAction {
grab_id = pending.grab_id, grab_id = pending.grab_id,
series = pending.series_title, series = pending.series_title,
release = pending.release_name, release = pending.release_name,
"torrent vanished from Transmission; target parked" "torrent vanished from qBittorrent; target parked"
); );
Ok(Outcome::new( Ok(Outcome::new(
format!( format!(
"grab {} downloaded, torrent vanished from Transmission", "grab {} downloaded, torrent vanished from qBittorrent",
pending.grab_id pending.grab_id
), ),
format!("parked {target_kind} {target_id}"), format!("parked {target_kind} {target_id}"),
@@ -897,7 +897,7 @@ async fn record_import(
Ok(()) Ok(())
} }
/// The gap, straight out of the domain rows (§8): a movie grab Transmission /// The gap, straight out of the domain rows (§8): a movie grab qBittorrent
/// finished that no import has settled. /// finished that no import has settled.
async fn pending_imports(database: &Db) -> Result<Vec<PendingImport>, ImportError> { async fn pending_imports(database: &Db) -> Result<Vec<PendingImport>, ImportError> {
let rows = sqlx::query!( let rows = sqlx::query!(
@@ -935,7 +935,7 @@ async fn pending_imports(database: &Db) -> Result<Vec<PendingImport>, ImportErro
.collect()) .collect())
} }
/// A TV grab Transmission finished downloading — one episode or a season /// A TV grab qBittorrent finished downloading — one episode or a season
/// pack — not yet imported. /// pack — not yet imported.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct PendingTvImport { struct PendingTvImport {
@@ -1397,18 +1397,39 @@ mod tests {
path path
} }
async fn harness(media_json: &str) -> Harness { /// A qBittorrent holding exactly one torrent, `INFOHASH`, with `files`
harness_with( /// under `save_path`.
media_json, async fn qbit(save_path: &str, files: &[(&str, u64)]) -> MockServer {
json!([ let server = MockServer::start().await;
{"name": "Dune/Dune.mkv", "length": 13, "bytesCompleted": 13}, let torrent = json!({
{"name": "Dune/Dune.nfo", "length": 10, "bytesCompleted": 10} "hash": INFOHASH, "name": RELEASE_NAME, "state": "uploading",
]), "progress": 1.0, "save_path": save_path, "tags": "movies-main",
) "ratio": 0.1, "ratio_limit": 1.5, "seeding_time": 1,
.await "seeding_time_limit": -1, "inactive_seeding_time_limit": -1,
"last_activity": 0
});
Mock::given(method("GET"))
.and(path("/api/v2/torrents/info"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([torrent])))
.mount(&server)
.await;
let files: Vec<serde_json::Value> = files
.iter()
.map(|(name, size)| json!({"name": name, "size": size}))
.collect();
Mock::given(method("GET"))
.and(path("/api/v2/torrents/files"))
.respond_with(ResponseTemplate::new(200).set_body_json(files))
.mount(&server)
.await;
server
} }
async fn harness_with(media_json: &str, files: serde_json::Value) -> Harness { async fn harness(media_json: &str) -> Harness {
harness_with(media_json, &[("Dune/Dune.mkv", 13), ("Dune/Dune.nfo", 10)]).await
}
async fn harness_with(media_json: &str, files: &[(&str, u64)]) -> Harness {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let downloads = dir.path().join("downloads"); let downloads = dir.path().join("downloads");
let library = dir.path().join("library"); let library = dir.path().join("library");
@@ -1453,18 +1474,7 @@ mod tests {
.unwrap(); .unwrap();
insert_owner(&database, "movie", 1, "Alice", "alice-topic").await; insert_owner(&database, "movie", 1, "Alice", "alice-topic").await;
let server = MockServer::start().await; let server = qbit(&downloads.to_string_lossy(), files).await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"result": "success",
"arguments": {"torrents": [{
"hashString": INFOHASH,
"downloadDir": downloads.to_string_lossy(),
"files": files
}]}
})))
.mount(&server)
.await;
let jellyfin_server = start_jellyfin_server().await; let jellyfin_server = start_jellyfin_server().await;
let ntfy_server = start_ntfy_server().await; let ntfy_server = start_ntfy_server().await;
@@ -1473,7 +1483,7 @@ mod tests {
let jellyfin = JellyfinClient::new(jellyfin_server.uri(), None).unwrap(); let jellyfin = JellyfinClient::new(jellyfin_server.uri(), None).unwrap();
let notifier = Notifier::new(ntfy_server.uri()).unwrap(); let notifier = Notifier::new(ntfy_server.uri()).unwrap();
let action = ImportAction::new( let action = ImportAction::new(
TransmissionClient::new(&server.uri()).unwrap(), QbitClient::new(&server.uri()).unwrap(),
prober, prober,
jellyfin, jellyfin,
notifier, notifier,
@@ -1727,7 +1737,7 @@ mod tests {
assert!(outcomes.is_empty()); assert!(outcomes.is_empty());
} }
/// #108, overriding §86: a `downloaded` grab whose torrent Transmission /// #108, overriding §86: a `downloaded` grab whose torrent qBittorrent
/// no longer reports — removed by hand, not a policy failure — is marked /// no longer reports — removed by hand, not a policy failure — is marked
/// `vanished` and parks the movie (`wanted` cleared) instead of /// `vanished` and parks the movie (`wanted` cleared) instead of
/// reopening it as a gap, without touching the blacklist. /// reopening it as a gap, without touching the blacklist.
@@ -1764,17 +1774,15 @@ mod tests {
.unwrap(); .unwrap();
let server = MockServer::start().await; let server = MockServer::start().await;
Mock::given(method("POST")) Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ .and(path("/api/v2/torrents/info"))
"result": "success", .respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
"arguments": {"torrents": []}
})))
.mount(&server) .mount(&server)
.await; .await;
let jellyfin_server = start_jellyfin_server().await; let jellyfin_server = start_jellyfin_server().await;
let ntfy_server = start_ntfy_server().await; let ntfy_server = start_ntfy_server().await;
let action = ImportAction::new( let action = ImportAction::new(
TransmissionClient::new(&server.uri()).unwrap(), QbitClient::new(&server.uri()).unwrap(),
Prober::new().with_binary(fake_ffprobe(dir.path(), HDR10_PROBE)), Prober::new().with_binary(fake_ffprobe(dir.path(), HDR10_PROBE)),
JellyfinClient::new(jellyfin_server.uri(), None).unwrap(), JellyfinClient::new(jellyfin_server.uri(), None).unwrap(),
Notifier::new(ntfy_server.uri()).unwrap(), Notifier::new(ntfy_server.uri()).unwrap(),
@@ -1809,11 +1817,11 @@ mod tests {
async fn hostile_torrent_paths_never_leave_the_download_root() { async fn hostile_torrent_paths_never_leave_the_download_root() {
let h = harness_with( let h = harness_with(
HDR10_PROBE, HDR10_PROBE,
json!([ &[
{"name": "../outside.mkv", "length": 13, "bytesCompleted": 13}, ("../outside.mkv", 13),
{"name": "/tmp/absolute.mkv", "length": 13, "bytesCompleted": 13}, ("/tmp/absolute.mkv", 13),
{"name": "Dune/Dune.mkv", "length": 13, "bytesCompleted": 13} ("Dune/Dune.mkv", 13),
]), ],
) )
.await; .await;
@@ -1832,11 +1840,7 @@ mod tests {
/// hard fail, not an escape. /// hard fail, not an escape.
#[tokio::test] #[tokio::test]
async fn a_torrent_of_only_hostile_paths_hard_fails() { async fn a_torrent_of_only_hostile_paths_hard_fails() {
let h = harness_with( let h = harness_with(HDR10_PROBE, &[("../../etc/passwd", 13)]).await;
HDR10_PROBE,
json!([{"name": "../../etc/passwd", "length": 13, "bytesCompleted": 13}]),
)
.await;
let outcomes = h.action.tick(&h.database).await.unwrap(); let outcomes = h.action.tick(&h.database).await.unwrap();
@@ -1885,7 +1889,7 @@ mod tests {
std::fs::set_permissions(&script, permissions).unwrap(); std::fs::set_permissions(&script, permissions).unwrap();
let action = ImportAction::new( let action = ImportAction::new(
TransmissionClient::new("http://127.0.0.1:1").unwrap(), QbitClient::new("http://127.0.0.1:1").unwrap(),
Prober::new().with_binary(&script), Prober::new().with_binary(&script),
JellyfinClient::new("http://127.0.0.1:1", None).unwrap(), JellyfinClient::new("http://127.0.0.1:1", None).unwrap(),
Notifier::new("http://127.0.0.1:1").unwrap(), Notifier::new("http://127.0.0.1:1").unwrap(),
@@ -1932,7 +1936,7 @@ mod tests {
permissions.set_mode(0o755); permissions.set_mode(0o755);
std::fs::set_permissions(&script, permissions).unwrap(); std::fs::set_permissions(&script, permissions).unwrap();
let action = ImportAction::new( let action = ImportAction::new(
TransmissionClient::new("http://127.0.0.1:1").unwrap(), QbitClient::new("http://127.0.0.1:1").unwrap(),
Prober::new().with_binary(&script), Prober::new().with_binary(&script),
JellyfinClient::new("http://127.0.0.1:1", None).unwrap(), JellyfinClient::new("http://127.0.0.1:1", None).unwrap(),
Notifier::new("http://127.0.0.1:1").unwrap(), Notifier::new("http://127.0.0.1:1").unwrap(),
@@ -2070,21 +2074,14 @@ mod tests {
.unwrap(); .unwrap();
insert_owner(&database, "series", 1, "Bob", "bob-topic").await; insert_owner(&database, "series", 1, "Bob", "bob-topic").await;
let server = MockServer::start().await; let server = qbit(
Mock::given(method("POST")) &downloads.to_string_lossy(),
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ &[
"result": "success", ("Fallout.S01/Fallout.S01E01.mkv", 2),
"arguments": {"torrents": [{ ("Fallout.S01/Fallout.S01E02.mkv", 2),
"hashString": INFOHASH, ],
"downloadDir": downloads.to_string_lossy(), )
"files": [ .await;
{"name": "Fallout.S01/Fallout.S01E01.mkv", "length": 2, "bytesCompleted": 2},
{"name": "Fallout.S01/Fallout.S01E02.mkv", "length": 2, "bytesCompleted": 2}
]
}]}
})))
.mount(&server)
.await;
let jellyfin_server = start_jellyfin_server().await; let jellyfin_server = start_jellyfin_server().await;
let ntfy_server = start_ntfy_server().await; let ntfy_server = start_ntfy_server().await;
@@ -2092,7 +2089,7 @@ mod tests {
let jellyfin = JellyfinClient::new(jellyfin_server.uri(), None).unwrap(); let jellyfin = JellyfinClient::new(jellyfin_server.uri(), None).unwrap();
let notifier = Notifier::new(ntfy_server.uri()).unwrap(); let notifier = Notifier::new(ntfy_server.uri()).unwrap();
let action = ImportAction::new( let action = ImportAction::new(
TransmissionClient::new(&server.uri()).unwrap(), QbitClient::new(&server.uri()).unwrap(),
prober, prober,
jellyfin, jellyfin,
notifier, notifier,
+35 -50
View File
@@ -9,6 +9,8 @@ mod indexers;
mod manual; mod manual;
mod metadata; mod metadata;
mod notify; mod notify;
#[cfg(test)]
mod qbit_fake;
mod reaper; mod reaper;
pub mod reconcile; pub mod reconcile;
mod rss; mod rss;
@@ -92,8 +94,8 @@ enum Error {
Tmdb(#[from] arr_meta::Error), Tmdb(#[from] arr_meta::Error),
#[error("prowlarr client: {0}")] #[error("prowlarr client: {0}")]
Prowlarr(#[from] arr_indexer::Error), Prowlarr(#[from] arr_indexer::Error),
#[error("transmission client: {0}")] #[error("qbit client: {0}")]
Transmission(#[from] arr_dl::Error), Qbit(#[from] arr_dl::Error),
#[error("jellyfin client: {0}")] #[error("jellyfin client: {0}")]
Jellyfin(#[from] arr_api::jellyfin::Error), Jellyfin(#[from] arr_api::jellyfin::Error),
#[error("ntfy client: {0}")] #[error("ntfy client: {0}")]
@@ -118,19 +120,18 @@ fn api_state(
database: &Db, database: &Db,
jellyfin: arr_api::jellyfin::JellyfinClient, jellyfin: arr_api::jellyfin::JellyfinClient,
translators: &Translators, translators: &Translators,
transmission: &arr_dl::TransmissionClient, qbit: &arr_dl::QbitClient,
) -> Result<AppState, Error> { ) -> Result<AppState, Error> {
let mut upstreams = let mut upstreams = Upstreams::new(config.prowlarr_url.clone(), config.qbittorrent_url.clone())
Upstreams::new(config.prowlarr_url.clone(), config.transmission_url.clone()) .with_prowlarr_api_key(config.prowlarr_api_key.clone())
.with_prowlarr_api_key(config.prowlarr_api_key.clone()) .with_tmdb_api_key(config.tmdb_api_key.clone());
.with_tmdb_api_key(config.tmdb_api_key.clone());
if let Some(tmdb_url) = config.tmdb_url.clone() { if let Some(tmdb_url) = config.tmdb_url.clone() {
upstreams = upstreams.with_tmdb_url(tmdb_url); upstreams = upstreams.with_tmdb_url(tmdb_url);
} }
let mut state = AppState::new(upstreams)? let mut state = AppState::new(upstreams)?
.with_database(database.clone()) .with_database(database.clone())
.with_transmission(transmission.clone()) .with_qbit(qbit.clone())
.with_subtitle_providers(subtitle_providers( .with_subtitle_providers(subtitle_providers(
config.opensubtitles_api_key.clone(), config.opensubtitles_api_key.clone(),
config.opensubtitles_username.clone(), config.opensubtitles_username.clone(),
@@ -158,7 +159,14 @@ async fn run() -> Result<(), Error> {
let database = Db::connect(&config.database_path).await?; let database = Db::connect(&config.database_path).await?;
database.migrate().await?; database.migrate().await?;
let transmission = arr_dl::TransmissionClient::new(&config.transmission_url)?; let qbit = match (&config.qbittorrent_username, &config.qbittorrent_password) {
(Some(username), Some(password)) => arr_dl::QbitClient::with_credentials(
&config.qbittorrent_url,
username.clone(),
password.clone(),
),
_ => arr_dl::QbitClient::new(&config.qbittorrent_url),
}?;
let tmdb = if let Some(key) = &config.tmdb_api_key { let tmdb = if let Some(key) = &config.tmdb_api_key {
let mut client = TmdbClient::builder(key.clone()); let mut client = TmdbClient::builder(key.clone());
if let Some(url) = &config.tmdb_url { if let Some(url) = &config.tmdb_url {
@@ -178,7 +186,7 @@ async fn run() -> Result<(), Error> {
let (reconcile, manual_grab, manual_tv) = reconcile_loop( let (reconcile, manual_grab, manual_tv) = reconcile_loop(
&database, &database,
&config, &config,
&transmission, &qbit,
tmdb.as_ref(), tmdb.as_ref(),
&notifier, &notifier,
&translators, &translators,
@@ -192,13 +200,7 @@ async fn run() -> Result<(), Error> {
compat = compat.with_tmdb(tmdb); compat = compat.with_tmdb(tmdb);
} }
let state = api_state( let state = api_state(&config, &database, api_jellyfin, &translators, &qbit)?;
&config,
&database,
api_jellyfin,
&translators,
&transmission,
)?;
let app = arr_api::router(state.clone()) let app = arr_api::router(state.clone())
.merge(arr_compat::router(compat)) .merge(arr_compat::router(compat))
.fallback(web::serve) .fallback(web::serve)
@@ -329,7 +331,7 @@ async fn seed_translator_settings(database: &Db, translators: &Translators) -> R
fn reconcile_loop( fn reconcile_loop(
database: &Db, database: &Db,
config: &Config, config: &Config,
transmission: &arr_dl::TransmissionClient, qbit: &arr_dl::QbitClient,
tmdb: Option<&Arc<TmdbClient>>, tmdb: Option<&Arc<TmdbClient>>,
notifier: &Notifier, notifier: &Notifier,
translators: &Translators, translators: &Translators,
@@ -360,27 +362,16 @@ fn reconcile_loop(
.map(|key| arr_indexer::ProwlarrClient::new(config.prowlarr_url.clone(), key)) .map(|key| arr_indexer::ProwlarrClient::new(config.prowlarr_url.clone(), key))
.transpose()?; .transpose()?;
let (reconcile, manual_grab) = register_movie_grab( let (reconcile, manual_grab) =
reconcile, register_movie_grab(reconcile, prowlarr.as_ref(), qbit, config, &seeding, tmdb);
prowlarr.as_ref(),
transmission,
config,
&seeding,
tmdb,
);
let (mut reconcile, manual_tv) = let (mut reconcile, manual_tv) =
register_tv_grab(reconcile, prowlarr.as_ref(), transmission, config, &seeding); register_tv_grab(reconcile, prowlarr.as_ref(), qbit, config, &seeding);
// RSS needs no TMDB: it matches what the feeds already carry against the // RSS needs no TMDB: it matches what the feeds already carry against the
// wanted list (§6.2). // wanted list (§6.2).
if let Some(prowlarr) = prowlarr { if let Some(prowlarr) = prowlarr {
reconcile = reconcile.register( reconcile = reconcile.register(
Tick::Rss, Tick::Rss,
RssAction::new( RssAction::new(prowlarr, qbit.clone(), config.download_dir.clone(), seeding),
prowlarr,
transmission.clone(),
config.download_dir.clone(),
seeding,
),
); );
} }
// §8: metadata refresh is its own daily lane, staggered against the // §8: metadata refresh is its own daily lane, staggered against the
@@ -396,7 +387,7 @@ fn reconcile_loop(
reconcile = reconcile.register( reconcile = reconcile.register(
Tick::Reconcile, Tick::Reconcile,
ImportAction::new( ImportAction::new(
transmission.clone(), qbit.clone(),
arr_probe::Prober::new(), arr_probe::Prober::new(),
jellyfin, jellyfin,
notifier.clone(), notifier.clone(),
@@ -430,11 +421,11 @@ fn reconcile_loop(
); );
} }
let reconcile = reconcile.register(Tick::Reaper, ReaperAction::new(transmission.clone())); let reconcile = reconcile.register(Tick::Reaper, ReaperAction::new(qbit.clone()));
Ok((reconcile, manual_grab, manual_tv)) Ok((reconcile, manual_grab, manual_tv))
} }
/// The *broken* lane (#200 included): Prowlarr, Transmission and TMDB, plus /// The *broken* lane (#200 included): Prowlarr, qBittorrent and TMDB, plus
/// the subtitle lamps — an enabled provider, the selected engine, or a /// the subtitle lamps — an enabled provider, the selected engine, or a
/// missing `alass`/`ffmpeg` all fold into the same operator message. /// missing `alass`/`ffmpeg` all fold into the same operator message.
fn register_broken( fn register_broken(
@@ -447,7 +438,7 @@ fn register_broken(
let broken_upstreams = broken::Upstreams { let broken_upstreams = broken::Upstreams {
prowlarr_url: config.prowlarr_url.clone(), prowlarr_url: config.prowlarr_url.clone(),
prowlarr_api_key: config.prowlarr_api_key.clone(), prowlarr_api_key: config.prowlarr_api_key.clone(),
transmission_url: config.transmission_url.clone(), qbittorrent_url: config.qbittorrent_url.clone(),
tmdb_url: config tmdb_url: config
.tmdb_url .tmdb_url
.clone() .clone()
@@ -483,7 +474,7 @@ fn register_broken(
fn register_tv_grab( fn register_tv_grab(
mut reconcile: ReconcileLoop, mut reconcile: ReconcileLoop,
prowlarr: Option<&arr_indexer::ProwlarrClient>, prowlarr: Option<&arr_indexer::ProwlarrClient>,
transmission: &arr_dl::TransmissionClient, qbit: &arr_dl::QbitClient,
config: &Config, config: &Config,
seeding: &SeedingRules, seeding: &SeedingRules,
) -> (ReconcileLoop, Option<TvGrabAction>) { ) -> (ReconcileLoop, Option<TvGrabAction>) {
@@ -493,7 +484,7 @@ fn register_tv_grab(
let tv_grab_action = || { let tv_grab_action = || {
TvGrabAction::new( TvGrabAction::new(
prowlarr.clone(), prowlarr.clone(),
transmission.clone(), qbit.clone(),
config.download_dir.clone(), config.download_dir.clone(),
seeding.clone(), seeding.clone(),
) )
@@ -511,7 +502,7 @@ fn register_tv_grab(
fn register_movie_grab( fn register_movie_grab(
mut reconcile: ReconcileLoop, mut reconcile: ReconcileLoop,
prowlarr: Option<&arr_indexer::ProwlarrClient>, prowlarr: Option<&arr_indexer::ProwlarrClient>,
transmission: &arr_dl::TransmissionClient, qbit: &arr_dl::QbitClient,
config: &Config, config: &Config,
seeding: &SeedingRules, seeding: &SeedingRules,
tmdb: Option<&Arc<TmdbClient>>, tmdb: Option<&Arc<TmdbClient>>,
@@ -522,29 +513,23 @@ fn register_movie_grab(
}; };
reconcile = reconcile.register( reconcile = reconcile.register(
Tick::Reconcile, Tick::Reconcile,
movie_grab_action(prowlarr, transmission, config, seeding, tmdb), movie_grab_action(prowlarr, qbit, config, seeding, tmdb),
); );
let manual_grab = Some(movie_grab_action( let manual_grab = Some(movie_grab_action(prowlarr, qbit, config, seeding, tmdb));
prowlarr,
transmission,
config,
seeding,
tmdb,
));
(reconcile, manual_grab) (reconcile, manual_grab)
} }
/// The movie grab lane, built fresh for each caller. /// The movie grab lane, built fresh for each caller.
fn movie_grab_action( fn movie_grab_action(
prowlarr: &arr_indexer::ProwlarrClient, prowlarr: &arr_indexer::ProwlarrClient,
transmission: &arr_dl::TransmissionClient, qbit: &arr_dl::QbitClient,
config: &Config, config: &Config,
seeding: &SeedingRules, seeding: &SeedingRules,
tmdb: &Arc<TmdbClient>, tmdb: &Arc<TmdbClient>,
) -> GrabAction { ) -> GrabAction {
GrabAction::new( GrabAction::new(
prowlarr.clone(), prowlarr.clone(),
transmission.clone(), qbit.clone(),
config.download_dir.clone(), config.download_dir.clone(),
seeding.clone(), seeding.clone(),
) )
+12 -20
View File
@@ -1,7 +1,7 @@
//! Drains `AppState`'s three command channels — the daemon-side consumer //! Drains `AppState`'s three command channels — the daemon-side consumer
//! DESIGN.md §6.2 and §9.3 assume exists (issues #107 and #132). A `Search` //! DESIGN.md §6.2 and §9.3 assume exists (issues #107 and #132). A `Search`
//! resets backoff and re-runs the targeted-search + grab lane immediately; //! resets backoff and re-runs the targeted-search + grab lane immediately;
//! `Grab` sends an already-chosen release straight to Transmission, skipping //! `Grab` sends an already-chosen release straight to qBittorrent, skipping
//! search. Movies, episodes and seasons share one lane because they share //! search. Movies, episodes and seasons share one lane because they share
//! one operator waiting on a 202. //! one operator waiting on a 202.
@@ -149,7 +149,7 @@ mod tests {
use std::path::PathBuf; use std::path::PathBuf;
use arr_db::Db; use arr_db::Db;
use arr_dl::TransmissionClient; use arr_dl::QbitClient;
use arr_indexer::ProwlarrClient; use arr_indexer::ProwlarrClient;
use wiremock::matchers::{method, path, path_regex, query_param}; use wiremock::matchers::{method, path, path_regex, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate}; use wiremock::{Mock, MockServer, ResponseTemplate};
@@ -172,23 +172,15 @@ mod tests {
(dir, database) (dir, database)
} }
async fn transmission() -> MockServer { async fn qbit() -> MockServer {
let server = MockServer::start().await; crate::qbit_fake::FakeQbit::start().await.0
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"result": "success",
"arguments": {"torrent-added": {"id": 1, "name": "x", "hashString": "aaaa"}}
})))
.mount(&server)
.await;
server
} }
fn grab_action(indexer: &MockServer, transmission: &MockServer) -> GrabAction { fn grab_action(indexer: &MockServer, qbit: &MockServer) -> GrabAction {
GrabAction::new( GrabAction::new(
ProwlarrClient::new(indexer.uri(), "key").unwrap(), ProwlarrClient::new(indexer.uri(), "key").unwrap(),
TransmissionClient::new(&transmission.uri()).unwrap(), QbitClient::new(&qbit.uri()).unwrap(),
PathBuf::from("/mnt/media/transmission/complete"), PathBuf::from("/mnt/media/qbittorrent/complete"),
SeedingRules::new( SeedingRules::new(
SeedingLimits { SeedingLimits {
ratio: 1.5, ratio: 1.5,
@@ -239,8 +231,8 @@ mod tests {
) )
.mount(&indexer) .mount(&indexer)
.await; .await;
let transmission = transmission().await; let qbit = qbit().await;
let grab = grab_action(&indexer, &transmission); let grab = grab_action(&indexer, &qbit);
handle_movie(&grab, &database, MovieCommand::Search { movie_id: 1 }) handle_movie(&grab, &database, MovieCommand::Search { movie_id: 1 })
.await .await
@@ -269,7 +261,7 @@ mod tests {
} }
/// Issue #107: a `Grab` command sends the already-chosen release straight /// Issue #107: a `Grab` command sends the already-chosen release straight
/// to Transmission — no indexer search at all. /// to qBittorrent — no indexer search at all.
#[tokio::test] #[tokio::test]
async fn a_grab_command_sends_the_chosen_release_without_searching() { async fn a_grab_command_sends_the_chosen_release_without_searching() {
let (_dir, database) = database_with_wanted_movie().await; let (_dir, database) = database_with_wanted_movie().await;
@@ -282,8 +274,8 @@ mod tests {
)) ))
.mount(&indexer) .mount(&indexer)
.await; .await;
let transmission = transmission().await; let qbit = qbit().await;
let grab = grab_action(&indexer, &transmission); let grab = grab_action(&indexer, &qbit);
let release_id = sqlx::query( let release_id = sqlx::query(
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, score, verdict) "INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, score, verdict)
+342
View File
@@ -0,0 +1,342 @@
//! An in-memory qBittorrent for the daemon's tests.
//!
//! Shared rather than per-module because every lane that grabs needs the same
//! two behaviours to be right, and both are easy to fake wrongly: `torrents/add`
//! answers `Ok.` with no hash, so the fake has to derive the same infohash the
//! client did, and adding the same source twice has to collapse to one torrent
//! or the restart and re-grab tests prove nothing.
#![allow(clippy::unwrap_used, dead_code)]
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use arr_dl::TorrentSource;
use serde_json::{json, Value};
use wiremock::matchers::any;
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
#[derive(Clone, Debug)]
pub(crate) struct FakeTorrent {
pub hash: String,
pub name: String,
/// The magnet URI, or `<inline .torrent>` for a torrent sent as bytes.
pub source: String,
/// The bytes of a torrent sent inline, so a test can prove arr forwarded
/// the file rather than the indexer link.
pub metainfo: Option<Vec<u8>>,
pub labels: Vec<String>,
pub save_path: String,
pub progress: f64,
pub state: String,
pub ratio_limit: f64,
pub idle_limit_minutes: i64,
/// `(path, size)`, relative to `save_path`.
pub files: Vec<(String, u64)>,
}
#[derive(Clone, Debug, Default)]
pub(crate) struct FakeQbit {
pub torrents: Arc<Mutex<Vec<FakeTorrent>>>,
/// Every source ever added, in order, kept across removals.
pub added: Arc<Mutex<Vec<String>>>,
}
impl FakeQbit {
/// Start a mock server backed by a fresh fake.
pub(crate) async fn start() -> (MockServer, Self) {
let server = MockServer::start().await;
let fake = Self::default();
Mock::given(any())
.respond_with(fake.clone())
.mount(&server)
.await;
(server, fake)
}
pub(crate) fn torrents(&self) -> Vec<FakeTorrent> {
self.torrents.lock().unwrap().clone()
}
pub(crate) fn complete_all(&self) {
for torrent in self.torrents.lock().unwrap().iter_mut() {
torrent.progress = 1.0;
torrent.state = "uploading".into();
}
}
/// Give every torrent the same file list and download directory, for the
/// import lane.
pub(crate) fn set_contents(&self, save_path: &str, files: &[(&str, u64)]) {
for torrent in self.torrents.lock().unwrap().iter_mut() {
torrent.save_path = save_path.to_owned();
torrent.files = files
.iter()
.map(|(path, size)| ((*path).to_owned(), *size))
.collect();
}
}
fn add(&self, request: &Request) -> ResponseTemplate {
let parts = multipart(request);
let (source, name, metainfo) = if let Some(urls) = parts.get("urls") {
let uri = String::from_utf8_lossy(urls).trim().to_owned();
let name = uri.split_once("dn=").map_or_else(
|| uri.clone(),
|(_, rest)| rest.split('&').next().unwrap_or_default().to_owned(),
);
(TorrentSource::Magnet(uri), name, None)
} else {
let bytes = parts.get("torrents").cloned().unwrap_or_default();
(
TorrentSource::Metainfo(bytes.clone()),
"<inline .torrent>".to_owned(),
Some(bytes),
)
};
let Ok(hash) = arr_dl::infohash(&source) else {
// The real client refuses these before the call, so reaching here
// means the fixture is malformed.
return ResponseTemplate::new(415).set_body_string("invalid torrent");
};
let display = match &source {
TorrentSource::Magnet(uri) => uri.clone(),
TorrentSource::Metainfo(_) => "<inline .torrent>".to_owned(),
};
self.added.lock().unwrap().push(display.clone());
let mut torrents = self.torrents.lock().unwrap();
if !torrents.iter().any(|torrent| torrent.hash == hash) {
torrents.push(FakeTorrent {
hash,
name,
source: display,
metainfo,
labels: text(&parts, "tags")
.split(',')
.filter(|tag| !tag.is_empty())
.map(ToOwned::to_owned)
.collect(),
save_path: text(&parts, "savepath"),
progress: 0.0,
state: "downloading".into(),
ratio_limit: -1.0,
idle_limit_minutes: -1,
files: Vec::new(),
});
}
ResponseTemplate::new(200).set_body_string("Ok.")
}
fn info(&self, request: &Request) -> ResponseTemplate {
let wanted = query(request, "hashes");
let torrents: Vec<Value> = self
.torrents()
.into_iter()
.filter(|torrent| {
wanted
.as_ref()
.is_none_or(|hash| torrent.hash.eq_ignore_ascii_case(hash))
})
.map(|torrent| {
json!({
"hash": torrent.hash,
"name": torrent.name,
"state": torrent.state,
"progress": torrent.progress,
"dlspeed": 0,
"save_path": torrent.save_path,
"tags": torrent.labels.join(","),
"ratio": 0.0,
"ratio_limit": torrent.ratio_limit,
"seeding_time": 0,
"seeding_time_limit": -1,
"inactive_seeding_time_limit": torrent.idle_limit_minutes,
"last_activity": 0
})
})
.collect();
ResponseTemplate::new(200).set_body_json(torrents)
}
fn files(&self, request: &Request) -> ResponseTemplate {
let hash = query(request, "hash").unwrap_or_default();
let files: Vec<Value> = self
.torrents()
.into_iter()
.find(|torrent| torrent.hash.eq_ignore_ascii_case(&hash))
.map(|torrent| {
torrent
.files
.into_iter()
.map(|(path, size)| json!({"name": path, "size": size}))
.collect()
})
.unwrap_or_default();
ResponseTemplate::new(200).set_body_json(files)
}
fn mutate(&self, path: &str, request: &Request) -> ResponseTemplate {
let form = urlencoded(request);
let hashes = form.get("hashes").cloned().unwrap_or_default();
for torrent in self.torrents.lock().unwrap().iter_mut() {
if !hashes
.split('|')
.any(|hash| hash.eq_ignore_ascii_case(&torrent.hash))
{
continue;
}
match path {
"addTags" => {
for tag in form.get("tags").cloned().unwrap_or_default().split(',') {
if !tag.is_empty() && !torrent.labels.iter().any(|held| held == tag) {
torrent.labels.push(tag.to_owned());
}
}
}
"setLocation" => {
torrent.save_path = form.get("location").cloned().unwrap_or_default();
}
"setShareLimits" => {
torrent.ratio_limit = form
.get("ratioLimit")
.and_then(|value| value.parse().ok())
.unwrap_or(-1.0);
torrent.idle_limit_minutes = form
.get("inactiveSeedingTimeLimit")
.and_then(|value| value.parse().ok())
.unwrap_or(-1);
}
_ => {}
}
}
ResponseTemplate::new(200).set_body_string("")
}
fn delete(&self, request: &Request) -> ResponseTemplate {
let form = urlencoded(request);
let hashes = form.get("hashes").cloned().unwrap_or_default();
self.torrents.lock().unwrap().retain(|torrent| {
!hashes
.split('|')
.any(|hash| hash.eq_ignore_ascii_case(&torrent.hash))
});
ResponseTemplate::new(200).set_body_string("")
}
}
impl Respond for FakeQbit {
fn respond(&self, request: &Request) -> ResponseTemplate {
let path = request.url.path().to_owned();
match path.rsplit('/').next().unwrap_or_default() {
"add" => self.add(request),
"info" => self.info(request),
"files" => self.files(request),
"delete" => self.delete(request),
endpoint @ ("addTags" | "setLocation" | "setShareLimits") => {
self.mutate(endpoint, request)
}
_ => ResponseTemplate::new(200).set_body_string("Ok."),
}
}
}
fn query(request: &Request, key: &str) -> Option<String> {
request
.url
.query_pairs()
.find(|(name, _)| name == key)
.map(|(_, value)| value.into_owned())
}
fn urlencoded(request: &Request) -> HashMap<String, String> {
String::from_utf8_lossy(&request.body)
.split('&')
.filter_map(|pair| pair.split_once('='))
.map(|(key, value)| (decode(key), decode(value)))
.collect()
}
fn decode(input: &str) -> String {
let bytes = input.replace('+', " ").into_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut at = 0;
while at < bytes.len() {
if bytes[at] == b'%' && at + 2 < bytes.len() {
if let Some(byte) = std::str::from_utf8(&bytes[at + 1..at + 3])
.ok()
.and_then(|pair| u8::from_str_radix(pair, 16).ok())
{
out.push(byte);
at += 3;
continue;
}
}
out.push(bytes[at]);
at += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
fn text(parts: &HashMap<String, Vec<u8>>, key: &str) -> String {
parts
.get(key)
.map(|bytes| String::from_utf8_lossy(bytes).into_owned())
.unwrap_or_default()
}
/// The named parts of a `multipart/form-data` body.
fn multipart(request: &Request) -> HashMap<String, Vec<u8>> {
let Some(boundary) = request
.headers
.get("content-type")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.split("boundary=").nth(1))
.map(|value| format!("--{}", value.trim_matches('"')))
else {
return HashMap::new();
};
let mut parts = HashMap::new();
for section in split(&request.body, boundary.as_bytes()).skip(1) {
let Some(header_end) = find(section, b"\r\n\r\n") else {
continue;
};
let headers = String::from_utf8_lossy(&section[..header_end]);
let Some(name) = headers
.split("name=\"")
.nth(1)
.and_then(|rest| rest.split('"').next())
else {
continue;
};
let body = &section[header_end + 4..];
let body = body.strip_suffix(b"\r\n").unwrap_or(body);
parts.insert(name.to_owned(), body.to_vec());
}
parts
}
fn split<'a>(haystack: &'a [u8], needle: &'a [u8]) -> impl Iterator<Item = &'a [u8]> {
let mut rest = haystack;
std::iter::from_fn(move || {
if rest.is_empty() {
return None;
}
if let Some(at) = find(rest, needle) {
let (section, tail) = rest.split_at(at);
rest = &tail[needle.len()..];
Some(section)
} else {
let section = rest;
rest = &[];
Some(section)
}
})
}
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack
.windows(needle.len())
.position(|window| window == needle)
}
+84 -55
View File
@@ -1,36 +1,36 @@
//! Removes arr torrents only after Transmission says their seeding obligation //! Removes arr torrents only after qBittorrent says their seeding obligation
//! is complete. The library/import state is deliberately not consulted (§7.3). //! is complete. The library/import state is deliberately not consulted (§7.3).
use std::collections::HashSet; use std::collections::HashSet;
use arr_db::Db; use arr_db::Db;
use arr_dl::TransmissionClient; use arr_dl::QbitClient;
use crate::grab::label_for_root; use crate::grab::label_for_root;
use crate::reconcile::{Action, ActionFuture, Outcome}; use crate::reconcile::{Action, ActionFuture, Outcome};
#[derive(Debug)] #[derive(Debug)]
pub struct ReaperAction { pub struct ReaperAction {
transmission: TransmissionClient, qbit: QbitClient,
} }
impl ReaperAction { impl ReaperAction {
#[must_use] #[must_use]
pub fn new(transmission: TransmissionClient) -> Self { pub fn new(qbit: QbitClient) -> Self {
Self { transmission } Self { qbit }
} }
async fn tick(&self, labels: &HashSet<String>) -> Result<Vec<Outcome>, arr_dl::Error> { async fn tick(&self, labels: &HashSet<String>) -> Result<Vec<Outcome>, arr_dl::Error> {
let torrents = self.transmission.list_torrents().await?; let torrents = self.qbit.list_torrents().await?;
let mut outcomes = Vec::new(); let mut outcomes = Vec::new();
for torrent in torrents { for torrent in torrents {
if !torrent.is_finished || !torrent.labels.iter().any(|label| labels.contains(label)) { if !torrent.is_finished || !torrent.labels.iter().any(|label| labels.contains(label)) {
continue; continue;
} }
self.transmission.remove_torrent(torrent.id, true).await?; self.qbit.remove_torrent(&torrent.hash, true).await?;
outcomes.push(Outcome::new( outcomes.push(Outcome::new(
format!("torrent {} finished seeding", torrent.hash), format!("torrent {} finished seeding", torrent.hash),
format!("removed torrent {} and its download data", torrent.id), format!("removed torrent {} and its download data", torrent.name),
)); ));
} }
Ok(outcomes) Ok(outcomes)
@@ -64,85 +64,114 @@ mod tests {
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use serde_json::{json, Value}; use serde_json::{json, Value};
use wiremock::matchers::any; use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
use super::*; use super::*;
/// Records what was asked to be deleted, so a test can assert the reaper
/// touched one torrent and not its neighbours.
#[derive(Clone)] #[derive(Clone)]
struct Transmission { struct Deletions(Arc<Mutex<Vec<String>>>);
torrents: Value,
removed: Arc<Mutex<Vec<Value>>>,
}
impl Respond for Transmission { impl Respond for Deletions {
fn respond(&self, request: &Request) -> ResponseTemplate { fn respond(&self, request: &Request) -> ResponseTemplate {
let body: Value = serde_json::from_slice(&request.body).unwrap(); let body = String::from_utf8_lossy(&request.body).into_owned();
let arguments = match body["method"].as_str().unwrap() { self.0.lock().unwrap().push(body);
"torrent-get" => json!({"torrents": self.torrents}), ResponseTemplate::new(200).set_body_string("")
"torrent-remove" => {
self.removed.lock().unwrap().push(body["arguments"].clone());
json!({})
}
method => panic!("unexpected method {method}"),
};
ResponseTemplate::new(200)
.insert_header("x-transmission-session-id", "session")
.set_body_json(json!({"result": "success", "arguments": arguments}))
} }
} }
/// A torrent qBittorrent stopped on the ratio limit arr set on it, which
/// is what "finished seeding" means to the reaper.
fn torrent(id: i64, label: &str, finished: bool) -> Value { fn torrent(id: i64, label: &str, finished: bool) -> Value {
json!({ json!({
"id": id, "name": format!("torrent-{id}"), "hashString": format!("hash-{id}"), "hash": format!("hash-{id}"), "name": format!("torrent-{id}"),
"status": if finished { 0 } else { 6 }, "percentDone": 1.0, "state": if finished { "stoppedUP" } else { "uploading" },
"downloadDir": "/downloads", "labels": [label], "isFinished": finished "progress": 1.0, "save_path": "/downloads", "tags": label,
"ratio": 2.0, "ratio_limit": 1.5,
"seeding_time": 10, "seeding_time_limit": -1,
"inactive_seeding_time_limit": -1, "last_activity": 0
}) })
} }
async fn qbit(server: &MockServer, torrents: Value) -> Arc<Mutex<Vec<String>>> {
let deleted = Arc::new(Mutex::new(Vec::new()));
Mock::given(method("GET"))
.and(path("/api/v2/torrents/info"))
.respond_with(ResponseTemplate::new(200).set_body_json(torrents))
.mount(server)
.await;
Mock::given(method("POST"))
.and(path("/api/v2/torrents/delete"))
.respond_with(Deletions(Arc::clone(&deleted)))
.mount(server)
.await;
deleted
}
#[tokio::test] #[tokio::test]
async fn removes_only_finished_arr_torrents_with_data() { async fn removes_only_finished_arr_torrents_with_data() {
let server = MockServer::start().await; let server = MockServer::start().await;
let removed = Arc::new(Mutex::new(Vec::new())); let deleted = qbit(
Mock::given(any()) &server,
.respond_with(Transmission { json!([
torrents: json!([ torrent(1, "movies-main", false),
torrent(1, "movies-main", false), torrent(2, "movies-main", true),
torrent(2, "movies-main", true), torrent(3, "radarr", true)
torrent(3, "radarr", true) ]),
]), )
removed: Arc::clone(&removed), .await;
}) let action = ReaperAction::new(QbitClient::new(&server.uri()).unwrap());
.mount(&server)
.await;
let action = ReaperAction::new(TransmissionClient::new(&server.uri()).unwrap());
let labels = HashSet::from(["movies-main".to_owned(), "movies-kids".to_owned()]); let labels = HashSet::from(["movies-main".to_owned(), "movies-kids".to_owned()]);
let outcomes = action.tick(&labels).await.unwrap(); let outcomes = action.tick(&labels).await.unwrap();
assert_eq!(outcomes.len(), 1); assert_eq!(outcomes.len(), 1);
assert_eq!( let deleted = deleted.lock().unwrap();
*removed.lock().unwrap(), assert_eq!(deleted.len(), 1);
[json!({"ids": [2], "delete-local-data": true})] assert!(
deleted[0].contains("hashes=hash-2"),
"asked: {}",
deleted[0]
);
assert!(
deleted[0].contains("deleteFiles=true"),
"asked: {}",
deleted[0]
); );
} }
#[tokio::test] #[tokio::test]
async fn finished_torrent_does_not_need_a_grab_or_import_row() { async fn finished_torrent_does_not_need_a_grab_or_import_row() {
let server = MockServer::start().await; let server = MockServer::start().await;
let removed = Arc::new(Mutex::new(Vec::new())); let deleted = qbit(&server, json!([torrent(9, "movies-kids", true)])).await;
Mock::given(any()) let action = ReaperAction::new(QbitClient::new(&server.uri()).unwrap());
.respond_with(Transmission {
torrents: json!([torrent(9, "movies-kids", true)]),
removed: Arc::clone(&removed),
})
.mount(&server)
.await;
let action = ReaperAction::new(TransmissionClient::new(&server.uri()).unwrap());
let labels = HashSet::from(["movies-kids".to_owned()]); let labels = HashSet::from(["movies-kids".to_owned()]);
action.tick(&labels).await.unwrap(); action.tick(&labels).await.unwrap();
assert_eq!(removed.lock().unwrap().len(), 1); assert_eq!(deleted.lock().unwrap().len(), 1);
}
#[tokio::test]
async fn a_torrent_stopped_by_hand_is_left_alone() {
let server = MockServer::start().await;
let deleted = qbit(
&server,
json!([{
"hash": "hash-4", "name": "paused by the operator", "state": "stoppedUP",
"progress": 1.0, "save_path": "/downloads", "tags": "movies-main",
"ratio": 0.2, "ratio_limit": 1.5,
"seeding_time": 10, "seeding_time_limit": -1,
"inactive_seeding_time_limit": -1, "last_activity": 0
}]),
)
.await;
let action = ReaperAction::new(QbitClient::new(&server.uri()).unwrap());
let labels = HashSet::from(["movies-main".to_owned()]);
assert!(action.tick(&labels).await.unwrap().is_empty());
assert!(deleted.lock().unwrap().is_empty());
} }
} }
+10 -10
View File
@@ -287,11 +287,11 @@ mod tests {
use super::*; use super::*;
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
struct FakeTransmission { struct FakeQbit {
torrents: Arc<Mutex<HashMap<i64, String>>>, torrents: Arc<Mutex<HashMap<i64, String>>>,
} }
impl FakeTransmission { impl FakeQbit {
async fn find_or_add(&self, movie_id: i64) -> (String, bool) { async fn find_or_add(&self, movie_id: i64) -> (String, bool) {
let mut torrents = self.torrents.lock().await; let mut torrents = self.torrents.lock().await;
if let Some(hash) = torrents.get(&movie_id) { if let Some(hash) = torrents.get(&movie_id) {
@@ -310,7 +310,7 @@ mod tests {
#[derive(Debug)] #[derive(Debug)]
struct FakeGrabAction { struct FakeGrabAction {
transmission: FakeTransmission, qbit: FakeQbit,
release_id: i64, release_id: i64,
fail_after_add: bool, fail_after_add: bool,
} }
@@ -356,11 +356,11 @@ mod tests {
return Ok(Vec::new()); return Ok(Vec::new());
}; };
let (infohash, added) = self.transmission.find_or_add(movie_id).await; let (infohash, added) = self.qbit.find_or_add(movie_id).await;
if added && self.fail_after_add { if added && self.fail_after_add {
return Err(Box::new(io::Error::new( return Err(Box::new(io::Error::new(
io::ErrorKind::Interrupted, io::ErrorKind::Interrupted,
"simulated process death after Transmission accepted the torrent", "simulated process death after qBittorrent accepted the torrent",
)) as ActionError); )) as ActionError);
} }
@@ -448,25 +448,25 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn restart_converges_without_duplicate_external_work() { async fn restart_converges_without_duplicate_external_work() {
let (_directory, database, release_id) = seeded_database().await; let (_directory, database, release_id) = seeded_database().await;
let transmission = FakeTransmission::default(); let qbit = FakeQbit::default();
let interrupted = ReconcileLoop::new(database.clone()).register( let interrupted = ReconcileLoop::new(database.clone()).register(
Tick::Reconcile, Tick::Reconcile,
FakeGrabAction { FakeGrabAction {
transmission: transmission.clone(), qbit: qbit.clone(),
release_id, release_id,
fail_after_add: true, fail_after_add: true,
}, },
); );
let first = interrupted.run_tick(Tick::Reconcile).await; let first = interrupted.run_tick(Tick::Reconcile).await;
assert_eq!(first.failures, 1); assert_eq!(first.failures, 1);
assert_eq!(transmission.len().await, 1); assert_eq!(qbit.len().await, 1);
drop(interrupted); drop(interrupted);
let restarted = ReconcileLoop::new(database.clone()).register( let restarted = ReconcileLoop::new(database.clone()).register(
Tick::Reconcile, Tick::Reconcile,
FakeGrabAction { FakeGrabAction {
transmission: transmission.clone(), qbit: qbit.clone(),
release_id, release_id,
fail_after_add: false, fail_after_add: false,
}, },
@@ -476,7 +476,7 @@ mod tests {
assert_eq!(recovered.actions_taken, 1); assert_eq!(recovered.actions_taken, 1);
assert_eq!(stable.actions_taken, 0); assert_eq!(stable.actions_taken, 0);
assert_eq!(transmission.len().await, 1); assert_eq!(qbit.len().await, 1);
let grabs: i64 = sqlx::query_scalar("SELECT count(*) FROM grabs") let grabs: i64 = sqlx::query_scalar("SELECT count(*) FROM grabs")
.fetch_one(database.pool()) .fetch_one(database.pool())
.await .await
+22 -62
View File
@@ -28,7 +28,7 @@ use arr_core::matching::{
}; };
use arr_core::{EpisodeId, Language, MovieId}; use arr_core::{EpisodeId, Language, MovieId};
use arr_db::{Blacklist, Db, MoviePolicy, TitlePolicy}; use arr_db::{Blacklist, Db, MoviePolicy, TitlePolicy};
use arr_dl::TransmissionClient; use arr_dl::QbitClient;
use arr_indexer::{ProwlarrClient, SearchRelease, SearchRequest}; use arr_indexer::{ProwlarrClient, SearchRelease, SearchRequest};
use crate::grab::{ use crate::grab::{
@@ -50,13 +50,13 @@ impl RssAction {
#[must_use] #[must_use]
pub fn new( pub fn new(
prowlarr: ProwlarrClient, prowlarr: ProwlarrClient,
transmission: TransmissionClient, qbit: QbitClient,
download_dir: PathBuf, download_dir: PathBuf,
seeding: SeedingRules, seeding: SeedingRules,
) -> Self { ) -> Self {
Self { Self {
indexers: IndexerDirectory::new(prowlarr.clone()), indexers: IndexerDirectory::new(prowlarr.clone()),
grabber: Grabber::new(prowlarr.clone(), transmission, download_dir, seeding), grabber: Grabber::new(prowlarr.clone(), qbit, download_dir, seeding),
prowlarr, prowlarr,
} }
} }
@@ -714,15 +714,15 @@ async fn load_grabbable(
#[cfg(test)] #[cfg(test)]
#[allow(clippy::unwrap_used)] #[allow(clippy::unwrap_used)]
mod tests { mod tests {
use std::sync::{Arc, Mutex};
use serde_json::{json, Value}; use serde_json::json;
use wiremock::matchers::{method, path, query_param, query_param_is_missing}; use wiremock::matchers::{method, path, query_param, query_param_is_missing};
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; use wiremock::{Mock, MockServer, ResponseTemplate};
use super::*; use super::*;
use crate::grab::{test_downloads, SeedingLimits}; use crate::grab::{test_downloads, SeedingLimits};
use arr_dl::TransmissionClient; use crate::qbit_fake::FakeQbit;
use arr_dl::QbitClient;
/// The recorded feed: two releases of one wanted title, one that only a /// The recorded feed: two releases of one wanted title, one that only a
/// TMDB id identifies, one near miss, one TV item and one film nobody /// TMDB id identifies, one near miss, one TV item and one film nobody
@@ -773,40 +773,6 @@ mod tests {
const INDEXERS: [i64; 2] = [7, 9]; const INDEXERS: [i64; 2] = [7, 9];
/// Enough of Transmission to add a torrent and list nothing back.
#[derive(Clone, Debug, Default)]
struct FakeTransmission {
added: Arc<Mutex<Vec<String>>>,
}
impl Respond for FakeTransmission {
fn respond(&self, request: &Request) -> ResponseTemplate {
let body: Value = serde_json::from_slice(&request.body).unwrap();
match body["method"].as_str().unwrap_or_default() {
"torrent-add" => {
let source = body["arguments"]["filename"]
.as_str()
.unwrap_or_default()
.to_owned();
let mut added = self.added.lock().unwrap();
added.push(source.clone());
let id = i64::try_from(added.len()).unwrap();
success(&json!({"torrent-added": {
"id": id, "name": source, "hashString": format!("{id:040x}")
}}))
}
"torrent-get" => success(&json!({"torrents": []})),
_ => success(&json!({})),
}
}
}
fn success(arguments: &Value) -> ResponseTemplate {
ResponseTemplate::new(200).set_body_json(json!({
"result": "success", "arguments": arguments
}))
}
/// Two indexers, both advertising a text search, both serving the same /// Two indexers, both advertising a text search, both serving the same
/// feed to an empty query. /// feed to an empty query.
async fn prowlarr(feed: &str) -> MockServer { async fn prowlarr(feed: &str) -> MockServer {
@@ -842,14 +808,8 @@ mod tests {
server server
} }
async fn transmission() -> (MockServer, FakeTransmission) { async fn qbit() -> (MockServer, FakeQbit) {
let server = MockServer::start().await; FakeQbit::start().await
let fake = FakeTransmission::default();
Mock::given(method("POST"))
.respond_with(fake.clone())
.mount(&server)
.await;
(server, fake)
} }
/// `(tmdb_id, title, year, blocked)`. /// `(tmdb_id, title, year, blocked)`.
@@ -874,11 +834,11 @@ mod tests {
(dir, database) (dir, database)
} }
fn action(prowlarr: &MockServer, transmission: &MockServer) -> RssAction { fn action(prowlarr: &MockServer, qbit: &MockServer) -> RssAction {
RssAction::new( RssAction::new(
ProwlarrClient::new(prowlarr.uri(), "key").unwrap(), ProwlarrClient::new(prowlarr.uri(), "key").unwrap(),
TransmissionClient::new(&transmission.uri()).unwrap(), QbitClient::new(&qbit.uri()).unwrap(),
PathBuf::from("/mnt/media/transmission/complete"), PathBuf::from("/mnt/media/qbittorrent/complete"),
SeedingRules::new( SeedingRules::new(
SeedingLimits { SeedingLimits {
ratio: 1.0, ratio: 1.0,
@@ -975,7 +935,7 @@ mod tests {
]) ])
.await; .await;
let indexer = prowlarr(FEED).await; let indexer = prowlarr(FEED).await;
let (downloader, fake) = transmission().await; let (downloader, fake) = qbit().await;
let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap(); let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap();
@@ -1019,7 +979,7 @@ mod tests {
async fn a_near_miss_is_not_grabbed() { async fn a_near_miss_is_not_grabbed() {
let (_dir, database) = wanted(&[(9_999_999, "Dune: Part Three", 2024, false)]).await; let (_dir, database) = wanted(&[(9_999_999, "Dune: Part Three", 2024, false)]).await;
let indexer = prowlarr(FEED).await; let indexer = prowlarr(FEED).await;
let (downloader, fake) = transmission().await; let (downloader, fake) = qbit().await;
let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap(); let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap();
@@ -1038,7 +998,7 @@ mod tests {
]) ])
.await; .await;
let indexer = prowlarr(FEED).await; let indexer = prowlarr(FEED).await;
let (downloader, _fake) = transmission().await; let (downloader, _fake) = qbit().await;
action(&indexer, &downloader).tick(&database).await.unwrap(); action(&indexer, &downloader).tick(&database).await.unwrap();
@@ -1055,7 +1015,7 @@ mod tests {
async fn a_blocked_title_still_matches_rss() { async fn a_blocked_title_still_matches_rss() {
let (_dir, database) = wanted(&[(693_134, "Dune: Part Two", 2024, true)]).await; let (_dir, database) = wanted(&[(693_134, "Dune: Part Two", 2024, true)]).await;
let indexer = prowlarr(FEED).await; let indexer = prowlarr(FEED).await;
let (downloader, _fake) = transmission().await; let (downloader, _fake) = qbit().await;
let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap(); let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap();
@@ -1073,7 +1033,7 @@ mod tests {
]) ])
.await; .await;
let indexer = prowlarr(FEED).await; let indexer = prowlarr(FEED).await;
let (downloader, _fake) = transmission().await; let (downloader, _fake) = qbit().await;
action(&indexer, &downloader).tick(&database).await.unwrap(); action(&indexer, &downloader).tick(&database).await.unwrap();
@@ -1103,7 +1063,7 @@ mod tests {
let (_dir, database) = wanted(&[]).await; let (_dir, database) = wanted(&[]).await;
let (_season_id, episodes) = wanted_series(&database, &["2024-04-11", "2099-01-01"]).await; let (_season_id, episodes) = wanted_series(&database, &["2024-04-11", "2099-01-01"]).await;
let indexer = prowlarr(TV_FEED).await; let indexer = prowlarr(TV_FEED).await;
let (downloader, fake) = transmission().await; let (downloader, fake) = qbit().await;
let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap(); let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap();
@@ -1149,7 +1109,7 @@ mod tests {
"Unrelated.S01E03.2160p.WEB-DL-GROUP</title>", "Unrelated.S01E03.2160p.WEB-DL-GROUP</title>",
); );
let indexer = prowlarr(&pack_only).await; let indexer = prowlarr(&pack_only).await;
let (downloader, fake) = transmission().await; let (downloader, fake) = qbit().await;
let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap(); let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap();
@@ -1197,7 +1157,7 @@ mod tests {
.await .await
.unwrap(); .unwrap();
let indexer = prowlarr(TV_FEED).await; let indexer = prowlarr(TV_FEED).await;
let (downloader, _fake) = transmission().await; let (downloader, _fake) = qbit().await;
action(&indexer, &downloader).tick(&database).await.unwrap(); action(&indexer, &downloader).tick(&database).await.unwrap();
@@ -1237,7 +1197,7 @@ mod tests {
.await .await
.unwrap(); .unwrap();
let indexer = prowlarr(TV_FEED).await; let indexer = prowlarr(TV_FEED).await;
let (downloader, _fake) = transmission().await; let (downloader, _fake) = qbit().await;
action(&indexer, &downloader).tick(&database).await.unwrap(); action(&indexer, &downloader).tick(&database).await.unwrap();
@@ -1269,7 +1229,7 @@ mod tests {
.await .await
.unwrap(); .unwrap();
let indexer = prowlarr(TV_FEED).await; let indexer = prowlarr(TV_FEED).await;
let (downloader, _fake) = transmission().await; let (downloader, _fake) = qbit().await;
let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap(); let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap();
+31 -110
View File
@@ -16,7 +16,7 @@ use std::path::PathBuf;
use arr_core::grabbing::{season_grab_mode, SeasonGrabFacts, SeasonGrabMode}; use arr_core::grabbing::{season_grab_mode, SeasonGrabFacts, SeasonGrabMode};
use arr_core::Language; use arr_core::Language;
use arr_db::{Blacklist, Db, TitlePolicy}; use arr_db::{Blacklist, Db, TitlePolicy};
use arr_dl::TransmissionClient; use arr_dl::QbitClient;
use arr_indexer::{ProwlarrClient, SearchRelease, TvSelector, TvTarget}; use arr_indexer::{ProwlarrClient, SearchRelease, TvSelector, TvTarget};
use arr_parse::EpisodeClaim; use arr_parse::EpisodeClaim;
@@ -47,13 +47,13 @@ impl TvGrabAction {
#[must_use] #[must_use]
pub fn new( pub fn new(
prowlarr: ProwlarrClient, prowlarr: ProwlarrClient,
transmission: TransmissionClient, qbit: QbitClient,
download_dir: PathBuf, download_dir: PathBuf,
seeding: SeedingRules, seeding: SeedingRules,
) -> Self { ) -> Self {
Self { Self {
indexers: IndexerDirectory::new(prowlarr.clone()), indexers: IndexerDirectory::new(prowlarr.clone()),
grabber: Grabber::new(prowlarr.clone(), transmission, download_dir, seeding), grabber: Grabber::new(prowlarr.clone(), qbit, download_dir, seeding),
prowlarr, prowlarr,
} }
} }
@@ -544,7 +544,7 @@ impl TvGrabAction {
/// The manual one-click episode grab (§9.3, issue #132): the release is /// The manual one-click episode grab (§9.3, issue #132): the release is
/// already chosen off the episode deck, so this skips search and scoring /// already chosen off the episode deck, so this skips search and scoring
/// and sends it straight to Transmission. A manual grab may take a /// and sends it straight to qBittorrent. A manual grab may take a
/// `waived` release (§9.3), never a `rejected` one. /// `waived` release (§9.3), never a `rejected` one.
pub(crate) async fn grab_episode_release_now( pub(crate) async fn grab_episode_release_now(
&self, &self,
@@ -645,7 +645,7 @@ impl TvGrabAction {
} }
/// The manual one-click season grab (§9.3, issue #125): a chosen pack /// The manual one-click season grab (§9.3, issue #125): a chosen pack
/// goes straight to Transmission against the season's still-open gaps. /// goes straight to qBittorrent against the season's still-open gaps.
pub(crate) async fn grab_season_release_now( pub(crate) async fn grab_season_release_now(
&self, &self,
database: &Db, database: &Db,
@@ -1223,89 +1223,16 @@ fn air_date_time(value: Option<&str>) -> Option<std::time::SystemTime> {
#[allow(clippy::unwrap_used)] #[allow(clippy::unwrap_used)]
mod tests { mod tests {
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use serde_json::{json, Value}; use serde_json::json;
use wiremock::matchers::{method, path, query_param}; use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; use wiremock::{Mock, MockServer, ResponseTemplate};
use crate::grab::{test_downloads, SeedingLimits}; use crate::grab::{test_downloads, SeedingLimits};
use crate::qbit_fake::FakeQbit;
use super::*; use super::*;
/// A Transmission that dedupes on the infohash, like the real one.
#[derive(Clone, Debug, Default)]
struct FakeTransmission {
torrents: Arc<Mutex<Vec<FakeTorrent>>>,
}
#[derive(Clone, Debug)]
struct FakeTorrent {
id: i64,
hash: String,
source: String,
progress: f64,
}
impl FakeTransmission {
fn torrents(&self) -> Vec<FakeTorrent> {
self.torrents.lock().unwrap().clone()
}
fn add(&self, arguments: &Value) -> ResponseTemplate {
let source = arguments["filename"]
.as_str()
.unwrap_or_default()
.to_owned();
let mut torrents = self.torrents.lock().unwrap();
if let Some(existing) = torrents.iter().find(|torrent| torrent.source == source) {
return success(&json!({"torrent-duplicate": {
"id": existing.id, "name": existing.source, "hashString": existing.hash
}}));
}
let id = i64::try_from(torrents.len()).unwrap() + 1;
let hash = format!("{:040x}", id * 7);
torrents.push(FakeTorrent {
id,
hash: hash.clone(),
source: source.clone(),
progress: 0.0,
});
success(&json!({"torrent-added": {"id": id, "name": source, "hashString": hash}}))
}
}
impl Respond for FakeTransmission {
fn respond(&self, request: &Request) -> ResponseTemplate {
let body: Value = serde_json::from_slice(&request.body).unwrap();
match body["method"].as_str().unwrap_or_default() {
"torrent-add" => self.add(&body["arguments"]),
"torrent-get" => {
let torrents: Vec<Value> = self
.torrents()
.into_iter()
.map(|torrent| {
json!({
"id": torrent.id, "name": torrent.source,
"hashString": torrent.hash, "status": 4,
"percentDone": torrent.progress,
"downloadDir": "/downloads", "labels": []
})
})
.collect();
success(&json!({"torrents": torrents}))
}
_ => success(&json!({})),
}
}
}
fn success(arguments: &Value) -> ResponseTemplate {
ResponseTemplate::new(200).set_body_json(json!({
"result": "success", "arguments": arguments
}))
}
/// A season pack and its three episodes, all eligible under the seeded /// A season pack and its three episodes, all eligible under the seeded
/// TV main policy (§5.5 bands: 2160p floor 3 GiB, per #95). /// TV main policy (§5.5 bands: 2160p floor 3 GiB, per #95).
const TV_RSS: &str = r#"<rss><channel> const TV_RSS: &str = r#"<rss><channel>
@@ -1364,14 +1291,8 @@ mod tests {
server server
} }
async fn transmission() -> (MockServer, FakeTransmission) { async fn qbit() -> (MockServer, FakeQbit) {
let server = MockServer::start().await; FakeQbit::start().await
let fake = FakeTransmission::default();
Mock::given(method("POST"))
.respond_with(fake.clone())
.mount(&server)
.await;
(server, fake)
} }
/// Fallout S01 with three wanted episodes at the given air dates. /// Fallout S01 with three wanted episodes at the given air dates.
@@ -1409,11 +1330,11 @@ mod tests {
(dir, database, season_id) (dir, database, season_id)
} }
fn action(prowlarr: &MockServer, transmission: &MockServer) -> TvGrabAction { fn action(prowlarr: &MockServer, qbit: &MockServer) -> TvGrabAction {
TvGrabAction::new( TvGrabAction::new(
ProwlarrClient::new(prowlarr.uri(), "key").unwrap(), ProwlarrClient::new(prowlarr.uri(), "key").unwrap(),
TransmissionClient::new(&transmission.uri()).unwrap(), QbitClient::new(&qbit.uri()).unwrap(),
PathBuf::from("/mnt/media/transmission/complete"), PathBuf::from("/mnt/media/qbittorrent/complete"),
SeedingRules::new( SeedingRules::new(
SeedingLimits { SeedingLimits {
ratio: 1.5, ratio: 1.5,
@@ -1448,7 +1369,7 @@ mod tests {
let (_dir, database, season_id) = let (_dir, database, season_id) =
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await; wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
let indexer = prowlarr().await; let indexer = prowlarr().await;
let (downloader, fake) = transmission().await; let (downloader, fake) = qbit().await;
let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap(); let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap();
@@ -1473,7 +1394,7 @@ mod tests {
let (_dir, database, _season_id) = let (_dir, database, _season_id) =
wanted_season(&["2024-04-11", "2024-04-18", "2999-01-01"]).await; wanted_season(&["2024-04-11", "2024-04-18", "2999-01-01"]).await;
let indexer = prowlarr().await; let indexer = prowlarr().await;
let (downloader, fake) = transmission().await; let (downloader, fake) = qbit().await;
action(&indexer, &downloader).tick(&database).await.unwrap(); action(&indexer, &downloader).tick(&database).await.unwrap();
@@ -1547,7 +1468,7 @@ mod tests {
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await; wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
failed_packs(&database, season_id, &["-10 minutes"]).await; failed_packs(&database, season_id, &["-10 minutes"]).await;
let indexer = prowlarr().await; let indexer = prowlarr().await;
let (downloader, fake) = transmission().await; let (downloader, fake) = qbit().await;
action(&indexer, &downloader).tick(&database).await.unwrap(); action(&indexer, &downloader).tick(&database).await.unwrap();
@@ -1579,7 +1500,7 @@ mod tests {
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await; wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
failed_packs(&database, season_id, &["-2 hours"]).await; failed_packs(&database, season_id, &["-2 hours"]).await;
let indexer = prowlarr().await; let indexer = prowlarr().await;
let (downloader, fake) = transmission().await; let (downloader, fake) = qbit().await;
action(&indexer, &downloader).tick(&database).await.unwrap(); action(&indexer, &downloader).tick(&database).await.unwrap();
@@ -1601,7 +1522,7 @@ mod tests {
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await; wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
stalled_failed_packs(&database, season_id, &[("-35 days", "-10 minutes")]).await; stalled_failed_packs(&database, season_id, &[("-35 days", "-10 minutes")]).await;
let indexer = prowlarr().await; let indexer = prowlarr().await;
let (downloader, fake) = transmission().await; let (downloader, fake) = qbit().await;
action(&indexer, &downloader).tick(&database).await.unwrap(); action(&indexer, &downloader).tick(&database).await.unwrap();
@@ -1631,7 +1552,7 @@ mod tests {
) )
.await; .await;
let indexer = prowlarr().await; let indexer = prowlarr().await;
let (downloader, fake) = transmission().await; let (downloader, fake) = qbit().await;
action(&indexer, &downloader).tick(&inside.1).await.unwrap(); action(&indexer, &downloader).tick(&inside.1).await.unwrap();
assert!( assert!(
fake.torrents() fake.torrents()
@@ -1647,7 +1568,7 @@ mod tests {
&["-30 days", "-25 days", "-20 days", "-12 days", "-8 days"], &["-30 days", "-25 days", "-20 days", "-12 days", "-8 days"],
) )
.await; .await;
let (downloader, fake) = transmission().await; let (downloader, fake) = qbit().await;
action(&indexer, &downloader) action(&indexer, &downloader)
.tick(&elapsed.1) .tick(&elapsed.1)
.await .await
@@ -1664,7 +1585,7 @@ mod tests {
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await; wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
failed_packs(&database, season_id, &["-10 minutes"]).await; failed_packs(&database, season_id, &["-10 minutes"]).await;
let indexer = prowlarr().await; let indexer = prowlarr().await;
let (downloader, fake) = transmission().await; let (downloader, fake) = qbit().await;
action(&indexer, &downloader) action(&indexer, &downloader)
.search_season_now(&database, season_id) .search_season_now(&database, season_id)
@@ -1701,7 +1622,7 @@ mod tests {
.await .await
.unwrap(); .unwrap();
let indexer = prowlarr().await; let indexer = prowlarr().await;
let (downloader, fake) = transmission().await; let (downloader, fake) = qbit().await;
action(&indexer, &downloader).tick(&database).await.unwrap(); action(&indexer, &downloader).tick(&database).await.unwrap();
@@ -1724,7 +1645,7 @@ mod tests {
let (_dir, database, season_id) = let (_dir, database, season_id) =
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await; wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
let indexer = prowlarr().await; let indexer = prowlarr().await;
let (downloader, fake) = transmission().await; let (downloader, fake) = qbit().await;
let stamp = |database: Db| async move { let stamp = |database: Db| async move {
sqlx::query_scalar::<_, Option<String>>( sqlx::query_scalar::<_, Option<String>>(
@@ -1788,7 +1709,7 @@ mod tests {
) )
.mount(&indexer) .mount(&indexer)
.await; .await;
let (downloader, fake) = transmission().await; let (downloader, fake) = qbit().await;
let action = action(&indexer, &downloader); let action = action(&indexer, &downloader);
action.tick(&database).await.unwrap(); action.tick(&database).await.unwrap();
@@ -1823,7 +1744,7 @@ mod tests {
/// be accepted onto the channel. `search_attempts` starts at 5 (a 7-day /// be accepted onto the channel. `search_attempts` starts at 5 (a 7-day
/// backoff, nowhere near elapsed) so a tick would skip this episode; /// backoff, nowhere near elapsed) so a tick would skip this episode;
/// ending at 1 rather than 6 proves the reset happened, and a torrent in /// ending at 1 rather than 6 proves the reset happened, and a torrent in
/// Transmission proves the grab did. /// qBittorrent proves the grab did.
#[tokio::test] #[tokio::test]
async fn a_manual_episode_search_searches_and_grabs_now() { async fn a_manual_episode_search_searches_and_grabs_now() {
let (_dir, database, _season_id) = let (_dir, database, _season_id) =
@@ -1841,7 +1762,7 @@ mod tests {
.await .await
.unwrap(); .unwrap();
let indexer = prowlarr().await; let indexer = prowlarr().await;
let (downloader, fake) = transmission().await; let (downloader, fake) = qbit().await;
action(&indexer, &downloader) action(&indexer, &downloader)
.search_episode_now(&database, episode_id) .search_episode_now(&database, episode_id)
@@ -1871,7 +1792,7 @@ mod tests {
} }
/// Issue #132: an `EpisodeCommand::Grab` sends the already-chosen release /// Issue #132: an `EpisodeCommand::Grab` sends the already-chosen release
/// straight to Transmission — no indexer search at all. /// straight to qBittorrent — no indexer search at all.
#[tokio::test] #[tokio::test]
async fn a_manual_episode_grab_sends_the_chosen_release_without_searching() { async fn a_manual_episode_grab_sends_the_chosen_release_without_searching() {
let (_dir, database, _season_id) = wanted_season(&["2024-04-11", "2024-04-18"]).await; let (_dir, database, _season_id) = wanted_season(&["2024-04-11", "2024-04-18"]).await;
@@ -1880,7 +1801,7 @@ mod tests {
.await .await
.unwrap(); .unwrap();
let indexer = prowlarr().await; let indexer = prowlarr().await;
let (downloader, fake) = transmission().await; let (downloader, fake) = qbit().await;
let release_id: i64 = sqlx::query_scalar( let release_id: i64 = sqlx::query_scalar(
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, score, verdict) "INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, score, verdict)
VALUES (7, 'chosen', 'Fallout.S01E01.2160p.WEB-DL.DDP5.1', 10737418240, VALUES (7, 'chosen', 'Fallout.S01E01.2160p.WEB-DL.DDP5.1', 10737418240,
@@ -1936,7 +1857,7 @@ mod tests {
.await .await
.unwrap(); .unwrap();
let indexer = prowlarr().await; let indexer = prowlarr().await;
let (downloader, fake) = transmission().await; let (downloader, fake) = qbit().await;
action(&indexer, &downloader) action(&indexer, &downloader)
.search_season_now(&database, season_id) .search_season_now(&database, season_id)
@@ -1973,13 +1894,13 @@ mod tests {
} }
/// Issue #132: a `SeasonCommand::Grab` sends the already-chosen pack /// Issue #132: a `SeasonCommand::Grab` sends the already-chosen pack
/// straight to Transmission — no indexer search at all. /// straight to qBittorrent — no indexer search at all.
#[tokio::test] #[tokio::test]
async fn a_manual_season_grab_sends_the_chosen_pack_without_searching() { async fn a_manual_season_grab_sends_the_chosen_pack_without_searching() {
let (_dir, database, season_id) = let (_dir, database, season_id) =
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await; wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
let indexer = prowlarr().await; let indexer = prowlarr().await;
let (downloader, fake) = transmission().await; let (downloader, fake) = qbit().await;
let release_id: i64 = sqlx::query_scalar( let release_id: i64 = sqlx::query_scalar(
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, score, verdict) "INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, score, verdict)
VALUES (7, 'chosen-pack', 'Fallout.S01.2160p.WEB-DL.DDP5.1.Atmos', 85899345920, VALUES (7, 'chosen-pack', 'Fallout.S01.2160p.WEB-DL.DDP5.1.Atmos', 85899345920,
+2 -2
View File
@@ -81,7 +81,7 @@ impl Blacklist {
} }
/// Whether this infohash has been blacklisted. Case-insensitive: /// Whether this infohash has been blacklisted. Case-insensitive:
/// Transmission and Torznab disagree on the hex casing. /// qBittorrent and Torznab disagree on the hex casing.
#[must_use] #[must_use]
pub fn blocks_infohash(&self, infohash: &str) -> bool { pub fn blocks_infohash(&self, infohash: &str) -> bool {
self.infohashes.contains_key(&infohash.to_ascii_lowercase()) self.infohashes.contains_key(&infohash.to_ascii_lowercase())
@@ -176,7 +176,7 @@ pub async fn add(
/// The infohash a magnet link declares, lowercased. /// The infohash a magnet link declares, lowercased.
/// ///
/// Only the 40-character hex form is recognised. Base32 `btih` values exist /// Only the 40-character hex form is recognised. Base32 `btih` values exist
/// in the wild but Transmission normalises them away, and guessing wrong here /// in the wild but qBittorrent normalises them away, and guessing wrong here
/// would blacklist an unrelated release. /// would blacklist an unrelated release.
#[must_use] #[must_use]
pub fn magnet_infohash(download_url: &str) -> Option<String> { pub fn magnet_infohash(download_url: &str) -> Option<String> {
+1 -1
View File
@@ -29,7 +29,7 @@ pub enum PolicyError {
/// The effective policy for one title, plus the root it is attached to. /// The effective policy for one title, plus the root it is attached to.
/// ///
/// The root's `kind` and `audience` are the Transmission label and the /// The root's `kind` and `audience` are the qBittorrent label and the
/// on-disk layout (§7.1, §7.4), and they only exist together with the policy, /// on-disk layout (§7.1, §7.4), and they only exist together with the policy,
/// so they are returned together. /// so they are returned together.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
+1 -1
View File
@@ -7,10 +7,10 @@ repository.workspace = true
publish = false publish = false
[dependencies] [dependencies]
base64 = { workspace = true }
reqwest = { workspace = true } reqwest = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
sha1 = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
url = { workspace = true } url = { workspace = true }
+266
View File
@@ -0,0 +1,266 @@
//! Deriving a torrent's v1 infohash without the client's help.
//!
//! qBittorrent's `torrents/add` answers `Ok.` and nothing else: no hash, no
//! name, and no signal that the torrent was already there. arr needs the hash
//! before it can do anything with the torrent — it is the identity in `grabs`
//! and §6.3's second blacklist key — so it computes the hash from what it is
//! about to send, and looks the torrent up by that.
use sha1::{Digest as _, Sha1};
/// A source arr cannot turn into an infohash.
#[derive(Debug, thiserror::Error)]
pub enum InfohashError {
#[error("magnet link carries no v1 infohash (xt=urn:btih:)")]
NoBtih,
#[error("magnet infohash is neither 40 hex nor 32 base32 characters: {0:?}")]
MalformedBtih(String),
#[error("torrent file is not valid bencode: {0}")]
Bencode(&'static str),
#[error("torrent file has no info dictionary")]
NoInfoDict,
}
/// The v1 infohash named by a magnet link, lowercase hex.
///
/// v2-only magnets (`urn:btmh:`) are rejected rather than guessed at: arr
/// keys everything on the v1 hash.
///
/// # Errors
///
/// Returns an error when the link carries no `urn:btih:` topic, or carries
/// one that is not 40 hex or 32 base32 characters.
pub fn from_magnet(uri: &str) -> Result<String, InfohashError> {
let query = uri.split_once('?').map_or(uri, |(_, query)| query);
let raw = query
.split('&')
.filter_map(|pair| pair.split_once('='))
.filter(|(key, _)| percent_decode(key).eq_ignore_ascii_case("xt"))
.map(|(_, value)| percent_decode(value))
.find_map(|value| {
let rest = value
.get(..9)?
.eq_ignore_ascii_case("urn:btih:")
.then(|| value[9..].to_owned())?;
Some(rest)
})
.ok_or(InfohashError::NoBtih)?;
if raw.len() == 40 && raw.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Ok(raw.to_ascii_lowercase());
}
if raw.len() == 32 {
if let Some(bytes) = base32_decode(&raw) {
return Ok(hex(&bytes));
}
}
Err(InfohashError::MalformedBtih(raw))
}
/// The v1 infohash of a `.torrent` file: SHA-1 over the raw bytes of its
/// `info` dictionary, exactly as they appear in the file.
///
/// The bytes are hashed as-is rather than re-encoded, because a torrent whose
/// bencode is technically non-canonical still has the infohash its tracker and
/// peers agreed on, and re-encoding would invent a different one.
///
/// # Errors
///
/// Returns an error when the file is not a bencoded dictionary, or has no
/// `info` key.
pub fn from_metainfo(bytes: &[u8]) -> Result<String, InfohashError> {
let mut at = 0;
if bytes.first() != Some(&b'd') {
return Err(InfohashError::Bencode("top level is not a dictionary"));
}
at += 1;
while bytes.get(at) != Some(&b'e') {
if at >= bytes.len() {
return Err(InfohashError::Bencode("dictionary is not terminated"));
}
let (key, after_key) = scan_string(bytes, at)?;
let after_value = scan(bytes, after_key)?;
if key == b"info" {
return Ok(hex(&Sha1::digest(&bytes[after_key..after_value])));
}
at = after_value;
}
Err(InfohashError::NoInfoDict)
}
/// The index just past the bencoded value starting at `at`.
fn scan(bytes: &[u8], at: usize) -> Result<usize, InfohashError> {
match bytes.get(at) {
None => Err(InfohashError::Bencode("value ends early")),
Some(b'i') => bytes[at..]
.iter()
.position(|byte| *byte == b'e')
.map(|offset| at + offset + 1)
.ok_or(InfohashError::Bencode("integer is not terminated")),
Some(b'l' | b'd') => {
let container = bytes[at];
let mut at = at + 1;
while bytes.get(at) != Some(&b'e') {
if at >= bytes.len() {
return Err(InfohashError::Bencode("container is not terminated"));
}
// A dictionary's keys are bencoded strings like any other
// value, so both containers scan the same way.
let _ = container;
at = scan(bytes, at)?;
}
Ok(at + 1)
}
Some(byte) if byte.is_ascii_digit() => Ok(scan_string(bytes, at)?.1),
Some(_) => Err(InfohashError::Bencode("unexpected type marker")),
}
}
/// The bytes of the bencoded string at `at`, and the index just past it.
fn scan_string(bytes: &[u8], at: usize) -> Result<(&[u8], usize), InfohashError> {
let colon = bytes[at..]
.iter()
.position(|byte| *byte == b':')
.map(|offset| at + offset)
.ok_or(InfohashError::Bencode("string has no length separator"))?;
let length: usize = std::str::from_utf8(&bytes[at..colon])
.ok()
.and_then(|digits| digits.parse().ok())
.ok_or(InfohashError::Bencode("string length is not a number"))?;
let start = colon + 1;
let end = start
.checked_add(length)
.filter(|end| *end <= bytes.len())
.ok_or(InfohashError::Bencode(
"string runs past the end of the file",
))?;
Ok((&bytes[start..end], end))
}
/// RFC 4648 base32, the 32-character form some trackers still hand out.
fn base32_decode(input: &str) -> Option<Vec<u8>> {
let mut accumulator: u16 = 0;
let mut bits = 0_u32;
let mut out = Vec::with_capacity(20);
for character in input.chars() {
let value = match character.to_ascii_uppercase() {
letter @ 'A'..='Z' => letter as u16 - 'A' as u16,
digit @ '2'..='7' => digit as u16 - '2' as u16 + 26,
_ => return None,
};
accumulator = (accumulator << 5) | value;
bits += 5;
if bits >= 8 {
bits -= 8;
#[allow(clippy::cast_possible_truncation)]
out.push((accumulator >> bits) as u8);
}
}
(out.len() == 20).then_some(out)
}
fn hex(bytes: &[u8]) -> String {
use std::fmt::Write as _;
bytes
.iter()
.fold(String::with_capacity(40), |mut out, byte| {
let _ = write!(out, "{byte:02x}");
out
})
}
/// Enough percent-decoding for a magnet query parameter. Non-UTF-8 escapes
/// are left alone rather than dropped: the caller only ever compares the
/// result against ASCII.
fn percent_decode(input: &str) -> String {
let bytes = input.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut at = 0;
while at < bytes.len() {
if bytes[at] == b'%' && at + 2 < bytes.len() {
if let Some(byte) = std::str::from_utf8(&bytes[at + 1..at + 3])
.ok()
.and_then(|pair| u8::from_str_radix(pair, 16).ok())
{
out.push(byte);
at += 3;
continue;
}
}
out.push(bytes[at]);
at += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
#[cfg(test)]
mod tests {
use super::{from_magnet, from_metainfo, InfohashError};
#[test]
fn reads_a_hex_magnet_and_lowercases_it() {
let hash =
from_magnet("magnet:?xt=urn:btih:C12FE1C06BBA254A9DC9F519B335AA7C1367A88A&dn=example")
.expect("hex magnet");
assert_eq!(hash, "c12fe1c06bba254a9dc9f519b335aa7c1367a88a");
}
#[test]
fn reads_a_base32_magnet_as_the_same_hash() {
// The base32 form of the hex hash above.
let hash = from_magnet("magnet:?xt=urn:btih:YEX6DQDLXISUVHOJ6UM3GNNKPQJWPKEK")
.expect("base32 magnet");
assert_eq!(hash, "c12fe1c06bba254a9dc9f519b335aa7c1367a88a");
}
#[test]
fn reads_a_percent_encoded_topic() {
let hash = from_magnet("magnet:?xt=urn%3Abtih%3Ac12fe1c06bba254a9dc9f519b335aa7c1367a88a")
.expect("encoded magnet");
assert_eq!(hash, "c12fe1c06bba254a9dc9f519b335aa7c1367a88a");
}
#[test]
fn a_v2_only_magnet_is_rejected_not_guessed() {
let error = from_magnet(
"magnet:?xt=urn:btmh:1220caf1e1c30e81cb361b8f26e5d34c7f7b0f1b4f0d2c3a4b5c6d7e8f9a0b1c2d3",
)
.expect_err("no v1 hash");
assert!(matches!(error, InfohashError::NoBtih));
}
#[test]
fn hashes_the_info_dictionary_of_a_torrent_file() {
// d8:announce5:x:aaa4:infod6:lengthi3e4:name1:ae4:zzzzi1ee
let file = b"d8:announce5:x:aaa4:infod6:lengthi3e4:name1:ae4:zzzzi1ee";
let hash = from_metainfo(file).expect("torrent file");
// SHA-1 of `d6:lengthi3e4:name1:ae`.
assert_eq!(hash, sha1_hex(b"d6:lengthi3e4:name1:ae"));
}
#[test]
fn skips_a_list_valued_key_before_info() {
let file = b"d13:announce-listll1:al1:beee4:infod6:lengthi3e4:name1:aee";
let hash = from_metainfo(file).expect("torrent file");
assert_eq!(hash, sha1_hex(b"d6:lengthi3e4:name1:ae"));
}
#[test]
fn a_truncated_file_is_an_error_not_a_panic() {
let error = from_metainfo(b"d4:infod6:length").expect_err("truncated");
assert!(matches!(error, InfohashError::Bencode(_)));
}
#[test]
fn a_file_without_an_info_key_is_rejected() {
let error = from_metainfo(b"d8:announce5:x:aaae").expect_err("no info");
assert!(matches!(error, InfohashError::NoInfoDict));
}
fn sha1_hex(bytes: &[u8]) -> String {
use sha1::{Digest as _, Sha1};
super::hex(&Sha1::digest(bytes))
}
}
+704 -347
View File
File diff suppressed because it is too large Load Diff
+10 -11
View File
@@ -1,6 +1,6 @@
//! arr-e2e — cross-process integration test harness. See DESIGN.md §12. //! arr-e2e — cross-process integration test harness. See DESIGN.md §12.
//! //!
//! Tests built on this harness talk to a real Transmission container and to //! Tests built on this harness talk to a real qBittorrent container and to
//! `wiremock` fakes serving recorded responses for Prowlarr and TMDB. Never a //! `wiremock` fakes serving recorded responses for Prowlarr and TMDB. Never a
//! live tracker: trackers rate-limit and it would leak credentials into CI. //! live tracker: trackers rate-limit and it would leak credentials into CI.
//! //!
@@ -55,13 +55,12 @@ pub const INDEXER_ID: i64 = 3;
/// The movie in [`fixtures::TMDB_MOVIE_DUNE`]. /// The movie in [`fixtures::TMDB_MOVIE_DUNE`].
pub const TMDB_MOVIE_ID: u32 = 693_134; pub const TMDB_MOVIE_ID: u32 = 693_134;
/// The Transmission RPC endpoint tests should use: `TRANSMISSION_RPC_URL` /// The qBittorrent `WebUI` base URL tests should use: `QBITTORRENT_URL` when
/// when set (CI points it at the service container), a local container's /// set (CI points it at the service container), a local container's default
/// default port otherwise. Never the production LXC. /// port otherwise. Never the production instance.
#[must_use] #[must_use]
pub fn transmission_url() -> String { pub fn qbittorrent_url() -> String {
std::env::var("TRANSMISSION_RPC_URL") std::env::var("QBITTORRENT_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".into())
.unwrap_or_else(|_| "http://127.0.0.1:9091/transmission/rpc".into())
} }
/// A `wiremock` Prowlarr: the REST indexer enumeration plus one enabled /// A `wiremock` Prowlarr: the REST indexer enumeration plus one enabled
@@ -197,8 +196,8 @@ impl Daemon {
/// ///
/// Panics when the binary cannot be built or spawned, or when the health /// Panics when the binary cannot be built or spawned, or when the health
/// endpoint does not answer within the boot timeout. /// endpoint does not answer within the boot timeout.
pub async fn spawn(prowlarr_url: &str, tmdb_url: &str, transmission_url: &str) -> Self { pub async fn spawn(prowlarr_url: &str, tmdb_url: &str, qbittorrent_url: &str) -> Self {
Self::spawn_with_env(prowlarr_url, tmdb_url, transmission_url, &[]).await Self::spawn_with_env(prowlarr_url, tmdb_url, qbittorrent_url, &[]).await
} }
/// Same as [`Self::spawn`], plus extra environment variables for the /// Same as [`Self::spawn`], plus extra environment variables for the
@@ -212,7 +211,7 @@ impl Daemon {
pub async fn spawn_with_env( pub async fn spawn_with_env(
prowlarr_url: &str, prowlarr_url: &str,
tmdb_url: &str, tmdb_url: &str,
transmission_url: &str, qbittorrent_url: &str,
extra_env: &[(&str, &str)], extra_env: &[(&str, &str)],
) -> Self { ) -> Self {
let dir = tempfile::tempdir().expect("create daemon tempdir"); let dir = tempfile::tempdir().expect("create daemon tempdir");
@@ -232,7 +231,7 @@ impl Daemon {
.env("ARR_PROWLARR_API_KEY", API_KEY) .env("ARR_PROWLARR_API_KEY", API_KEY)
.env("ARR_TMDB_URL", tmdb_url) .env("ARR_TMDB_URL", tmdb_url)
.env("ARR_TMDB_API_KEY", API_KEY) .env("ARR_TMDB_API_KEY", API_KEY)
.env("ARR_TRANSMISSION_URL", transmission_url) .env("ARR_QBITTORRENT_URL", qbittorrent_url)
.stdin(Stdio::null()); .stdin(Stdio::null());
for (key, value) in extra_env { for (key, value) in extra_env {
command.env(key, value); command.env(key, value);
+26 -21
View File
@@ -1,5 +1,5 @@
//! Cross-process end-to-end tests over the DESIGN.md §12 seams: the real //! Cross-process end-to-end tests over the DESIGN.md §12 seams: the real
//! `arr` binary, a real Transmission container, and `wiremock` fakes serving //! `arr` binary, a real qBittorrent container, and `wiremock` fakes serving
//! recorded Prowlarr and TMDB responses. Never a live tracker. //! recorded Prowlarr and TMDB responses. Never a live tracker.
//! //!
//! The full add-to-imported run lands here once the reconcile loop (#21), //! The full add-to-imported run lands here once the reconcile loop (#21),
@@ -12,28 +12,28 @@ use {arr_db as _, sqlx as _, tempfile as _, wiremock as _};
use std::path::PathBuf; use std::path::PathBuf;
use arr_dl::{AddTorrent, TorrentSource, TransmissionClient}; use arr_dl::{AddTorrent, QbitClient, TorrentSource};
use arr_e2e::{ use arr_e2e::{
transmission_url, Daemon, FakeProwlarr, FakeTmdb, API_KEY, INDEXER_ID, TMDB_MOVIE_ID, qbittorrent_url, Daemon, FakeProwlarr, FakeTmdb, API_KEY, INDEXER_ID, TMDB_MOVIE_ID,
}; };
use arr_indexer::{ProwlarrClient, SearchRequest}; use arr_indexer::{ProwlarrClient, SearchRequest};
use arr_meta::TmdbClient; use arr_meta::TmdbClient;
use chrono::NaiveDate; use chrono::NaiveDate;
/// The daemon boots as its own process against faked Prowlarr and TMDB plus a /// The daemon boots as its own process against faked Prowlarr and TMDB plus a
/// real Transmission, reports every upstream healthy, and serves the movie /// real qBittorrent, reports every upstream healthy, and serves the movie
/// API over the wire. /// API over the wire.
#[tokio::test] #[tokio::test]
async fn daemon_runs_against_fakes_and_a_real_transmission() { async fn daemon_runs_against_fakes_and_a_real_qbittorrent() {
let prowlarr = FakeProwlarr::start().await; let prowlarr = FakeProwlarr::start().await;
let tmdb = FakeTmdb::start().await; let tmdb = FakeTmdb::start().await;
let daemon = Daemon::spawn(&prowlarr.url(), &tmdb.url(), &transmission_url()).await; let daemon = Daemon::spawn(&prowlarr.url(), &tmdb.url(), &qbittorrent_url()).await;
let base = daemon.base_url(); let base = daemon.base_url();
let health = daemon.health().await; let health = daemon.health().await;
assert_eq!(health["status"], "ok", "health report: {health}"); assert_eq!(health["status"], "ok", "health report: {health}");
assert_eq!(health["prowlarr"]["status"], "ok"); assert_eq!(health["prowlarr"]["status"], "ok");
assert_eq!(health["transmission"]["status"], "ok"); assert_eq!(health["qbit"]["status"], "ok");
assert_eq!(health["tmdb"]["status"], "ok"); assert_eq!(health["tmdb"]["status"], "ok");
// Movie CRUD across the process boundary, against the migrated seed data. // Movie CRUD across the process boundary, against the migrated seed data.
@@ -134,11 +134,13 @@ async fn recorded_fixtures_satisfy_the_real_clients() {
assert!(movie.is_digitally_released(after_digital)); assert!(movie.is_digitally_released(after_digital));
} }
/// Torrent lifecycle against the real Transmission container: RPC semantics /// Torrent lifecycle against the real qBittorrent container: the `WebUI` API's
/// are the seam most likely to surprise (DESIGN.md §12). /// semantics are the seam most likely to surprise (DESIGN.md §12). In
/// particular `torrents/add` answers `Ok.` and nothing else, so the infohash
/// arr derived locally has to be the one qBittorrent went on to list.
#[tokio::test] #[tokio::test]
async fn transmission_add_list_and_remove() { async fn qbittorrent_add_list_and_remove() {
let client = TransmissionClient::new(&transmission_url()).expect("valid endpoint"); let client = QbitClient::new(&qbittorrent_url()).expect("valid endpoint");
let name = format!("arr-e2e-{}", uuid::Uuid::new_v4()); let name = format!("arr-e2e-{}", uuid::Uuid::new_v4());
let metainfo = torrent_with_name(&name); let metainfo = torrent_with_name(&name);
let download_dir = PathBuf::from("/tmp/arr-e2e"); let download_dir = PathBuf::from("/tmp/arr-e2e");
@@ -156,34 +158,37 @@ async fn transmission_add_list_and_remove() {
let torrents = client.list_torrents().await.expect("list torrents"); let torrents = client.list_torrents().await.expect("list torrents");
let listed = torrents let listed = torrents
.iter() .iter()
.find(|torrent| torrent.id == first.id) .find(|torrent| torrent.hash == first.hash)
.expect("added torrent is authoritative in list"); .expect("added torrent is authoritative in list");
assert_eq!(listed.name, name); assert_eq!(listed.name, name);
assert_eq!(listed.hash, first.hash);
assert_eq!(listed.download_dir, download_dir); assert_eq!(listed.download_dir, download_dir);
assert_eq!(listed.labels, ["movies-main"]); assert_eq!(listed.labels, ["movies-main"]);
assert!((0.0..=1.0).contains(&listed.progress)); assert!((0.0..=1.0).contains(&listed.progress));
client client
.remove_torrent(first.id, false) .remove_torrent(&first.hash, false)
.await .await
.expect("remove without data"); .expect("remove without data");
let second = client.add_torrent(request()).await.expect("add again"); let second = client.add_torrent(request()).await.expect("add again");
assert_eq!(
second.hash, first.hash,
"the same file is the same infohash"
);
client client
.remove_torrent(second.id, true) .remove_torrent(&second.hash, true)
.await .await
.expect("remove with data"); .expect("remove with data");
let torrents = client.list_torrents().await.expect("list after remove"); let torrents = client.list_torrents().await.expect("list after remove");
assert!(torrents.iter().all(|torrent| torrent.id != second.id)); assert!(torrents.iter().all(|torrent| torrent.hash != second.hash));
} }
/// Transmission, not arr's import state, owns the done-seeding boundary. The /// qBittorrent, not arr's import state, owns the done-seeding boundary. The
/// real service must report an unfinished torrent before its seed limit clears. /// real service must report an unfinished torrent before its seed limit clears.
#[tokio::test] #[tokio::test]
async fn transmission_reports_done_only_after_its_seed_limit() { async fn qbittorrent_reports_done_only_after_its_seed_limit() {
let client = TransmissionClient::new(&transmission_url()).expect("valid endpoint"); let client = QbitClient::new(&qbittorrent_url()).expect("valid endpoint");
let name = format!("arr-e2e-reaper-{}", uuid::Uuid::new_v4()); let name = format!("arr-e2e-reaper-{}", uuid::Uuid::new_v4());
let download_dir = PathBuf::from("/tmp/arr-e2e"); let download_dir = PathBuf::from("/tmp/arr-e2e");
let metainfo = torrent_with_name(&name); let metainfo = torrent_with_name(&name);
@@ -204,7 +209,7 @@ async fn transmission_reports_done_only_after_its_seed_limit() {
.await .await
.expect("list before limit") .expect("list before limit")
.into_iter() .into_iter()
.find(|torrent| torrent.id == added.id) .find(|torrent| torrent.hash == added.hash)
.expect("torrent before limit"); .expect("torrent before limit");
assert!( assert!(
!before.is_finished, !before.is_finished,
@@ -212,7 +217,7 @@ async fn transmission_reports_done_only_after_its_seed_limit() {
); );
client client
.remove_torrent(added.id, true) .remove_torrent(&added.hash, true)
.await .await
.expect("cleanup"); .expect("cleanup");
} }
+1 -1
View File
@@ -164,7 +164,7 @@ impl ProwlarrClient {
/// Resolves an indexer download link into something the download client /// Resolves an indexer download link into something the download client
/// can take without reaching the indexer itself. /// can take without reaching the indexer itself.
/// ///
/// Handing Transmission the Prowlarr link fails twice over: Transmission /// Handing qBittorrent the Prowlarr link fails twice over: qBittorrent
/// has no route to Prowlarr, and Prowlarr answers a `.torrent` link with a /// has no route to Prowlarr, and Prowlarr answers a `.torrent` link with a
/// redirect to a magnet, which a plain file fetch cannot follow. arr has /// redirect to a magnet, which a plain file fetch cannot follow. arr has
/// the route and the API key, so it resolves the link here and passes on /// the route and the API key, so it resolves the link here and passes on
+5 -5
View File
@@ -9,7 +9,7 @@
</head> </head>
<body> <body>
<!-- <!--
THESIS: arr is a signal chain — TMDB and Prowlarr feed in, Transmission is THESIS: arr is a signal chain — TMDB and Prowlarr feed in, qBittorrent is
driven out — and health is lamps on that chain, read in one glance. Refuses driven out — and health is lamps on that chain, read in one glance. Refuses
the admin-dashboard rut: no sidebar, no stat cards, no hero metric. the admin-dashboard rut: no sidebar, no stat cards, no hero metric.
OWN-WORLD: broadcast master-control panel. Near-black charcoal ground, OWN-WORLD: broadcast master-control panel. Near-black charcoal ground,
@@ -20,7 +20,7 @@
second whether arr can do its job — and which upstream is down, by name. second whether arr can do its job — and which upstream is down, by name.
FIRST VIEWPORT: a top rail (wordmark + master lamp + version), FIRST VIEWPORT: a top rail (wordmark + master lamp + version),
then the chain: TMDB and PROWLARR modules on the left feeding the central then the chain: TMDB and PROWLARR modules on the left feeding the central
arr module, TRANSMISSION driven on the right, joined by bus hairlines with arr module, QBITTORRENT driven on the right, joined by bus hairlines with
direction arrows. On a phone the chain stacks vertically. direction arrows. On a phone the chain stacks vertically.
FORM: broadcast master-control panel, #2 of 7 on the ordered list; external FORM: broadcast master-control panel, #2 of 7 on the ordered list; external
roll (shell RANDOM → 2, witness 57910; concept-seed.mjs degraded to empty roll (shell RANDOM → 2, witness 57910; concept-seed.mjs degraded to empty
@@ -453,14 +453,14 @@
</svg> </svg>
</div> </div>
<article class="module area-transmission" data-check="transmission"> <article class="module area-qbittorrent" data-check="qbit">
<header class="module-head"> <header class="module-head">
<span class="lamp" data-state="probing" aria-hidden="true"></span> <span class="lamp" data-state="probing" aria-hidden="true"></span>
<h2 class="module-name">transmission</h2> <h2 class="module-name">qbittorrent</h2>
<span class="module-status readout" data-role="status">probing</span> <span class="module-status readout" data-role="status">probing</span>
</header> </header>
<p class="module-role">torrents out</p> <p class="module-role">torrents out</p>
<p class="module-detail readout" data-role="detail">rpc · grab and seed</p> <p class="module-detail readout" data-role="detail">webui · grab and seed</p>
</article> </article>
</section> </section>
+1 -1
View File
@@ -17,7 +17,7 @@ export interface Download {
} }
/** §9.8: the live snapshot, degrading to nothing rather than breaking the /** §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. */ * signal chain already reports, not something this poll repeats. */
export async function fetchDownloads(): Promise<Download[]> { export async function fetchDownloads(): Promise<Download[]> {
try { try {
+1 -1
View File
@@ -28,7 +28,7 @@ export interface HealthReport {
status: "ok" | "degraded"; status: "ok" | "degraded";
version: string; version: string;
prowlarr: Check; prowlarr: Check;
transmission: Check; qbit: Check;
tmdb: Check; tmdb: Check;
subtitles: SubtitleHealth; subtitles: SubtitleHealth;
} }
+4 -4
View File
@@ -271,7 +271,7 @@ function main() {
const checks = { const checks = {
tmdb: moduleRefs("tmdb"), tmdb: moduleRefs("tmdb"),
prowlarr: moduleRefs("prowlarr"), prowlarr: moduleRefs("prowlarr"),
transmission: moduleRefs("transmission"), qbit: moduleRefs("qbit"),
} as const; } as const;
const traces = { const traces = {
@@ -293,7 +293,7 @@ function main() {
} }
// stagger order for the one power-up animation, upstream to downstream // 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) => { strikeOrder.forEach((refs, index) => {
refs.lamp.style.setProperty("--strike", String(index)); refs.lamp.style.setProperty("--strike", String(index));
}); });
@@ -331,7 +331,7 @@ function main() {
setModule(master, degraded ? "degraded" : "ok", degraded ? "warn" : "ok", report.status); setModule(master, degraded ? "degraded" : "ok", degraded ? "warn" : "ok", report.status);
setTrace("master", degraded ? "warn" : "ok"); 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]; const check = report[name];
setModule( setModule(
checks[name], checks[name],
@@ -340,7 +340,7 @@ function main() {
check.status, check.status,
check.detail, check.detail,
); );
if (name !== "transmission") { if (name !== "qbit") {
setTrace(name, TONE_BY_STATUS[check.status]); 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. * 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 * 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 * no grab had ever been tried and the operator found out by opening
* Transmission. * qBittorrent.
*/ */
export interface ImportFailure { export interface ImportFailure {
release: string; release: string;
+1 -1
View File
@@ -308,7 +308,7 @@ body {
grid-area: out; grid-area: out;
} }
.area-transmission { .area-qbittorrent {
grid-area: sink; grid-area: sink;
} }