feat(dl): replace Transmission with qBittorrent
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:
+26
-10
@@ -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
|
||||
|
||||
+2
-2
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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"] }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -47,7 +47,6 @@ tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
base64 = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
wiremock = { workspace = true }
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
¬ifier,
|
||||
&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(),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(§ion[..header_end]);
|
||||
let Some(name) = headers
|
||||
.split("name=\"")
|
||||
.nth(1)
|
||||
.and_then(|rest| rest.split('"').next())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let body = §ion[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)
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+10
-11
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -28,7 +28,7 @@ export interface HealthReport {
|
||||
status: "ok" | "degraded";
|
||||
version: string;
|
||||
prowlarr: Check;
|
||||
transmission: Check;
|
||||
qbit: Check;
|
||||
tmdb: Check;
|
||||
subtitles: SubtitleHealth;
|
||||
}
|
||||
|
||||
+4
-4
@@ -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
@@ -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
@@ -308,7 +308,7 @@ body {
|
||||
grid-area: out;
|
||||
}
|
||||
|
||||
.area-transmission {
|
||||
.area-qbittorrent {
|
||||
grid-area: sink;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user