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
parent afe167ca7b
commit ab4f95b166
42 changed files with 2008 additions and 1203 deletions
+26 -10
View File
@@ -26,15 +26,6 @@ jobs:
e2e:
runs-on: ubuntu-latest
services:
transmission:
image: linuxserver/transmission:latest
env:
PUID: "1000"
PGID: "1000"
ports:
- 9091:9091
steps:
- uses: actions/checkout@v4
@@ -81,6 +72,31 @@ jobs:
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
# The harness spawns this binary; building it here keeps the compile
# out of the first test's boot window. `translate-command` is the
@@ -89,5 +105,5 @@ jobs:
- name: e2e
env:
TRANSMISSION_RPC_URL: http://transmission:9091/transmission/rpc
QBITTORRENT_URL: http://127.0.0.1:8080
run: cargo nextest run -p arr-e2e
@@ -1,6 +1,6 @@
{
"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": {
"columns": [
{
@@ -70,5 +70,5 @@
false
]
},
"hash": "594d1391c0e9d8a0dded5a354e0dc438bfdbac096ef23b5c3b816afdc03684ae"
"hash": "0cfce0fe064cccb1dceb285a50fa77d990b116a95b987e604913cd8953da36ad"
}
+4 -3
View File
@@ -16,7 +16,7 @@ crates/
├── arr-parse/ release name parsing (no IO)
├── arr-meta/ TMDB client
├── arr-indexer/ Torznab via Prowlarr
├── arr-dl/ Transmission RPC
├── arr-dl/ qBittorrent WebUI API
├── arr-probe/ ffprobe wrapper
├── arr-subs/ subtitle providers, translation, sync
├── arr-db/ sqlx + migrations
@@ -244,8 +244,9 @@ mid-issue.
- **Prowlarr** — `prowlarr` container on the Dokploy host, port 9696. Owns
tracker auth, FlareSolverr and the Cardigann definitions. Not replaced.
- **Transmission** — native in LXC 130 at `10.6.10.45:9091`, RPC
unauthenticated. Download dir `/mnt/media/transmission/complete`.
- **qBittorrent** — `qbittorrent.n62.casa`, WebUI API v2, login required
(`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
`/mnt/media-v2`.
- **Media** — ZFS, single dataset, bind-mounted as `/mnt/media`. Downloads and
Generated
+15 -3
View File
@@ -93,7 +93,6 @@ dependencies = [
"arr-probe",
"arr-subs",
"axum",
"base64",
"chrono",
"include_dir",
"mime_guess",
@@ -129,10 +128,10 @@ dependencies = [
name = "arr-dl"
version = "0.1.0"
dependencies = [
"base64",
"reqwest",
"serde",
"serde_json",
"sha1 0.10.7",
"thiserror",
"tokio",
"url",
@@ -1543,6 +1542,7 @@ dependencies = [
"base64",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
@@ -1551,6 +1551,7 @@ dependencies = [
"hyper-util",
"js-sys",
"log",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"quinn",
@@ -1733,6 +1734,17 @@ dependencies = [
"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]]
name = "sha1"
version = "0.11.0"
@@ -1932,7 +1944,7 @@ dependencies = [
"log",
"percent-encoding",
"serde",
"sha1",
"sha1 0.11.0",
"sha2 0.11.0",
"sqlx-core",
"thiserror",
+2 -2
View File
@@ -26,8 +26,7 @@ arr-subs = { path = "crates/arr-subs" }
axum = "0.8"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "net", "io-util", "fs", "signal", "process"] }
tower-http = { version = "0.6", features = ["trace"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
base64 = "0.22"
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls"] }
# Persistence
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"] }
clap = { version = "4.5", features = ["derive", "env"] }
encoding_rs = "0.8"
sha1 = "0.10"
thiserror = "2"
tracing = "0.1"
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:
- **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
on disk. Adopting the pre-existing library, if ever wanted, is a one-off
script against both APIs, not a feature.
@@ -46,7 +46,7 @@ Explicitly out of scope, permanently unless stated:
```
┌──────────────┐
TMDB ──────▶│ │
│ arr │────▶ Transmission RPC 10.6.10.45:9091
│ arr │────▶ qBittorrent WebUI qbittorrent.n62.casa
Prowlarr ──────▶│ │
(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
definitions — replacing it buys nothing.
Transmission runs natively in its own LXC (VMID 130, `10.6.10.45`), RPC
unauthenticated, download dir `/mnt/media/transmission/complete`.
qBittorrent is reached at `qbittorrent.n62.casa`, WebUI API v2, download dir
`/mnt/media/qbittorrent/complete`. Its WebUI requires a login, so arr carries
credentials — the one upstream that does.
## 4. Domain model
@@ -517,11 +518,20 @@ leaving RSS matching on.
## 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`
and so on — enough to find things in Transmission's own UI, and distinct from
Radarr's existing `radarr`/`sonarr` labels so both stacks can run side by side.
WebUI API v2 at `qbittorrent.n62.casa`. Labels are qBittorrent **tags**
`movies-main`, `tv-kids` and so on — enough to find things in qBittorrent's own
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
@@ -540,9 +550,16 @@ at 4K is 40-80 GB per title.
The torrent and the library entry are separate state machines.
Seeding obligation is **per tracker**, configured locally because Prowlarr does
not expose tracker rules — `ratio` and `min_seed_time`. Set `seedRatioLimit`
and `seedIdleLimit` on the torrent at add time and let Transmission enforce
them. A reaper deletes torrents Transmission reports as done seeding.
not expose tracker rules — `ratio` and `min_seed_time`. Set `ratioLimit` and
`inactiveSeedingTimeLimit` on the torrent at add time, with
`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
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
the next tick recomputes the same gap and continues. A job table would need
retry counts, dead-lettering and reconciliation against Transmission anyway,
because Transmission is an external system that changes underneath the app.
retry counts, dead-lettering and reconciliation against qBittorrent anyway,
because qBittorrent is an external system that changes underneath the app.
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.
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.
- **Needs a decision** → to the operator alone. Entered the no-PT-source queue,
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.
Not notified: grabs, searches, downloads starting or finishing, soft fails.
@@ -742,7 +759,7 @@ inside the app, review text.
opens on.
**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
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**.
**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
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.
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
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,
media root, TMDB response cache directory) come from config/env.
Only bootstrap settings (bind address, Prowlarr URL, qBittorrent URL and
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.
@@ -806,7 +825,7 @@ arr-core domain types, policy engine, scoring no IO, no heavy deps
arr-parse release name parsing no IO
arr-meta TMDB client
arr-indexer Torznab via Prowlarr
arr-dl Transmission RPC
arr-dl qBittorrent WebUI API
arr-probe ffprobe wrapper
arr-subs subtitle providers, translation, sync
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.
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
likely source of surprise and it starts in a second.
- qBittorrent — a real container, started with a seeded config because the
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
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
+26 -8
View File
@@ -31,20 +31,38 @@ deps:
test:
cargo nextest run --workspace --exclude arr-e2e --all-features
# End-to-end tests. Needs a Transmission container (`just e2e-up`); not part
# of the push gate. Never point this at the production Transmission LXC.
# End-to-end tests. Needs a qBittorrent container (`just e2e-up`); not part
# of the push gate. Never point this at the production qBittorrent.
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:
docker run --rm -d --name arr-e2e-transmission \
-e PUID=1000 -e PGID=1000 -p 9091:9091 \
linuxserver/transmission:latest
#!/usr/bin/env bash
set -euo pipefail
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:
docker stop arr-e2e-transmission
docker stop arr-e2e-qbittorrent
# 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
+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.
- API-first: the SPA is one client of the OpenAPI-described HTTP API, no
privileged path.
- Coexists with Prowlarr (indexers), Transmission (downloads), Jellyfin
- Coexists with Prowlarr (indexers), qBittorrent (downloads), Jellyfin
(playback), Jellyseerr (requests), ntfy (notifications).
## 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
definitions, and replacing it buys nothing.
- **Transmission** stays.
- **qBittorrent** stays.
- **Jellyseerr** stays, talking to a thin Radarr-compatible shim.
## Status
+16 -30
View File
@@ -42,14 +42,12 @@ pub struct Download {
tag = "system",
responses(
(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> {
let database = state.database().ok_or(ApiError::Unavailable)?;
let transmission = state
.transmission()
.ok_or(ApiError::Upstream("transmission"))?;
let qbit = state.qbit().ok_or(ApiError::Upstream("qbit"))?;
let torrents = {
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 {
torrents.clone()
} else {
let torrents = transmission
let torrents = qbit
.list_torrents()
.await
.map_err(|_| ApiError::Upstream("transmission"))?;
.map_err(|_| ApiError::Upstream("qbit"))?;
*cache = Some((Instant::now(), torrents.clone()));
torrents
}
} else {
let torrents = transmission
let torrents = qbit
.list_torrents()
.await
.map_err(|_| ApiError::Upstream("transmission"))?;
.map_err(|_| ApiError::Upstream("qbit"))?;
*cache = Some((Instant::now(), torrents.clone()));
torrents
}
@@ -116,7 +114,7 @@ fn phase(torrent: &Torrent) -> DownloadPhase {
} else {
match torrent.state {
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
// §9.8's stalled chip is for the case that never does.
TorrentState::Downloading if torrent.is_stalled => DownloadPhase::Stalled,
@@ -133,7 +131,7 @@ mod tests {
use arr_db::Db;
use axum::extract::State;
use serde_json::json;
use wiremock::matchers::{body_partial_json, method};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use super::{list, phase, DownloadPhase};
@@ -143,7 +141,6 @@ mod tests {
fn torrent_states_become_the_download_phases() {
let torrent =
|state: arr_dl::TorrentState, rate: u64, error: Option<&str>| arr_dl::Torrent {
id: 1,
name: "name".into(),
hash: "hash".into(),
state,
@@ -186,24 +183,13 @@ mod tests {
#[tokio::test]
async fn endpoint_joins_only_arr_grabs_and_reuses_the_snapshot() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(
ResponseTemplate::new(409).insert_header("x-transmission-session-id", "session"),
)
.up_to_n_times(1)
.mount(&server)
.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
}]}
})))
Mock::given(method("GET"))
.and(path("/api/v2/torrents/info"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([{
"hash": "ABC", "name": "grabbed", "state": "downloading",
"progress": 0.4, "dlspeed": 123, "eta": 60,
"save_path": "/downloads", "tags": ""
}])))
.expect(1)
.mount(&server)
.await;
@@ -231,7 +217,7 @@ mod tests {
let state = AppState::new(Upstreams::new("unused".into(), server.uri()))
.expect("state")
.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 second = list(State(state)).await.expect("second").0;
assert_eq!(first.len(), 1);
+14 -15
View File
@@ -1,7 +1,7 @@
//! `GET /api/health` — is each of the three upstreams answering.
//!
//! 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` —
//! the body carries the verdict, so a degraded service can still explain
//! itself to the UI instead of looking like a fourth outage.
@@ -118,12 +118,12 @@ pub struct HealthReport {
#[schema(example = "0.1.0")]
pub version: String,
pub prowlarr: Check,
pub transmission: Check,
pub qbit: Check,
pub tmdb: Check,
pub subtitles: SubtitleHealth,
}
/// Report reachability of Prowlarr, Transmission and TMDB, plus the
/// Report reachability of Prowlarr, qBittorrent and TMDB, plus the
/// subtitle upstreams (#200).
#[utoipa::path(
get,
@@ -136,14 +136,14 @@ pub struct HealthReport {
pub async fn health(State(state): State<AppState>) -> Json<HealthReport> {
// Independent network probes; serialising them would make the endpoint
// 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_transmission(&state),
probe_qbit(&state),
probe_tmdb(&state),
probe_subtitles(&state)
);
let status = if [prowlarr.status, transmission.status, tmdb.status]
let status = if [prowlarr.status, qbit.status, tmdb.status]
.into_iter()
.chain(subtitles.statuses())
.all(|check| check == Status::Ok)
@@ -157,7 +157,7 @@ pub async fn health(State(state): State<AppState>) -> Json<HealthReport> {
status,
version: env!("CARGO_PKG_VERSION").to_string(),
prowlarr,
transmission,
qbit,
tmdb,
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
/// id to retry with. That is a live daemon, so it counts as reachable.
async fn probe_transmission(state: &AppState) -> Check {
let request = state
.http()
.post(&state.upstreams().transmission_url)
.json(&serde_json::json!({ "method": "session-get" }));
/// A live `WebUI` answers `/api/v2/app/version`, and answers `403` when the
/// probe carries no session. Both are a running daemon, which is all this
/// lamp claims; whether arr's credentials work shows up on the first grab.
async fn probe_qbit(state: &AppState) -> Check {
let base = state.upstreams().qbittorrent_url.trim_end_matches('/');
let request = state.http().get(format!("{base}/api/v2/app/version"));
match request.send().await {
Ok(response)
if response.status().is_success()
|| response.status() == reqwest::StatusCode::CONFLICT =>
|| response.status() == reqwest::StatusCode::FORBIDDEN =>
{
Check::ok()
}
+25 -44
View File
@@ -177,8 +177,8 @@ mod tests {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
/// A Prowlarr that answers `/ping`, and a Transmission that answers an
/// RPC call the way a real one does when it has no session id yet.
/// A Prowlarr that answers `/ping`, and a qBittorrent that answers the
/// version call the way a real one does for an unauthenticated caller.
async fn upstreams_up() -> (MockServer, MockServer) {
let prowlarr = MockServer::start().await;
Mock::given(method("GET"))
@@ -189,16 +189,14 @@ mod tests {
.mount(&prowlarr)
.await;
let transmission = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/transmission/rpc"))
.respond_with(
ResponseTemplate::new(409).insert_header("X-Transmission-Session-Id", "abc"),
)
.mount(&transmission)
let qbit = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v2/app/version"))
.respond_with(ResponseTemplate::new(403).set_body_string("Forbidden"))
.mount(&qbit)
.await;
(prowlarr, transmission)
(prowlarr, qbit)
}
/// Serve the app on an ephemeral port and return its base URL. The server
@@ -225,7 +223,7 @@ mod tests {
#[tokio::test]
async fn all_upstreams_up_is_ok() {
let (prowlarr, transmission) = upstreams_up().await;
let (prowlarr, qbit) = upstreams_up().await;
let tmdb = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/configuration"))
@@ -234,12 +232,9 @@ mod tests {
.await;
let state = AppState::new(
Upstreams::new(
prowlarr.uri(),
format!("{}/transmission/rpc", transmission.uri()),
)
.with_tmdb_url(tmdb.uri())
.with_tmdb_api_key(Some("key".into())),
Upstreams::new(prowlarr.uri(), qbit.uri())
.with_tmdb_url(tmdb.uri())
.with_tmdb_api_key(Some("key".into())),
)
.expect("state")
// The subtitle binaries default to `PATH`; pin them to something
@@ -250,19 +245,15 @@ mod tests {
let body = report(state).await;
assert_eq!(body["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["version"], env!("CARGO_PKG_VERSION"));
}
#[tokio::test]
async fn a_missing_tmdb_key_is_unconfigured_not_an_outage() {
let (prowlarr, transmission) = upstreams_up().await;
let state = AppState::new(Upstreams::new(
prowlarr.uri(),
format!("{}/transmission/rpc", transmission.uri()),
))
.expect("state");
let (prowlarr, qbit) = upstreams_up().await;
let state = AppState::new(Upstreams::new(prowlarr.uri(), qbit.uri())).expect("state");
let body = report(state).await;
assert_eq!(body["tmdb"]["status"], "unconfigured");
@@ -271,20 +262,17 @@ mod tests {
#[tokio::test]
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
// refused connection immediately instead of waiting out the timeout.
let state = AppState::new(Upstreams::new(
"http://127.0.0.1:1".into(),
format!("{}/transmission/rpc", transmission.uri()),
))
.expect("state");
let state =
AppState::new(Upstreams::new("http://127.0.0.1:1".into(), qbit.uri())).expect("state");
let body = report(state).await;
assert_eq!(body["status"], "degraded");
assert_eq!(body["prowlarr"]["status"], "unreachable");
assert_eq!(body["transmission"]["status"], "ok");
assert_eq!(body["qbit"]["status"], "ok");
}
#[tokio::test]
@@ -295,13 +283,9 @@ mod tests {
.respond_with(ResponseTemplate::new(500))
.mount(&prowlarr)
.await;
let transmission = MockServer::start().await;
let qbit = MockServer::start().await;
let state = AppState::new(Upstreams::new(
prowlarr.uri(),
format!("{}/transmission/rpc", transmission.uri()),
))
.expect("state");
let state = AppState::new(Upstreams::new(prowlarr.uri(), qbit.uri())).expect("state");
let body = report(state).await;
assert_eq!(body["prowlarr"]["status"], "unreachable");
@@ -310,7 +294,7 @@ mod tests {
#[tokio::test]
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;
Mock::given(method("GET"))
.and(path("/configuration"))
@@ -319,12 +303,9 @@ mod tests {
.await;
let state = AppState::new(
Upstreams::new(
prowlarr.uri(),
format!("{}/transmission/rpc", transmission.uri()),
)
.with_tmdb_url(tmdb.uri())
.with_tmdb_api_key(Some("super-secret".into())),
Upstreams::new(prowlarr.uri(), qbit.uri())
.with_tmdb_url(tmdb.uri())
.with_tmdb_api_key(Some("super-secret".into())),
)
.expect("state");
+1 -1
View File
@@ -27,7 +27,7 @@ pub struct Root {
/// The payload for creating or replacing a root.
#[derive(Debug, Clone, Deserialize, ToSchema)]
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,
/// `main` or `kids`.
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
/// 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
/// 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
@@ -1713,7 +1713,7 @@ async fn season_import_failures(
WHERE newer.target_kind = 'season'
AND newer.target_id = g.target_id
-- '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
-- all about a gap that still exists.
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
/// 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
/// 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>,
}
+11 -11
View File
@@ -6,7 +6,7 @@ use std::sync::{atomic::AtomicU64, Arc};
use std::time::{Duration, Instant};
use arr_db::Db;
use arr_dl::TransmissionClient;
use arr_dl::QbitClient;
use arr_probe::Extractor;
use arr_subs::{Backend, OpenAiEndpoint, Provider, Syncer};
use tokio::sync::mpsc;
@@ -33,7 +33,7 @@ const PROBE_TIMEOUT: Duration = Duration::from_secs(3);
pub struct Upstreams {
pub prowlarr_url: String,
pub prowlarr_api_key: Option<String>,
pub transmission_url: String,
pub qbittorrent_url: String,
pub tmdb_url: String,
pub tmdb_api_key: Option<String>,
}
@@ -41,11 +41,11 @@ pub struct Upstreams {
impl Upstreams {
/// Every upstream at its documented default, no keys.
#[must_use]
pub fn new(prowlarr_url: String, transmission_url: String) -> Self {
pub fn new(prowlarr_url: String, qbittorrent_url: String) -> Self {
Self {
prowlarr_url,
prowlarr_api_key: None,
transmission_url,
qbittorrent_url,
tmdb_url: DEFAULT_TMDB_URL.to_string(),
tmdb_api_key: None,
}
@@ -109,7 +109,7 @@ pub struct AppState {
extractor: Extractor,
jellyfin: Option<JellyfinClient>,
syncer: Syncer,
transmission: Option<TransmissionClient>,
qbit: Option<QbitClient>,
download_snapshot: DownloadSnapshot,
}
@@ -189,7 +189,7 @@ impl AppState {
extractor: Extractor::default(),
jellyfin: None,
syncer: Syncer::default(),
transmission: None,
qbit: None,
download_snapshot: Arc::new(RwLock::new(None)),
})
}
@@ -201,11 +201,11 @@ impl AppState {
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.
#[must_use]
pub fn with_transmission(mut self, transmission: TransmissionClient) -> Self {
self.transmission = Some(transmission);
pub fn with_qbit(mut self, qbit: QbitClient) -> Self {
self.qbit = Some(qbit);
self
}
@@ -368,8 +368,8 @@ impl AppState {
self.database.as_ref()
}
pub(crate) fn transmission(&self) -> Option<&TransmissionClient> {
self.transmission.as_ref()
pub(crate) fn qbit(&self) -> Option<&QbitClient> {
self.qbit.as_ref()
}
pub(crate) fn download_snapshot(&self) -> &DownloadSnapshot {
+1 -1
View File
@@ -257,7 +257,7 @@ pub enum MediaState {
Missing,
Downloading,
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.
Parked,
}
-1
View File
@@ -47,7 +47,6 @@ tracing = { workspace = true }
tracing-subscriber = { workspace = true }
[dev-dependencies]
base64 = { workspace = true }
tempfile = { 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).
//!
//! 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 prowlarr_url: String,
pub prowlarr_api_key: Option<String>,
pub transmission_url: String,
pub qbittorrent_url: String,
pub tmdb_url: String,
pub tmdb_api_key: Option<String>,
}
@@ -118,16 +118,16 @@ impl BrokenAction {
}
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_transmission(),
self.probe_qbit(),
self.probe_tmdb(),
self.subtitles.probe(database),
);
let mut outcomes = Vec::new();
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);
for (name, reachable) in subtitles {
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())
}
/// 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
/// reachable.
async fn probe_transmission(&self) -> bool {
async fn probe_qbit(&self) -> bool {
let request = self
.http
.post(&self.upstreams.transmission_url)
.post(&self.upstreams.qbittorrent_url)
.timeout(PROBE_TIMEOUT)
.json(&serde_json::json!({ "method": "session-get" }));
matches!(
@@ -235,11 +235,11 @@ mod tests {
use super::*;
fn upstreams(prowlarr_url: String, transmission_url: String) -> Upstreams {
fn upstreams(prowlarr_url: String, qbittorrent_url: String) -> Upstreams {
Upstreams {
prowlarr_url,
prowlarr_api_key: None,
transmission_url,
qbittorrent_url,
tmdb_url: "http://127.0.0.1:1".to_string(),
tmdb_api_key: None,
}
@@ -275,10 +275,10 @@ mod tests {
async fn an_unreachable_upstream_notifies_once_then_re_arms() {
let prowlarr = MockServer::start().await;
// No mock mounted: every request 404s, which counts as unreachable.
let transmission = MockServer::start().await;
let qbit = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(409))
.mount(&transmission)
.mount(&qbit)
.await;
let ntfy = MockServer::start().await;
@@ -289,7 +289,7 @@ mod tests {
let (_dir, db) = database().await;
let action = BrokenAction::new(
upstreams(prowlarr.uri(), transmission.uri()),
upstreams(prowlarr.uri(), qbit.uri()),
subtitles(),
Notifier::new(ntfy.uri()).unwrap(),
"operator-topic".to_string(),
@@ -337,14 +337,14 @@ mod tests {
.respond_with(ResponseTemplate::new(200))
.mount(&prowlarr)
.await;
let transmission = MockServer::start().await;
let qbit = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(409))
.mount(&transmission)
.mount(&qbit)
.await;
let action = BrokenAction::new(
upstreams(prowlarr.uri(), transmission.uri()),
upstreams(prowlarr.uri(), qbit.uri()),
subtitles(),
Notifier::new(ntfy.uri()).unwrap(),
"operator-topic".to_string(),
@@ -381,10 +381,10 @@ mod tests {
.respond_with(ResponseTemplate::new(200))
.mount(&prowlarr)
.await;
let transmission = MockServer::start().await;
let qbit = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(409))
.mount(&transmission)
.mount(&qbit)
.await;
let subs = SubtitleUpstreams {
@@ -392,7 +392,7 @@ mod tests {
..subtitles()
};
let action = BrokenAction::new(
upstreams(prowlarr.uri(), transmission.uri()),
upstreams(prowlarr.uri(), qbit.uri()),
subs,
Notifier::new(ntfy.uri()).unwrap(),
"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_PROWLARR_URL: &str = "ARR_PROWLARR_URL";
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_SEED_RATIO_LIMIT: &str = "ARR_SEED_RATIO_LIMIT";
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_MEDIA_ROOT: &str = "/mnt/media";
pub const DEFAULT_PROWLARR_URL: &str = "http://localhost:9696";
pub const DEFAULT_TRANSMISSION_URL: &str = "http://localhost:9091/transmission/rpc";
/// Transmission's own view of the download directory (DESIGN.md §3). It
pub const DEFAULT_QBITTORRENT_URL: &str = "http://localhost:8080";
/// qBittorrent's own view of the download directory (DESIGN.md §3). It
/// 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
/// 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_IDLE_LIMIT_MINUTES: u64 = 4320;
pub const DEFAULT_JELLYFIN_URL: &str = "http://localhost:8096";
@@ -97,7 +102,7 @@ struct ConfigFile {
#[serde(default)]
prowlarr_url: Option<String>,
#[serde(default)]
transmission_url: Option<String>,
qbittorrent_url: Option<String>,
#[serde(default)]
download_dir: Option<PathBuf>,
#[serde(default)]
@@ -151,7 +156,9 @@ pub struct EnvOverrides {
pub media_root: Option<String>,
pub prowlarr_url: 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 seed_ratio_limit: Option<String>,
pub seed_idle_limit_minutes: Option<String>,
@@ -183,7 +190,9 @@ impl EnvOverrides {
media_root: std::env::var(ENV_MEDIA_ROOT).ok(),
prowlarr_url: std::env::var(ENV_PROWLARR_URL).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(),
seed_ratio_limit: std::env::var(ENV_SEED_RATIO_LIMIT).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 prowlarr_url: String,
pub prowlarr_api_key: Option<String>,
pub transmission_url: String,
/// Where Transmission puts completed downloads, in Transmission's own
pub qbittorrent_url: String,
/// 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).
pub download_dir: PathBuf,
pub seed_ratio_limit: f64,
@@ -362,10 +376,12 @@ impl Config {
.or(file.prowlarr_url)
.unwrap_or_else(|| DEFAULT_PROWLARR_URL.to_string()),
prowlarr_api_key: env.prowlarr_api_key,
transmission_url: env
.transmission_url
.or(file.transmission_url)
.unwrap_or_else(|| DEFAULT_TRANSMISSION_URL.to_string()),
qbittorrent_url: env
.qbittorrent_url
.or(file.qbittorrent_url)
.unwrap_or_else(|| DEFAULT_QBITTORRENT_URL.to_string()),
qbittorrent_username: env.qbittorrent_username,
qbittorrent_password: env.qbittorrent_password,
download_dir: env
.download_dir
.map(PathBuf::from)
@@ -429,6 +445,28 @@ fn parse_bind_addr(raw: &str, env: &'static str) -> Result<SocketAddr, ConfigErr
mod tests {
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]
fn empty_environment_resolves_to_defaults() {
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.media_root, PathBuf::from(DEFAULT_MEDIA_ROOT));
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!((config.seed_ratio_limit - DEFAULT_SEED_RATIO_LIMIT).abs() < f64::EPSILON);
assert_eq!(
@@ -491,7 +529,7 @@ prowlarr_url = "http://prowlarr.internal:9696"
assert_eq!(config.media_root, PathBuf::from("/tank/media"));
assert_eq!(config.prowlarr_url, "http://prowlarr.internal:9696");
// Untouched fields still default.
assert_eq!(config.transmission_url, DEFAULT_TRANSMISSION_URL);
assert_eq!(config.qbittorrent_url, DEFAULT_QBITTORRENT_URL);
}
#[test]
+109 -228
View File
@@ -1,5 +1,5 @@
//! 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.
//!
//! 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:
//!
//! - 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
//! second one;
//! - 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::{Language, Policy, TitleOverrides, Verdict};
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_meta::TmdbClient;
@@ -37,7 +37,7 @@ const MOVIES_PER_TICK: i64 = 5;
/// 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
/// add time and enforced by Transmission.
/// add time and enforced by qBittorrent.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SeedingLimits {
pub ratio: f64,
@@ -77,8 +77,8 @@ pub enum GrabError {
Metadata(#[from] arr_meta::Error),
#[error("movie {0} has an invalid TMDB id")]
InvalidTmdbId(i64),
#[error("transmission: {0}")]
Transmission(#[from] arr_dl::Error),
#[error("qbit: {0}")]
Qbit(#[from] arr_dl::Error),
#[error("download link: {0}")]
Download(#[from] arr_indexer::DownloadError),
#[error("release {name}: {source}")]
@@ -102,13 +102,13 @@ impl GrabAction {
#[must_use]
pub fn new(
prowlarr: ProwlarrClient,
transmission: TransmissionClient,
qbit: QbitClient,
download_dir: PathBuf,
seeding: SeedingRules,
) -> Self {
Self {
indexers: IndexerDirectory::new(prowlarr.clone()),
grabber: Grabber::new(prowlarr.clone(), transmission, download_dir, seeding),
grabber: Grabber::new(prowlarr.clone(), qbit, download_dir, seeding),
prowlarr,
tmdb: None,
}
@@ -427,7 +427,7 @@ impl GrabAction {
/// 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
/// Transmission.
/// qBittorrent.
pub(crate) async fn grab_release_now(
&self,
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
/// whether a failed attempt counts toward a backoff; from the winning
/// release onward they are the same writes, so they share this.
#[derive(Debug)]
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).
prowlarr: ProwlarrClient,
transmission: TransmissionClient,
qbit: QbitClient,
download_dir: PathBuf,
seeding: SeedingRules,
}
@@ -532,22 +532,22 @@ impl GrabScope {
impl Grabber {
pub(crate) fn new(
prowlarr: ProwlarrClient,
transmission: TransmissionClient,
qbit: QbitClient,
download_dir: PathBuf,
seeding: SeedingRules,
) -> Self {
Self {
prowlarr,
transmission,
qbit,
download_dir,
seeding,
}
}
/// Move grabs Transmission reports as complete out of `sent`, whatever
/// Move grabs qBittorrent reports as complete out of `sent`, whatever
/// 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
/// state after a restart. Both grab actions call it; whichever runs first
/// does the work and the other finds nothing.
@@ -564,7 +564,7 @@ impl Grabber {
}
let torrents: HashMap<String, f64> = self
.transmission
.qbit
.list_torrents()
.await?
.into_iter()
@@ -603,7 +603,7 @@ impl Grabber {
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
/// `failed` so it does not feed the `needs_decision` queue (attention.rs).
/// Nothing is blacklisted, since the release itself never failed policy,
@@ -624,10 +624,10 @@ impl Grabber {
grab_id,
target_kind,
target_id,
"torrent vanished from Transmission; target parked"
"torrent vanished from qBittorrent; target parked"
);
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}"),
))
}
@@ -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
/// (issue #100).
async fn send_to_transmission(
async fn send_to_qbit(
&self,
winner: &Eligible,
loaded: &MoviePolicy,
@@ -659,7 +659,7 @@ impl Grabber {
let seeding = self.seeding.for_indexer(winner.indexer_id);
let source = torrent_source(self.prowlarr.download(&winner.download_url).await?);
Ok(self
.transmission
.qbit
.add_torrent(AddTorrent {
source,
label: label(loaded),
@@ -670,10 +670,10 @@ impl Grabber {
.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
/// 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
/// backoff.
pub(crate) async fn send_winner(
@@ -684,7 +684,7 @@ impl Grabber {
blacklist: &Blacklist,
winner: Eligible,
) -> 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,
Err(error) => {
self.record_attempt(database, target).await?;
@@ -694,7 +694,7 @@ impl Grabber {
let infohash = added.hash.to_ascii_lowercase();
// §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.
if blacklist.blocks_infohash(&infohash) {
self.record_attempt(database, target).await?;
@@ -782,7 +782,7 @@ impl Grabber {
/// 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
/// 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.
async fn drop_blacklisted_torrent(
&self,
@@ -822,7 +822,7 @@ impl Grabber {
} else {
// This tick added it seconds ago, so it carries no seeding
// 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!(
title = target.title,
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
/// both stacks can run against one Transmission.
/// both stacks can run against one qBittorrent.
fn label(loaded: &MoviePolicy) -> String {
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}")
}
/// A resolved download, in the shape Transmission takes it.
/// A resolved download, in the shape qBittorrent takes it.
fn torrent_source(download: Download) -> TorrentSource {
match download {
Download::Magnet(uri) => TorrentSource::Magnet(uri),
@@ -1572,6 +1572,18 @@ pub(crate) mod test_downloads {
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.
pub(crate) async fn mount(server: &MockServer) {
Mock::given(method("GET"))
@@ -1592,16 +1604,9 @@ pub(crate) mod test_downloads {
.next()
.unwrap_or_default()
.to_owned();
// Deterministic stand-in for the infohash the tracker would give,
// and distinct per release so duplicates still collapse.
let mut hash: u64 = 5381;
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}"),
)
let hash = super::test_downloads::infohash_for(&name);
ResponseTemplate::new(302)
.insert_header("location", format!("magnet:?xt=urn:btih:{hash}&dn={name}"))
}
}
}
@@ -1609,127 +1614,16 @@ pub(crate) mod test_downloads {
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use base64::Engine as _;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use serde_json::{json, Value};
use serde_json::json;
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::*;
/// 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>
<item>
<title>Dune.Part.Two.2024.2160p.WEB-DL.DDP5.1.Atmos</title>
@@ -1846,14 +1740,8 @@ mod tests {
server
}
async fn transmission() -> (MockServer, FakeTransmission) {
let server = MockServer::start().await;
let fake = FakeTransmission::default();
Mock::given(method("POST"))
.respond_with(fake.clone())
.mount(&server)
.await;
(server, fake)
async fn qbit() -> (MockServer, FakeQbit) {
FakeQbit::start().await
}
async fn wanted_movie() -> (tempfile::TempDir, Db) {
@@ -1871,11 +1759,11 @@ mod tests {
(dir, database)
}
fn action(prowlarr: &MockServer, transmission: &MockServer) -> GrabAction {
fn action(prowlarr: &MockServer, qbit: &MockServer) -> GrabAction {
GrabAction::new(
ProwlarrClient::new(prowlarr.uri(), "key").unwrap(),
TransmissionClient::new(&transmission.uri()).unwrap(),
PathBuf::from("/mnt/media/transmission/complete"),
QbitClient::new(&qbit.uri()).unwrap(),
PathBuf::from("/mnt/media/qbittorrent/complete"),
SeedingRules::new(
SeedingLimits {
ratio: 1.5,
@@ -1888,10 +1776,10 @@ mod tests {
fn action_with_tmdb(
prowlarr: &MockServer,
transmission: &MockServer,
qbit: &MockServer,
metadata: &MockServer,
) -> GrabAction {
action(prowlarr, transmission).with_tmdb(Arc::new(
action(prowlarr, qbit).with_tmdb(Arc::new(
TmdbClient::builder("key")
.base_url(format!("{}/3/", metadata.uri()))
.build()
@@ -1945,7 +1833,7 @@ mod tests {
async fn a_wanted_movie_ends_with_one_torrent_and_one_grab() {
let (_dir, database) = wanted_movie().await;
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
let (downloader, fake) = qbit().await;
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() {
let (_dir, database) = wanted_movie().await;
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
let (downloader, fake) = qbit().await;
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")
.execute(database.pool())
.await
@@ -1996,7 +1884,7 @@ mod tests {
async fn a_second_tick_grabs_nothing_new() {
let (_dir, database) = wanted_movie().await;
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
let (downloader, fake) = qbit().await;
let action = action(&indexer, &downloader);
action.tick(&database).await.unwrap();
@@ -2013,26 +1901,17 @@ mod tests {
async fn the_label_and_both_seed_limits_are_set_at_add_time() {
let (_dir, database) = wanted_movie().await;
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
let (downloader, fake) = qbit().await;
action(&indexer, &downloader).tick(&database).await.unwrap();
assert_eq!(fake.torrents()[0].labels, vec!["movies-main".to_owned()]);
let add = downloader
.received_requests()
.await
.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));
let added = &fake.torrents()[0];
assert_eq!(added.labels, vec!["movies-main".to_owned()]);
assert!((added.ratio_limit - 1.5).abs() < f64::EPSILON);
assert_eq!(added.idle_limit_minutes, 60);
assert_eq!(
add["arguments"]["download-dir"],
json!("/mnt/media/transmission/complete")
added.save_path,
String::from("/mnt/media/qbittorrent/complete")
);
}
@@ -2058,7 +1937,7 @@ mod tests {
async fn every_candidate_is_recorded_with_its_verdict() {
let (_dir, database) = wanted_movie().await;
let indexer = prowlarr().await;
let (downloader, _fake) = transmission().await;
let (downloader, _fake) = qbit().await;
action(&indexer, &downloader).tick(&database).await.unwrap();
@@ -2081,13 +1960,13 @@ mod tests {
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.
#[tokio::test]
async fn a_completed_torrent_moves_its_grab_to_downloaded() {
let (_dir, database) = wanted_movie().await;
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
let (downloader, fake) = qbit().await;
let action = action(&indexer, &downloader);
action.tick(&database).await.unwrap();
@@ -2099,14 +1978,14 @@ mod tests {
}
/// #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
/// it never counts toward the `needs_decision` queue (attention.rs).
#[tokio::test]
async fn a_torrent_removed_by_hand_parks_the_movie() {
let (_dir, database) = wanted_movie().await;
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
let (downloader, fake) = qbit().await;
action(&indexer, &downloader).tick(&database).await.unwrap();
fake.torrents.lock().unwrap().clear();
@@ -2131,7 +2010,7 @@ mod tests {
async fn a_parked_movie_is_not_regrabbed() {
let (_dir, database) = wanted_movie().await;
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
let (downloader, fake) = qbit().await;
let action = action(&indexer, &downloader);
action.tick(&database).await.unwrap();
@@ -2154,7 +2033,7 @@ mod tests {
async fn a_rewanted_movie_can_regrab_the_same_infohash() {
let (_dir, database) = wanted_movie().await;
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
let (downloader, fake) = qbit().await;
let action = action(&indexer, &downloader);
action.tick(&database).await.unwrap();
@@ -2189,7 +2068,7 @@ mod tests {
.await
.unwrap();
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
let (downloader, fake) = qbit().await;
let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap();
@@ -2212,7 +2091,7 @@ mod tests {
.await
.unwrap();
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
let (downloader, fake) = qbit().await;
action(&indexer, &downloader).tick(&database).await.unwrap();
@@ -2240,7 +2119,7 @@ mod tests {
.await
.unwrap();
let indexer = prowlarr().await;
let (downloader, _fake) = transmission().await;
let (downloader, _fake) = qbit().await;
action(&indexer, &downloader).tick(&database).await.unwrap();
@@ -2253,23 +2132,24 @@ mod tests {
assert_eq!(rule.as_deref(), Some("blacklisted"));
}
/// §6.3's second key. A `.torrent` link hides its infohash until
/// Transmission has fetched it, so the blacklisted torrent is only
/// recognised after the add — and must not leave a grab behind.
/// §6.3's second key. The infohash is only known once the indexer link
/// has been resolved, which happens at grab time, so a blacklisted
/// torrent under a new release name is recognised after the add — and
/// must not leave a grab behind.
#[tokio::test]
async fn a_blacklisted_infohash_never_becomes_a_grab() {
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(
database.pool(),
Some(&format!("{:040x}", 7)),
Some(&test_downloads::infohash_for("good.torrent")),
"Some.Older.Name.Of.The.Same.Torrent",
"required_audio",
)
.await
.unwrap();
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
let (downloader, fake) = qbit().await;
action(&indexer, &downloader).tick(&database).await.unwrap();
@@ -2279,7 +2159,7 @@ mod tests {
"a torrent added this tick and then found blacklisted is removed"
);
// Recorded under its new name, so the next tick stops before paying
// Transmission again.
// qBittorrent again.
let blacklist = Blacklist::load(database.pool()).await.unwrap();
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
@@ -2329,7 +2209,7 @@ mod tests {
async fn a_manual_search_on_an_available_movie_refreshes_the_deck() {
let (_dir, database) = available_movie().await;
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
let (downloader, fake) = qbit().await;
let outcome = action(&indexer, &downloader)
.search_now(&database, 1)
@@ -2351,7 +2231,7 @@ mod tests {
let (_dir, database) = available_movie().await;
let indexer = prowlarr().await;
let metadata = tmdb(UNRELEASED_METADATA).await;
let (downloader, _fake) = transmission().await;
let (downloader, _fake) = qbit().await;
action_with_tmdb(&indexer, &downloader, &metadata)
.search_now(&database, 1)
@@ -2367,7 +2247,7 @@ mod tests {
async fn a_manual_search_with_a_grab_in_flight_does_not_grab_again() {
let (_dir, database) = wanted_movie().await;
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
let (downloader, fake) = qbit().await;
let action = action(&indexer, &downloader);
action.tick(&database).await.unwrap();
assert_eq!(grabs(&database).await.len(), 1);
@@ -2384,7 +2264,7 @@ mod tests {
async fn a_manual_search_on_a_wanted_movie_still_grabs() {
let (_dir, database) = wanted_movie().await;
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
let (downloader, fake) = qbit().await;
let outcome = action(&indexer, &downloader)
.search_now(&database, 1)
@@ -2405,7 +2285,7 @@ mod tests {
.await
.unwrap();
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
let (downloader, fake) = qbit().await;
let outcome = action(&indexer, &downloader)
.search_now(&database, 1)
@@ -2427,7 +2307,7 @@ mod tests {
.await
.unwrap();
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
let (downloader, fake) = qbit().await;
let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap();
@@ -2441,7 +2321,7 @@ mod tests {
let (_dir, database) = wanted_movie().await;
let indexer = prowlarr().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);
for _ in 0..3 {
@@ -2461,7 +2341,7 @@ mod tests {
let (_dir, database) = wanted_movie().await;
let indexer = empty_prowlarr().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);
action.tick(&database).await.unwrap();
@@ -2513,7 +2393,7 @@ mod tests {
let (_dir, database) = wanted_movie().await;
let indexer = empty_prowlarr().await;
let metadata = tmdb(RELEASED_METADATA).await;
let (downloader, _fake) = transmission().await;
let (downloader, _fake) = qbit().await;
action_with_tmdb(&indexer, &downloader, &metadata)
.tick(&database)
@@ -2568,7 +2448,7 @@ mod tests {
"vote_average": 8.152,"#,
);
let metadata = tmdb(&artwork_metadata).await;
let (downloader, _fake) = transmission().await;
let (downloader, _fake) = qbit().await;
action_with_tmdb(&indexer, &downloader, &metadata)
.tick(&database)
@@ -2629,7 +2509,7 @@ mod tests {
"vote_average": 8.152,"#,
)
};
let (downloader, _fake) = transmission().await;
let (downloader, _fake) = qbit().await;
action_with_tmdb(&indexer, &downloader, &tmdb(&unrated()).await)
.tick(&database)
@@ -2690,7 +2570,7 @@ mod tests {
.unwrap();
let indexer = empty_prowlarr().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)
.tick(&database)
@@ -2713,7 +2593,7 @@ mod tests {
async fn indexer_discovery_is_cached_across_ticks() {
let (_dir, database) = wanted_movie().await;
let indexer = prowlarr().await;
let (downloader, _fake) = transmission().await;
let (downloader, _fake) = qbit().await;
let action = action(&indexer, &downloader);
action.tick(&database).await.unwrap();
@@ -2744,7 +2624,7 @@ mod tests {
)
.mount(&indexer)
.await;
let (downloader, fake) = transmission().await;
let (downloader, fake) = qbit().await;
let mut action = action(&indexer, &downloader);
action.indexers.discovery_timeout = Duration::from_millis(50);
@@ -2761,7 +2641,7 @@ mod tests {
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
/// and the link never leaves arr.
#[tokio::test]
@@ -2779,13 +2659,14 @@ mod tests {
)
.mount(&indexer)
.await;
let (downloader, fake) = transmission().await;
let (downloader, fake) = qbit().await;
action(&indexer, &downloader).tick(&database).await.unwrap();
let sent = base64::engine::general_purpose::STANDARD
.decode(&fake.torrents()[0].source)
.expect("the torrent is sent as base64 metainfo");
let sent = fake.torrents()[0]
.metainfo
.clone()
.expect("the torrent file is sent inline");
assert_eq!(sent, TORRENT);
let fetched_with_key = indexer
.received_requests()
@@ -2798,12 +2679,12 @@ mod tests {
}
/// 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]
async fn a_link_that_redirects_to_a_magnet_is_sent_as_the_magnet() {
let (_dir, database) = wanted_movie().await;
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
let (downloader, fake) = qbit().await;
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
//! and the library entry are separate lifecycles (§7.3). A hard-failed
//! 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
//! `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::{ProbedMedia, Rule, Source, Verdict};
use arr_db::Db;
use arr_dl::TransmissionClient;
use arr_dl::QbitClient;
use arr_probe::Prober;
use crate::notify::Notifier;
@@ -36,8 +36,8 @@ pub enum ImportError {
Database(#[from] sqlx::Error),
#[error("policy: {0}")]
Policy(#[from] arr_db::PolicyError),
#[error("transmission: {0}")]
Transmission(#[from] arr_dl::Error),
#[error("qbit: {0}")]
Qbit(#[from] arr_dl::Error),
#[error("probe: {0}")]
Probe(#[from] arr_probe::Error),
#[error("blocking task: {0}")]
@@ -73,7 +73,7 @@ enum ProbeOutcome {
/// the §7.4 layout.
#[derive(Debug)]
pub struct ImportAction {
transmission: TransmissionClient,
qbit: QbitClient,
prober: Prober,
jellyfin: JellyfinClient,
notifier: Notifier,
@@ -96,7 +96,7 @@ pub struct ImportAction {
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)]
struct PendingImport {
grab_id: i64,
@@ -112,14 +112,14 @@ struct PendingImport {
impl ImportAction {
#[must_use]
pub fn new(
transmission: TransmissionClient,
qbit: QbitClient,
prober: Prober,
jellyfin: JellyfinClient,
notifier: Notifier,
operator_topic: Option<String>,
) -> Self {
Self {
transmission,
qbit,
prober,
jellyfin,
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.
///
/// Torrent-declared names are untrusted input: an absolute or
@@ -399,13 +399,13 @@ impl ImportAction {
grab_id: i64,
infohash: &str,
) -> Result<Option<Vec<PathBuf>>, ImportError> {
let Some(content) = self.transmission.torrent_content(infohash).await? else {
// Gone from Transmission — the caller marks the grab vanished
let Some(content) = self.qbit.torrent_content(infohash).await? else {
// Gone from qBittorrent — the caller marks the grab vanished
// and parks the target (#108).
tracing::warn!(
grab_id,
infohash,
"downloaded grab has no torrent in Transmission; not importing"
"downloaded grab has no torrent in qBittorrent; not importing"
);
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`
/// rather than `failed` so it does not feed the `needs_decision` queue
/// (attention.rs). Nothing is blacklisted, since the release itself
@@ -692,11 +692,11 @@ impl ImportAction {
movie_id = pending.movie_id,
title = pending.title,
release = pending.release_name,
"torrent vanished from Transmission; movie parked"
"torrent vanished from qBittorrent; movie parked"
);
Ok(Outcome::new(
format!(
"grab {} downloaded, torrent vanished from Transmission",
"grab {} downloaded, torrent vanished from qBittorrent",
pending.grab_id
),
format!("parked movie {}", pending.movie_id),
@@ -726,11 +726,11 @@ impl ImportAction {
grab_id = pending.grab_id,
series = pending.series_title,
release = pending.release_name,
"torrent vanished from Transmission; target parked"
"torrent vanished from qBittorrent; target parked"
);
Ok(Outcome::new(
format!(
"grab {} downloaded, torrent vanished from Transmission",
"grab {} downloaded, torrent vanished from qBittorrent",
pending.grab_id
),
format!("parked {target_kind} {target_id}"),
@@ -897,7 +897,7 @@ async fn record_import(
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.
async fn pending_imports(database: &Db) -> Result<Vec<PendingImport>, ImportError> {
let rows = sqlx::query!(
@@ -935,7 +935,7 @@ async fn pending_imports(database: &Db) -> Result<Vec<PendingImport>, ImportErro
.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.
#[derive(Debug, Clone)]
struct PendingTvImport {
@@ -1397,18 +1397,39 @@ mod tests {
path
}
async fn harness(media_json: &str) -> Harness {
harness_with(
media_json,
json!([
{"name": "Dune/Dune.mkv", "length": 13, "bytesCompleted": 13},
{"name": "Dune/Dune.nfo", "length": 10, "bytesCompleted": 10}
]),
)
.await
/// A qBittorrent holding exactly one torrent, `INFOHASH`, with `files`
/// under `save_path`.
async fn qbit(save_path: &str, files: &[(&str, u64)]) -> MockServer {
let server = MockServer::start().await;
let torrent = json!({
"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,
"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 downloads = dir.path().join("downloads");
let library = dir.path().join("library");
@@ -1453,18 +1474,7 @@ mod tests {
.unwrap();
insert_owner(&database, "movie", 1, "Alice", "alice-topic").await;
let server = MockServer::start().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 server = qbit(&downloads.to_string_lossy(), files).await;
let jellyfin_server = start_jellyfin_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 notifier = Notifier::new(ntfy_server.uri()).unwrap();
let action = ImportAction::new(
TransmissionClient::new(&server.uri()).unwrap(),
QbitClient::new(&server.uri()).unwrap(),
prober,
jellyfin,
notifier,
@@ -1727,7 +1737,7 @@ mod tests {
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
/// `vanished` and parks the movie (`wanted` cleared) instead of
/// reopening it as a gap, without touching the blacklist.
@@ -1764,17 +1774,15 @@ mod tests {
.unwrap();
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"result": "success",
"arguments": {"torrents": []}
})))
Mock::given(method("GET"))
.and(path("/api/v2/torrents/info"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
.mount(&server)
.await;
let jellyfin_server = start_jellyfin_server().await;
let ntfy_server = start_ntfy_server().await;
let action = ImportAction::new(
TransmissionClient::new(&server.uri()).unwrap(),
QbitClient::new(&server.uri()).unwrap(),
Prober::new().with_binary(fake_ffprobe(dir.path(), HDR10_PROBE)),
JellyfinClient::new(jellyfin_server.uri(), None).unwrap(),
Notifier::new(ntfy_server.uri()).unwrap(),
@@ -1809,11 +1817,11 @@ mod tests {
async fn hostile_torrent_paths_never_leave_the_download_root() {
let h = harness_with(
HDR10_PROBE,
json!([
{"name": "../outside.mkv", "length": 13, "bytesCompleted": 13},
{"name": "/tmp/absolute.mkv", "length": 13, "bytesCompleted": 13},
{"name": "Dune/Dune.mkv", "length": 13, "bytesCompleted": 13}
]),
&[
("../outside.mkv", 13),
("/tmp/absolute.mkv", 13),
("Dune/Dune.mkv", 13),
],
)
.await;
@@ -1832,11 +1840,7 @@ mod tests {
/// hard fail, not an escape.
#[tokio::test]
async fn a_torrent_of_only_hostile_paths_hard_fails() {
let h = harness_with(
HDR10_PROBE,
json!([{"name": "../../etc/passwd", "length": 13, "bytesCompleted": 13}]),
)
.await;
let h = harness_with(HDR10_PROBE, &[("../../etc/passwd", 13)]).await;
let outcomes = h.action.tick(&h.database).await.unwrap();
@@ -1885,7 +1889,7 @@ mod tests {
std::fs::set_permissions(&script, permissions).unwrap();
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),
JellyfinClient::new("http://127.0.0.1:1", None).unwrap(),
Notifier::new("http://127.0.0.1:1").unwrap(),
@@ -1932,7 +1936,7 @@ mod tests {
permissions.set_mode(0o755);
std::fs::set_permissions(&script, permissions).unwrap();
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),
JellyfinClient::new("http://127.0.0.1:1", None).unwrap(),
Notifier::new("http://127.0.0.1:1").unwrap(),
@@ -2070,21 +2074,14 @@ mod tests {
.unwrap();
insert_owner(&database, "series", 1, "Bob", "bob-topic").await;
let server = MockServer::start().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": [
{"name": "Fallout.S01/Fallout.S01E01.mkv", "length": 2, "bytesCompleted": 2},
{"name": "Fallout.S01/Fallout.S01E02.mkv", "length": 2, "bytesCompleted": 2}
]
}]}
})))
.mount(&server)
.await;
let server = qbit(
&downloads.to_string_lossy(),
&[
("Fallout.S01/Fallout.S01E01.mkv", 2),
("Fallout.S01/Fallout.S01E02.mkv", 2),
],
)
.await;
let jellyfin_server = start_jellyfin_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 notifier = Notifier::new(ntfy_server.uri()).unwrap();
let action = ImportAction::new(
TransmissionClient::new(&server.uri()).unwrap(),
QbitClient::new(&server.uri()).unwrap(),
prober,
jellyfin,
notifier,
+35 -50
View File
@@ -9,6 +9,8 @@ mod indexers;
mod manual;
mod metadata;
mod notify;
#[cfg(test)]
mod qbit_fake;
mod reaper;
pub mod reconcile;
mod rss;
@@ -92,8 +94,8 @@ enum Error {
Tmdb(#[from] arr_meta::Error),
#[error("prowlarr client: {0}")]
Prowlarr(#[from] arr_indexer::Error),
#[error("transmission client: {0}")]
Transmission(#[from] arr_dl::Error),
#[error("qbit client: {0}")]
Qbit(#[from] arr_dl::Error),
#[error("jellyfin client: {0}")]
Jellyfin(#[from] arr_api::jellyfin::Error),
#[error("ntfy client: {0}")]
@@ -118,19 +120,18 @@ fn api_state(
database: &Db,
jellyfin: arr_api::jellyfin::JellyfinClient,
translators: &Translators,
transmission: &arr_dl::TransmissionClient,
qbit: &arr_dl::QbitClient,
) -> Result<AppState, Error> {
let mut upstreams =
Upstreams::new(config.prowlarr_url.clone(), config.transmission_url.clone())
.with_prowlarr_api_key(config.prowlarr_api_key.clone())
.with_tmdb_api_key(config.tmdb_api_key.clone());
let mut upstreams = Upstreams::new(config.prowlarr_url.clone(), config.qbittorrent_url.clone())
.with_prowlarr_api_key(config.prowlarr_api_key.clone())
.with_tmdb_api_key(config.tmdb_api_key.clone());
if let Some(tmdb_url) = config.tmdb_url.clone() {
upstreams = upstreams.with_tmdb_url(tmdb_url);
}
let mut state = AppState::new(upstreams)?
.with_database(database.clone())
.with_transmission(transmission.clone())
.with_qbit(qbit.clone())
.with_subtitle_providers(subtitle_providers(
config.opensubtitles_api_key.clone(),
config.opensubtitles_username.clone(),
@@ -158,7 +159,14 @@ async fn run() -> Result<(), Error> {
let database = Db::connect(&config.database_path).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 mut client = TmdbClient::builder(key.clone());
if let Some(url) = &config.tmdb_url {
@@ -178,7 +186,7 @@ async fn run() -> Result<(), Error> {
let (reconcile, manual_grab, manual_tv) = reconcile_loop(
&database,
&config,
&transmission,
&qbit,
tmdb.as_ref(),
&notifier,
&translators,
@@ -192,13 +200,7 @@ async fn run() -> Result<(), Error> {
compat = compat.with_tmdb(tmdb);
}
let state = api_state(
&config,
&database,
api_jellyfin,
&translators,
&transmission,
)?;
let state = api_state(&config, &database, api_jellyfin, &translators, &qbit)?;
let app = arr_api::router(state.clone())
.merge(arr_compat::router(compat))
.fallback(web::serve)
@@ -329,7 +331,7 @@ async fn seed_translator_settings(database: &Db, translators: &Translators) -> R
fn reconcile_loop(
database: &Db,
config: &Config,
transmission: &arr_dl::TransmissionClient,
qbit: &arr_dl::QbitClient,
tmdb: Option<&Arc<TmdbClient>>,
notifier: &Notifier,
translators: &Translators,
@@ -360,27 +362,16 @@ fn reconcile_loop(
.map(|key| arr_indexer::ProwlarrClient::new(config.prowlarr_url.clone(), key))
.transpose()?;
let (reconcile, manual_grab) = register_movie_grab(
reconcile,
prowlarr.as_ref(),
transmission,
config,
&seeding,
tmdb,
);
let (reconcile, manual_grab) =
register_movie_grab(reconcile, prowlarr.as_ref(), qbit, config, &seeding, tmdb);
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
// wanted list (§6.2).
if let Some(prowlarr) = prowlarr {
reconcile = reconcile.register(
Tick::Rss,
RssAction::new(
prowlarr,
transmission.clone(),
config.download_dir.clone(),
seeding,
),
RssAction::new(prowlarr, qbit.clone(), config.download_dir.clone(), seeding),
);
}
// §8: metadata refresh is its own daily lane, staggered against the
@@ -396,7 +387,7 @@ fn reconcile_loop(
reconcile = reconcile.register(
Tick::Reconcile,
ImportAction::new(
transmission.clone(),
qbit.clone(),
arr_probe::Prober::new(),
jellyfin,
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))
}
/// 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
/// missing `alass`/`ffmpeg` all fold into the same operator message.
fn register_broken(
@@ -447,7 +438,7 @@ fn register_broken(
let broken_upstreams = broken::Upstreams {
prowlarr_url: config.prowlarr_url.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
.clone()
@@ -483,7 +474,7 @@ fn register_broken(
fn register_tv_grab(
mut reconcile: ReconcileLoop,
prowlarr: Option<&arr_indexer::ProwlarrClient>,
transmission: &arr_dl::TransmissionClient,
qbit: &arr_dl::QbitClient,
config: &Config,
seeding: &SeedingRules,
) -> (ReconcileLoop, Option<TvGrabAction>) {
@@ -493,7 +484,7 @@ fn register_tv_grab(
let tv_grab_action = || {
TvGrabAction::new(
prowlarr.clone(),
transmission.clone(),
qbit.clone(),
config.download_dir.clone(),
seeding.clone(),
)
@@ -511,7 +502,7 @@ fn register_tv_grab(
fn register_movie_grab(
mut reconcile: ReconcileLoop,
prowlarr: Option<&arr_indexer::ProwlarrClient>,
transmission: &arr_dl::TransmissionClient,
qbit: &arr_dl::QbitClient,
config: &Config,
seeding: &SeedingRules,
tmdb: Option<&Arc<TmdbClient>>,
@@ -522,29 +513,23 @@ fn register_movie_grab(
};
reconcile = reconcile.register(
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(
prowlarr,
transmission,
config,
seeding,
tmdb,
));
let manual_grab = Some(movie_grab_action(prowlarr, qbit, config, seeding, tmdb));
(reconcile, manual_grab)
}
/// The movie grab lane, built fresh for each caller.
fn movie_grab_action(
prowlarr: &arr_indexer::ProwlarrClient,
transmission: &arr_dl::TransmissionClient,
qbit: &arr_dl::QbitClient,
config: &Config,
seeding: &SeedingRules,
tmdb: &Arc<TmdbClient>,
) -> GrabAction {
GrabAction::new(
prowlarr.clone(),
transmission.clone(),
qbit.clone(),
config.download_dir.clone(),
seeding.clone(),
)
+12 -20
View File
@@ -1,7 +1,7 @@
//! 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`
//! 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
//! one operator waiting on a 202.
@@ -149,7 +149,7 @@ mod tests {
use std::path::PathBuf;
use arr_db::Db;
use arr_dl::TransmissionClient;
use arr_dl::QbitClient;
use arr_indexer::ProwlarrClient;
use wiremock::matchers::{method, path, path_regex, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
@@ -172,23 +172,15 @@ mod tests {
(dir, database)
}
async fn transmission() -> MockServer {
let server = MockServer::start().await;
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
async fn qbit() -> MockServer {
crate::qbit_fake::FakeQbit::start().await.0
}
fn grab_action(indexer: &MockServer, transmission: &MockServer) -> GrabAction {
fn grab_action(indexer: &MockServer, qbit: &MockServer) -> GrabAction {
GrabAction::new(
ProwlarrClient::new(indexer.uri(), "key").unwrap(),
TransmissionClient::new(&transmission.uri()).unwrap(),
PathBuf::from("/mnt/media/transmission/complete"),
QbitClient::new(&qbit.uri()).unwrap(),
PathBuf::from("/mnt/media/qbittorrent/complete"),
SeedingRules::new(
SeedingLimits {
ratio: 1.5,
@@ -239,8 +231,8 @@ mod tests {
)
.mount(&indexer)
.await;
let transmission = transmission().await;
let grab = grab_action(&indexer, &transmission);
let qbit = qbit().await;
let grab = grab_action(&indexer, &qbit);
handle_movie(&grab, &database, MovieCommand::Search { movie_id: 1 })
.await
@@ -269,7 +261,7 @@ mod tests {
}
/// 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]
async fn a_grab_command_sends_the_chosen_release_without_searching() {
let (_dir, database) = database_with_wanted_movie().await;
@@ -282,8 +274,8 @@ mod tests {
))
.mount(&indexer)
.await;
let transmission = transmission().await;
let grab = grab_action(&indexer, &transmission);
let qbit = qbit().await;
let grab = grab_action(&indexer, &qbit);
let release_id = sqlx::query(
"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).
use std::collections::HashSet;
use arr_db::Db;
use arr_dl::TransmissionClient;
use arr_dl::QbitClient;
use crate::grab::label_for_root;
use crate::reconcile::{Action, ActionFuture, Outcome};
#[derive(Debug)]
pub struct ReaperAction {
transmission: TransmissionClient,
qbit: QbitClient,
}
impl ReaperAction {
#[must_use]
pub fn new(transmission: TransmissionClient) -> Self {
Self { transmission }
pub fn new(qbit: QbitClient) -> Self {
Self { qbit }
}
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();
for torrent in torrents {
if !torrent.is_finished || !torrent.labels.iter().any(|label| labels.contains(label)) {
continue;
}
self.transmission.remove_torrent(torrent.id, true).await?;
self.qbit.remove_torrent(&torrent.hash, true).await?;
outcomes.push(Outcome::new(
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)
@@ -64,85 +64,114 @@ mod tests {
use std::sync::{Arc, Mutex};
use serde_json::{json, Value};
use wiremock::matchers::any;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
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)]
struct Transmission {
torrents: Value,
removed: Arc<Mutex<Vec<Value>>>,
}
struct Deletions(Arc<Mutex<Vec<String>>>);
impl Respond for Transmission {
impl Respond for Deletions {
fn respond(&self, request: &Request) -> ResponseTemplate {
let body: Value = serde_json::from_slice(&request.body).unwrap();
let arguments = match body["method"].as_str().unwrap() {
"torrent-get" => json!({"torrents": self.torrents}),
"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}))
let body = String::from_utf8_lossy(&request.body).into_owned();
self.0.lock().unwrap().push(body);
ResponseTemplate::new(200).set_body_string("")
}
}
/// 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 {
json!({
"id": id, "name": format!("torrent-{id}"), "hashString": format!("hash-{id}"),
"status": if finished { 0 } else { 6 }, "percentDone": 1.0,
"downloadDir": "/downloads", "labels": [label], "isFinished": finished
"hash": format!("hash-{id}"), "name": format!("torrent-{id}"),
"state": if finished { "stoppedUP" } else { "uploading" },
"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]
async fn removes_only_finished_arr_torrents_with_data() {
let server = MockServer::start().await;
let removed = Arc::new(Mutex::new(Vec::new()));
Mock::given(any())
.respond_with(Transmission {
torrents: json!([
torrent(1, "movies-main", false),
torrent(2, "movies-main", true),
torrent(3, "radarr", true)
]),
removed: Arc::clone(&removed),
})
.mount(&server)
.await;
let action = ReaperAction::new(TransmissionClient::new(&server.uri()).unwrap());
let deleted = qbit(
&server,
json!([
torrent(1, "movies-main", false),
torrent(2, "movies-main", true),
torrent(3, "radarr", true)
]),
)
.await;
let action = ReaperAction::new(QbitClient::new(&server.uri()).unwrap());
let labels = HashSet::from(["movies-main".to_owned(), "movies-kids".to_owned()]);
let outcomes = action.tick(&labels).await.unwrap();
assert_eq!(outcomes.len(), 1);
assert_eq!(
*removed.lock().unwrap(),
[json!({"ids": [2], "delete-local-data": true})]
let deleted = deleted.lock().unwrap();
assert_eq!(deleted.len(), 1);
assert!(
deleted[0].contains("hashes=hash-2"),
"asked: {}",
deleted[0]
);
assert!(
deleted[0].contains("deleteFiles=true"),
"asked: {}",
deleted[0]
);
}
#[tokio::test]
async fn finished_torrent_does_not_need_a_grab_or_import_row() {
let server = MockServer::start().await;
let removed = Arc::new(Mutex::new(Vec::new()));
Mock::given(any())
.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 deleted = qbit(&server, json!([torrent(9, "movies-kids", true)])).await;
let action = ReaperAction::new(QbitClient::new(&server.uri()).unwrap());
let labels = HashSet::from(["movies-kids".to_owned()]);
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::*;
#[derive(Clone, Debug, Default)]
struct FakeTransmission {
struct FakeQbit {
torrents: Arc<Mutex<HashMap<i64, String>>>,
}
impl FakeTransmission {
impl FakeQbit {
async fn find_or_add(&self, movie_id: i64) -> (String, bool) {
let mut torrents = self.torrents.lock().await;
if let Some(hash) = torrents.get(&movie_id) {
@@ -310,7 +310,7 @@ mod tests {
#[derive(Debug)]
struct FakeGrabAction {
transmission: FakeTransmission,
qbit: FakeQbit,
release_id: i64,
fail_after_add: bool,
}
@@ -356,11 +356,11 @@ mod tests {
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 {
return Err(Box::new(io::Error::new(
io::ErrorKind::Interrupted,
"simulated process death after Transmission accepted the torrent",
"simulated process death after qBittorrent accepted the torrent",
)) as ActionError);
}
@@ -448,25 +448,25 @@ mod tests {
#[tokio::test]
async fn restart_converges_without_duplicate_external_work() {
let (_directory, database, release_id) = seeded_database().await;
let transmission = FakeTransmission::default();
let qbit = FakeQbit::default();
let interrupted = ReconcileLoop::new(database.clone()).register(
Tick::Reconcile,
FakeGrabAction {
transmission: transmission.clone(),
qbit: qbit.clone(),
release_id,
fail_after_add: true,
},
);
let first = interrupted.run_tick(Tick::Reconcile).await;
assert_eq!(first.failures, 1);
assert_eq!(transmission.len().await, 1);
assert_eq!(qbit.len().await, 1);
drop(interrupted);
let restarted = ReconcileLoop::new(database.clone()).register(
Tick::Reconcile,
FakeGrabAction {
transmission: transmission.clone(),
qbit: qbit.clone(),
release_id,
fail_after_add: false,
},
@@ -476,7 +476,7 @@ mod tests {
assert_eq!(recovered.actions_taken, 1);
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")
.fetch_one(database.pool())
.await
+22 -62
View File
@@ -28,7 +28,7 @@ use arr_core::matching::{
};
use arr_core::{EpisodeId, Language, MovieId};
use arr_db::{Blacklist, Db, MoviePolicy, TitlePolicy};
use arr_dl::TransmissionClient;
use arr_dl::QbitClient;
use arr_indexer::{ProwlarrClient, SearchRelease, SearchRequest};
use crate::grab::{
@@ -50,13 +50,13 @@ impl RssAction {
#[must_use]
pub fn new(
prowlarr: ProwlarrClient,
transmission: TransmissionClient,
qbit: QbitClient,
download_dir: PathBuf,
seeding: SeedingRules,
) -> Self {
Self {
indexers: IndexerDirectory::new(prowlarr.clone()),
grabber: Grabber::new(prowlarr.clone(), transmission, download_dir, seeding),
grabber: Grabber::new(prowlarr.clone(), qbit, download_dir, seeding),
prowlarr,
}
}
@@ -714,15 +714,15 @@ async fn load_grabbable(
#[cfg(test)]
#[allow(clippy::unwrap_used)]
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::{Mock, MockServer, Request, Respond, ResponseTemplate};
use wiremock::{Mock, MockServer, ResponseTemplate};
use super::*;
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
/// 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];
/// 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
/// feed to an empty query.
async fn prowlarr(feed: &str) -> MockServer {
@@ -842,14 +808,8 @@ mod tests {
server
}
async fn transmission() -> (MockServer, FakeTransmission) {
let server = MockServer::start().await;
let fake = FakeTransmission::default();
Mock::given(method("POST"))
.respond_with(fake.clone())
.mount(&server)
.await;
(server, fake)
async fn qbit() -> (MockServer, FakeQbit) {
FakeQbit::start().await
}
/// `(tmdb_id, title, year, blocked)`.
@@ -874,11 +834,11 @@ mod tests {
(dir, database)
}
fn action(prowlarr: &MockServer, transmission: &MockServer) -> RssAction {
fn action(prowlarr: &MockServer, qbit: &MockServer) -> RssAction {
RssAction::new(
ProwlarrClient::new(prowlarr.uri(), "key").unwrap(),
TransmissionClient::new(&transmission.uri()).unwrap(),
PathBuf::from("/mnt/media/transmission/complete"),
QbitClient::new(&qbit.uri()).unwrap(),
PathBuf::from("/mnt/media/qbittorrent/complete"),
SeedingRules::new(
SeedingLimits {
ratio: 1.0,
@@ -975,7 +935,7 @@ mod tests {
])
.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();
@@ -1019,7 +979,7 @@ mod tests {
async fn a_near_miss_is_not_grabbed() {
let (_dir, database) = wanted(&[(9_999_999, "Dune: Part Three", 2024, false)]).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();
@@ -1038,7 +998,7 @@ mod tests {
])
.await;
let indexer = prowlarr(FEED).await;
let (downloader, _fake) = transmission().await;
let (downloader, _fake) = qbit().await;
action(&indexer, &downloader).tick(&database).await.unwrap();
@@ -1055,7 +1015,7 @@ mod tests {
async fn a_blocked_title_still_matches_rss() {
let (_dir, database) = wanted(&[(693_134, "Dune: Part Two", 2024, true)]).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();
@@ -1073,7 +1033,7 @@ mod tests {
])
.await;
let indexer = prowlarr(FEED).await;
let (downloader, _fake) = transmission().await;
let (downloader, _fake) = qbit().await;
action(&indexer, &downloader).tick(&database).await.unwrap();
@@ -1103,7 +1063,7 @@ mod tests {
let (_dir, database) = wanted(&[]).await;
let (_season_id, episodes) = wanted_series(&database, &["2024-04-11", "2099-01-01"]).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();
@@ -1149,7 +1109,7 @@ mod tests {
"Unrelated.S01E03.2160p.WEB-DL-GROUP</title>",
);
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();
@@ -1197,7 +1157,7 @@ mod tests {
.await
.unwrap();
let indexer = prowlarr(TV_FEED).await;
let (downloader, _fake) = transmission().await;
let (downloader, _fake) = qbit().await;
action(&indexer, &downloader).tick(&database).await.unwrap();
@@ -1237,7 +1197,7 @@ mod tests {
.await
.unwrap();
let indexer = prowlarr(TV_FEED).await;
let (downloader, _fake) = transmission().await;
let (downloader, _fake) = qbit().await;
action(&indexer, &downloader).tick(&database).await.unwrap();
@@ -1269,7 +1229,7 @@ mod tests {
.await
.unwrap();
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();
+31 -110
View File
@@ -16,7 +16,7 @@ use std::path::PathBuf;
use arr_core::grabbing::{season_grab_mode, SeasonGrabFacts, SeasonGrabMode};
use arr_core::Language;
use arr_db::{Blacklist, Db, TitlePolicy};
use arr_dl::TransmissionClient;
use arr_dl::QbitClient;
use arr_indexer::{ProwlarrClient, SearchRelease, TvSelector, TvTarget};
use arr_parse::EpisodeClaim;
@@ -47,13 +47,13 @@ impl TvGrabAction {
#[must_use]
pub fn new(
prowlarr: ProwlarrClient,
transmission: TransmissionClient,
qbit: QbitClient,
download_dir: PathBuf,
seeding: SeedingRules,
) -> Self {
Self {
indexers: IndexerDirectory::new(prowlarr.clone()),
grabber: Grabber::new(prowlarr.clone(), transmission, download_dir, seeding),
grabber: Grabber::new(prowlarr.clone(), qbit, download_dir, seeding),
prowlarr,
}
}
@@ -544,7 +544,7 @@ impl TvGrabAction {
/// 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
/// 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.
pub(crate) async fn grab_episode_release_now(
&self,
@@ -645,7 +645,7 @@ impl TvGrabAction {
}
/// 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(
&self,
database: &Db,
@@ -1223,89 +1223,16 @@ fn air_date_time(value: Option<&str>) -> Option<std::time::SystemTime> {
#[allow(clippy::unwrap_used)]
mod tests {
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::{Mock, MockServer, Request, Respond, ResponseTemplate};
use wiremock::{Mock, MockServer, ResponseTemplate};
use crate::grab::{test_downloads, SeedingLimits};
use crate::qbit_fake::FakeQbit;
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
/// TV main policy (§5.5 bands: 2160p floor 3 GiB, per #95).
const TV_RSS: &str = r#"<rss><channel>
@@ -1364,14 +1291,8 @@ mod tests {
server
}
async fn transmission() -> (MockServer, FakeTransmission) {
let server = MockServer::start().await;
let fake = FakeTransmission::default();
Mock::given(method("POST"))
.respond_with(fake.clone())
.mount(&server)
.await;
(server, fake)
async fn qbit() -> (MockServer, FakeQbit) {
FakeQbit::start().await
}
/// Fallout S01 with three wanted episodes at the given air dates.
@@ -1409,11 +1330,11 @@ mod tests {
(dir, database, season_id)
}
fn action(prowlarr: &MockServer, transmission: &MockServer) -> TvGrabAction {
fn action(prowlarr: &MockServer, qbit: &MockServer) -> TvGrabAction {
TvGrabAction::new(
ProwlarrClient::new(prowlarr.uri(), "key").unwrap(),
TransmissionClient::new(&transmission.uri()).unwrap(),
PathBuf::from("/mnt/media/transmission/complete"),
QbitClient::new(&qbit.uri()).unwrap(),
PathBuf::from("/mnt/media/qbittorrent/complete"),
SeedingRules::new(
SeedingLimits {
ratio: 1.5,
@@ -1448,7 +1369,7 @@ mod tests {
let (_dir, database, season_id) =
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
let (downloader, fake) = qbit().await;
let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap();
@@ -1473,7 +1394,7 @@ mod tests {
let (_dir, database, _season_id) =
wanted_season(&["2024-04-11", "2024-04-18", "2999-01-01"]).await;
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
let (downloader, fake) = qbit().await;
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;
failed_packs(&database, season_id, &["-10 minutes"]).await;
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
let (downloader, fake) = qbit().await;
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;
failed_packs(&database, season_id, &["-2 hours"]).await;
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
let (downloader, fake) = qbit().await;
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;
stalled_failed_packs(&database, season_id, &[("-35 days", "-10 minutes")]).await;
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
let (downloader, fake) = qbit().await;
action(&indexer, &downloader).tick(&database).await.unwrap();
@@ -1631,7 +1552,7 @@ mod tests {
)
.await;
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
let (downloader, fake) = qbit().await;
action(&indexer, &downloader).tick(&inside.1).await.unwrap();
assert!(
fake.torrents()
@@ -1647,7 +1568,7 @@ mod tests {
&["-30 days", "-25 days", "-20 days", "-12 days", "-8 days"],
)
.await;
let (downloader, fake) = transmission().await;
let (downloader, fake) = qbit().await;
action(&indexer, &downloader)
.tick(&elapsed.1)
.await
@@ -1664,7 +1585,7 @@ mod tests {
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
failed_packs(&database, season_id, &["-10 minutes"]).await;
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
let (downloader, fake) = qbit().await;
action(&indexer, &downloader)
.search_season_now(&database, season_id)
@@ -1701,7 +1622,7 @@ mod tests {
.await
.unwrap();
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
let (downloader, fake) = qbit().await;
action(&indexer, &downloader).tick(&database).await.unwrap();
@@ -1724,7 +1645,7 @@ mod tests {
let (_dir, database, season_id) =
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
let (downloader, fake) = qbit().await;
let stamp = |database: Db| async move {
sqlx::query_scalar::<_, Option<String>>(
@@ -1788,7 +1709,7 @@ mod tests {
)
.mount(&indexer)
.await;
let (downloader, fake) = transmission().await;
let (downloader, fake) = qbit().await;
let action = action(&indexer, &downloader);
action.tick(&database).await.unwrap();
@@ -1823,7 +1744,7 @@ mod tests {
/// be accepted onto the channel. `search_attempts` starts at 5 (a 7-day
/// 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
/// Transmission proves the grab did.
/// qBittorrent proves the grab did.
#[tokio::test]
async fn a_manual_episode_search_searches_and_grabs_now() {
let (_dir, database, _season_id) =
@@ -1841,7 +1762,7 @@ mod tests {
.await
.unwrap();
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
let (downloader, fake) = qbit().await;
action(&indexer, &downloader)
.search_episode_now(&database, episode_id)
@@ -1871,7 +1792,7 @@ mod tests {
}
/// 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]
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;
@@ -1880,7 +1801,7 @@ mod tests {
.await
.unwrap();
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
let (downloader, fake) = qbit().await;
let release_id: i64 = sqlx::query_scalar(
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, score, verdict)
VALUES (7, 'chosen', 'Fallout.S01E01.2160p.WEB-DL.DDP5.1', 10737418240,
@@ -1936,7 +1857,7 @@ mod tests {
.await
.unwrap();
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
let (downloader, fake) = qbit().await;
action(&indexer, &downloader)
.search_season_now(&database, season_id)
@@ -1973,13 +1894,13 @@ mod tests {
}
/// 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]
async fn a_manual_season_grab_sends_the_chosen_pack_without_searching() {
let (_dir, database, season_id) =
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
let (downloader, fake) = qbit().await;
let release_id: i64 = sqlx::query_scalar(
"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,
+2 -2
View File
@@ -81,7 +81,7 @@ impl Blacklist {
}
/// 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]
pub fn blocks_infohash(&self, infohash: &str) -> bool {
self.infohashes.contains_key(&infohash.to_ascii_lowercase())
@@ -176,7 +176,7 @@ pub async fn add(
/// The infohash a magnet link declares, lowercased.
///
/// 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.
#[must_use]
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 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,
/// so they are returned together.
#[derive(Debug, Clone, PartialEq, Eq)]
+1 -1
View File
@@ -7,10 +7,10 @@ repository.workspace = true
publish = false
[dependencies]
base64 = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sha1 = { workspace = true }
thiserror = { workspace = true }
tokio = { 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.
//!
//! 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
//! 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`].
pub const TMDB_MOVIE_ID: u32 = 693_134;
/// The Transmission RPC endpoint tests should use: `TRANSMISSION_RPC_URL`
/// when set (CI points it at the service container), a local container's
/// default port otherwise. Never the production LXC.
/// The qBittorrent `WebUI` base URL tests should use: `QBITTORRENT_URL` when
/// set (CI points it at the service container), a local container's default
/// port otherwise. Never the production instance.
#[must_use]
pub fn transmission_url() -> String {
std::env::var("TRANSMISSION_RPC_URL")
.unwrap_or_else(|_| "http://127.0.0.1:9091/transmission/rpc".into())
pub fn qbittorrent_url() -> String {
std::env::var("QBITTORRENT_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".into())
}
/// 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
/// endpoint does not answer within the boot timeout.
pub async fn spawn(prowlarr_url: &str, tmdb_url: &str, transmission_url: &str) -> Self {
Self::spawn_with_env(prowlarr_url, tmdb_url, transmission_url, &[]).await
pub async fn spawn(prowlarr_url: &str, tmdb_url: &str, qbittorrent_url: &str) -> Self {
Self::spawn_with_env(prowlarr_url, tmdb_url, qbittorrent_url, &[]).await
}
/// Same as [`Self::spawn`], plus extra environment variables for the
@@ -212,7 +211,7 @@ impl Daemon {
pub async fn spawn_with_env(
prowlarr_url: &str,
tmdb_url: &str,
transmission_url: &str,
qbittorrent_url: &str,
extra_env: &[(&str, &str)],
) -> Self {
let dir = tempfile::tempdir().expect("create daemon tempdir");
@@ -232,7 +231,7 @@ impl Daemon {
.env("ARR_PROWLARR_API_KEY", API_KEY)
.env("ARR_TMDB_URL", tmdb_url)
.env("ARR_TMDB_API_KEY", API_KEY)
.env("ARR_TRANSMISSION_URL", transmission_url)
.env("ARR_QBITTORRENT_URL", qbittorrent_url)
.stdin(Stdio::null());
for (key, value) in extra_env {
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
//! `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.
//!
//! 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 arr_dl::{AddTorrent, TorrentSource, TransmissionClient};
use arr_dl::{AddTorrent, QbitClient, TorrentSource};
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_meta::TmdbClient;
use chrono::NaiveDate;
/// 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.
#[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 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 health = daemon.health().await;
assert_eq!(health["status"], "ok", "health report: {health}");
assert_eq!(health["prowlarr"]["status"], "ok");
assert_eq!(health["transmission"]["status"], "ok");
assert_eq!(health["qbit"]["status"], "ok");
assert_eq!(health["tmdb"]["status"], "ok");
// 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));
}
/// Torrent lifecycle against the real Transmission container: RPC semantics
/// are the seam most likely to surprise (DESIGN.md §12).
/// Torrent lifecycle against the real qBittorrent container: the `WebUI` API's
/// 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]
async fn transmission_add_list_and_remove() {
let client = TransmissionClient::new(&transmission_url()).expect("valid endpoint");
async fn qbittorrent_add_list_and_remove() {
let client = QbitClient::new(&qbittorrent_url()).expect("valid endpoint");
let name = format!("arr-e2e-{}", uuid::Uuid::new_v4());
let metainfo = torrent_with_name(&name);
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 listed = torrents
.iter()
.find(|torrent| torrent.id == first.id)
.find(|torrent| torrent.hash == first.hash)
.expect("added torrent is authoritative in list");
assert_eq!(listed.name, name);
assert_eq!(listed.hash, first.hash);
assert_eq!(listed.download_dir, download_dir);
assert_eq!(listed.labels, ["movies-main"]);
assert!((0.0..=1.0).contains(&listed.progress));
client
.remove_torrent(first.id, false)
.remove_torrent(&first.hash, false)
.await
.expect("remove without data");
let second = client.add_torrent(request()).await.expect("add again");
assert_eq!(
second.hash, first.hash,
"the same file is the same infohash"
);
client
.remove_torrent(second.id, true)
.remove_torrent(&second.hash, true)
.await
.expect("remove with data");
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.
#[tokio::test]
async fn transmission_reports_done_only_after_its_seed_limit() {
let client = TransmissionClient::new(&transmission_url()).expect("valid endpoint");
async fn qbittorrent_reports_done_only_after_its_seed_limit() {
let client = QbitClient::new(&qbittorrent_url()).expect("valid endpoint");
let name = format!("arr-e2e-reaper-{}", uuid::Uuid::new_v4());
let download_dir = PathBuf::from("/tmp/arr-e2e");
let metainfo = torrent_with_name(&name);
@@ -204,7 +209,7 @@ async fn transmission_reports_done_only_after_its_seed_limit() {
.await
.expect("list before limit")
.into_iter()
.find(|torrent| torrent.id == added.id)
.find(|torrent| torrent.hash == added.hash)
.expect("torrent before limit");
assert!(
!before.is_finished,
@@ -212,7 +217,7 @@ async fn transmission_reports_done_only_after_its_seed_limit() {
);
client
.remove_torrent(added.id, true)
.remove_torrent(&added.hash, true)
.await
.expect("cleanup");
}
+1 -1
View File
@@ -164,7 +164,7 @@ impl ProwlarrClient {
/// Resolves an indexer download link into something the download client
/// 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
/// 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
+5 -5
View File
@@ -9,7 +9,7 @@
</head>
<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
the admin-dashboard rut: no sidebar, no stat cards, no hero metric.
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.
FIRST VIEWPORT: a top rail (wordmark + master lamp + version),
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.
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
@@ -453,14 +453,14 @@
</svg>
</div>
<article class="module area-transmission" data-check="transmission">
<article class="module area-qbittorrent" data-check="qbit">
<header class="module-head">
<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>
</header>
<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>
</section>
+1 -1
View File
@@ -17,7 +17,7 @@ export interface Download {
}
/** §9.8: the live snapshot, degrading to nothing rather than breaking the
* page it decorates — Transmission or the daemon being down is a fact the
* page it decorates — qBittorrent or the daemon being down is a fact the
* signal chain already reports, not something this poll repeats. */
export async function fetchDownloads(): Promise<Download[]> {
try {
+1 -1
View File
@@ -28,7 +28,7 @@ export interface HealthReport {
status: "ok" | "degraded";
version: string;
prowlarr: Check;
transmission: Check;
qbit: Check;
tmdb: Check;
subtitles: SubtitleHealth;
}
+4 -4
View File
@@ -271,7 +271,7 @@ function main() {
const checks = {
tmdb: moduleRefs("tmdb"),
prowlarr: moduleRefs("prowlarr"),
transmission: moduleRefs("transmission"),
qbit: moduleRefs("qbit"),
} as const;
const traces = {
@@ -293,7 +293,7 @@ function main() {
}
// stagger order for the one power-up animation, upstream to downstream
const strikeOrder = [checks.tmdb, checks.prowlarr, master, checks.transmission];
const strikeOrder = [checks.tmdb, checks.prowlarr, master, checks.qbit];
strikeOrder.forEach((refs, index) => {
refs.lamp.style.setProperty("--strike", String(index));
});
@@ -331,7 +331,7 @@ function main() {
setModule(master, degraded ? "degraded" : "ok", degraded ? "warn" : "ok", report.status);
setTrace("master", degraded ? "warn" : "ok");
for (const name of ["tmdb", "prowlarr", "transmission"] as const) {
for (const name of ["tmdb", "prowlarr", "qbit"] as const) {
const check = report[name];
setModule(
checks[name],
@@ -340,7 +340,7 @@ function main() {
check.status,
check.detail,
);
if (name !== "transmission") {
if (name !== "qbit") {
setTrace(name, TONE_BY_STATUS[check.status]);
}
}
+1 -1
View File
@@ -129,7 +129,7 @@ export interface SeasonPackState {
* the release is blacklisted and every episode it covered reopens as a gap.
* Nothing on screen joined those facts, so the season read `0/10` as though
* no grab had ever been tried and the operator found out by opening
* Transmission.
* qBittorrent.
*/
export interface ImportFailure {
release: string;
+1 -1
View File
@@ -308,7 +308,7 @@ body {
grid-area: out;
}
.area-transmission {
.area-qbittorrent {
grid-area: sink;
}