Files
felhom.eu/documentation/architecture/15-media-library.md
T

254 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 15 — The media library: media apps on every drive
> **What this is:** how the media apps (download → library → player) share the household's drives — the folder
> layout, the links between the apps, what happens when a drive comes or goes, and what backup, removal and export must
> learn. **No document covered this before 2026-10-14** — itself a finding: the four templates were written one by one
> and never tested together (R-1014). Rulings: `09` §3 decisions 237–241. Measured on Tester 1:
> `audits/media-all-drives-2026-10-14/`. Marks as in `01`: **[DESIGN]** a decision or proposal; **[FACT]** read in a
> named file or measured. **Status: BUILT (controller v0.313.0, 2026-10-15)** — §2–§10 are built and proven on Tester 1 and demo-hp 9201 (§11, with the evidence per part); delivery to the fleet is golden 0.313.0 → the operator's vouch → the floor (`09` §3 decision 247). Plex's real claim on the running server is proven by hand (K6-2); through the dashboard it waits on a household code (§11).
## 1. The problem
**[FACT]** Live catalog (`main` 8ee687e): qBittorrent saves to `${USERDATA_PATH}/media/torrents`, Transmission to
`media/transmission`; Sonarr and Radarr look in `${USERDATA_PATH}/downloads`. A download never reaches a library.
**[FACT]** Even with matching folders, Sonarr/Radarr bind `media/tv` (or `movies`) and `downloads` as TWO bind mounts:
`link(2)` answers EXDEV across two mount points on one filesystem, so every import is a full copy (S1:
`ln: … Cross-device link`; two inodes). **[FACT]** Each app binds ONE drive (`HDD_PATH`, locked after deploy,
`stacks/deploy.go` `withPathVars`); only FileBrowser follows every drive (`web/handlers.go` `buildFileBrowserPaths`).
## 2. The layout [DESIGN, measured on Tester 1]
Each media app binds, **per enrolled drive**, that drive's `userdata/media` at **the same path inside the container as
in the guest**:
```
/mnt/felhom-drives/<drive>/userdata/media → /mnt/felhom-drives/<drive>/userdata/media (rw; :ro for players)
media/tv/<Show>/… Sonarr library media/torrents/{tv,movies}/… qBittorrent
media/movies/<Film>/… Radarr library media/transmission/{tv,movies}/… Transmission
```
The four reasons:
1. **One bind per drive keeps download and library on one mount point** — the import is a hardlink: one file, two
names, no extra space. **[FACT]** S2: Transmission and qBittorrent, drive B → link count 2, same inode; S3: drive A
the same.
2. **The same path everywhere** — no app translates another app's path (no „remote path mapping"), and moving an
installed app from one drive to many later ADDS binds without rewriting the paths Sonarr and Radarr have stored.
3. **`media/` only** — never `felhom-data`, never the whole `userdata` (documents, roms and the app's own data stay
out).
4. **Today's host folders** (`media/torrents`, `media/transmission`) — an installed download app keeps its files.
**[FACT, and a correction of the brief]** `media/` is not photo-free: the skeleton puts `photos`, `books`,
`audiobooks`, `music`, `comics`, `podcasts` there too (`appbackup/userdata.go` + the catalog-derived set), and Immich,
calibre-web, audiobookshelf and navidrome use them. With reason 1, a download app needs ONE mount that holds both its
download folder and the libraries — so binding `media/tv` and `media/torrents` separately brings EXDEV back.
**RULED 2026-10-14 (`09` §3 decision 242, D9 = A with a limit) — this table replaces every earlier shape:**
| App | Bind (rw unless noted), the SAME path inside the container as on the box | Sees |
|---|---|---|
| Sonarr, Radarr | `${USERDATA_PATH}/media` | the whole `media` folder — it must: download and library on ONE mount (S1) |
| qBittorrent | `${USERDATA_PATH}/media/torrents` (+ the same folder as `/downloads`, for torrents added before 2026-10-14) | only its download folder |
| Transmission | `${USERDATA_PATH}/media/transmission` (+ `/downloads` likewise; `/watch` = `media/transmission/watch`) | only its download folder |
| Plex, Jellyfin, Emby | unchanged: `${USERDATA_PATH}/media:/media:ro` | they move with the all-drives build |
The hardlink is made by Sonarr/Radarr in their own container, where both folders are one mount; the client reports
`…/media/torrents/<name>` and Sonarr finds exactly that path. The download clients cannot reach `media/photos` (B-3,
measured: the path does not exist in their containers). Sonarr/Radarr's bind is declared `excluded` in their backup
block — unlisted it would be MANDATORY, every tier, off-site included (B-1, measured with the production classifier).
Built in the live catalog 2026-10-14 (`audits/media-folder-fix-2026-10-14/`); the all-drives build adds one such bind
per drive at the same paths, so nothing installed now is redone.
## 3. Which apps are „media apps" [DESIGN, proposal]
Sonarr, Radarr, qBittorrent, Transmission (download/library, rw); Plex, Jellyfin, Emby (players, `:ro`). Others
(Lidarr, Readarr, SABnzbd, NZBGet, navidrome, audiobookshelf…) later, each with its own rule. A media app carries a
marker in `.felhom.yml` (e.g. `media_library: rw|ro`); the controller builds its per-drive binds — templates never
spell drive names (the drill used plain host paths only to stay out of the classifier, §7).
## 4. The drive-absent gate and the restart notice
**[FACT]** FileBrowser's pattern is the one to extend: bind every drive's `userdata/`, skip a drive that is not a live
mountpoint, recreate the container when the set changes. **[FACT]** The app gate (`web/intermediary.go`
`planDriveGates`/`appsOnStoragePath`) stops and restarts only apps whose `HDD_PATH` equals the absent drive; at boot it
waits for THAT drive's bind only (seen after the 17:25 power cut: the four media apps were recreated onto
`media_a` with no check of `media_b`). A media app depends on EVERY drive it binds.
**[FACT] What the dashboard says when a drive is added (A5):** the drive card „Aktív", „Nincs alkalmazás ezen a
tárolón" — nothing about apps that need a restart. **[FACT] Docker binds are resolved at container START**: a running
container does not see a drive added later (guest propagation is `shared,slave`, the container's bind is `rprivate`).
**[FACT] Today's „restart" restarts nothing** (R-1026): `RestartStack` = `compose up -d`, a no-op when the compose did
not change; it logged success in 0.1 s while the containers kept their start time. Stop + start works. The notice of
decisions 238–239 needs a real restart (`up -d --force-recreate` once the per-drive binds are in the compose).
**[DESIGN]** The restart notice (decision 238) fires on: a drive added, a drive removed, and a drive that came back
(S6 and K1-c/K5 measured it: after a return nothing sees the drive until restarted; §10 has the per-app actions). Each drive card carries „apps restarted since this drive was added: yes/no" with the button
(decision 239).
## 5. A drive is pulled / comes back [FACT, measured on Tester 1 2026-10-14, `audits/media-all-drives-2026-10-14/S5-S6/`]
**S5 — Media B's disk detached at 20:10:23 while six media apps ran** (Sonarr, Radarr, qBittorrent, Transmission,
Plex, Jellyfin; all with `HDD_PATH` on Media A):
- **The host** saw the disk go in 2 s and unmounted its own mount, but the **guest kept a dead ext4 mount** of
`/mnt/felhom-drives/media_b`: `mountpoint` = yes, every read = Input/output error (not an empty folder — so nothing can
be written onto the guest's own disk there, which the brief feared).
- **The controller** marked the drive disconnected at 20:10:47, **stopped 0 apps** (none keys on Media B), pushed
`storage_disconnected` (error); the hub got it the same second and mailed the operator; `health_degraded` followed at
20:13. It also tried to rebuild the skeleton ON the dead mount („chmod … input/output error") — the gate asks „is it a
mountpoint?", and a dead mount says yes (R-1038).
- **The dashboard**: the card reads „Media B — Leválasztva 2026. 10. 14. 20:10" with „Csatlakoztatás"; it does not say
that six apps use the drive. No banner on the start page.
- **The apps** did not crash; all read I/O errors on B. Sonarr/Radarr mark root folder B `accessible=false` but their
own health page stays EMPTY. qBittorrent's B torrents sat queued and did not notice.
- **Plex and Jellyfin removed nothing in 30 minutes.** Plex scans only when asked (R-1037); Jellyfin's real-time monitor
got errors, not deletions. A scan during an outage is the risk; with Plex's „empty trash after every scan" on by default
such a scan would drop B's items — not measured, deliberately.
**S6 — the same volume re-attached to the same slot at 20:41:00:** the agent re-mounted it (`sde`) and bound it under
the parent within 24 s, ON TOP of the dead mount (two stacked mounts); the controller logged „drive RETURNED …
restarted gate-stopped apps" (there were none) and `storage_reconnected`. **Every running container still saw the dead
mount**, FileBrowser included („ensured running without recreate"). The dashboard's app „restart" changed nothing
(same start time, still I/O error — R-1026). **Stop + start fixed all six apps at once**; files and all 13 hardlink pairs
intact. FileBrowser is protected — the household cannot stop or start it (403), so it stays broken (R-1038).
**What the restart notice (decisions 238–239) must therefore cover [DESIGN]:** a drive added, a drive removed, **and a
drive that came back** — the last is the one where the household is told nothing today and the apps are silently half
blind. The restart is stop + start (or `up -d --force-recreate`) of every container that binds the drive, FileBrowser
included, and the box — not Sonarr — says that a root folder is unreachable.
## 6. The links [FACT, measured calls in `audits/media-all-drives-2026-10-14/tools/wire.py`]
Per drive: a qBittorrent category `tv-<d>`/`movies-<d>` (save path on that drive, incomplete path on that drive); a
Sonarr/Radarr root folder, a tag, and two client entries („qBittorrent <d>" by category, „Transmission <d>" by
directory) with that tag. Facts the link code needs:
- **The API key**: read from `/config/config.xml` — or SET at install with `SONARR__AUTH__APIKEY` /
`RADARR__AUTH__APIKEY` (measured: 200 with that key, 401 with another). Setting it is the cleaner design.
- **qBittorrent must run in automatic torrent management** (`auto_tmm_enabled`), or a category's save path is ignored
and every torrent lands on the default drive.
- **A Transmission entry must clear the default category** — Sonarr/Radarr refuse „Cannot use Category and Directory".
- **Transmission has ONE incomplete folder** for all torrents; it must be off, or every drive-B download is a cross-drive
copy at completion.
- **Transmission answers by its container name** (`transmission:9091`) despite its Host allow-list (measured).
- **A show with no tag gets no download app** when every entry carries a tag. The UI must make the drive choice set
both the root folder AND the tag; S3: a mismatch (root B, tag A) works but makes a full copy on B while A seeds.
- **The OnlyOffice revoke-on-stop rule must NOT apply** (`integrations/lifecycle.go:10-13`): it would delete the download
entries whenever a download app restarts. And the OnlyOffice links themselves have no live proof since Proxmox
(R-1018).
- **Prowlarr holds the indexers ONCE** and pushes them (Settings → Apps, „Full Sync”) to Sonarr and Radarr by container
name (`http://sonarr:8989`, `http://radarr:7878`, `prowlarrUrl http://prowlarr:9696`); both entries pass Prowlarr's own
connection test; the sync command completes (measured with zero indexers — the push of a real one waits for the
household's own). It needs no drive and no media folder. FlareSolverr only for an indexer that asks for it.
- **Call the apps with `Host: <container name>`**: once the household sets „Allowed hosts”, Sonarr/Radarr answer a bare
IP with 400 „Invalid Hostname” (measured).
- **A setting made through Transmission's RPC is lost on a power cut** (it writes `settings.json` only on a clean exit):
`incomplete-dir-enabled=false` was back to `true` after the 17:25 cut. The link code writes it into `settings.json`
at start (as the template's entrypoint does for port forwarding), not by RPC alone.
- **Sign-in (R-1030):** with „Disabled for Local Addresses” an outside visitor IS asked (the tunnel keeps the public
address in `X-Forwarded-For`), but the home network and every other container get in with no sign-in, the API key
included. The box seeds „Enabled”.
- Plex and Jellyfin are NOT linked: they watch folders. Indexers stay the household's choice.
- **Plex behind the tunnel** needs `ADVERTISE_IP=https://<sub>.<domain>:443/` so plex.tv hands apps a reachable
address (measured: `customConnections` set; public `/identity` 200; anonymous library calls 401).
## 7. Backup classes for a per-drive media bind
**[FACT]** An unlisted writable `${…_PATH}` bind is MANDATORY (every tier, off-site included); a plain host path is in
no capture set (`stacks/classify_binds.go` `classifyRoot`). Measured over the live templates: Transmission's unlisted
`watch` bind goes off-site; the download folders are `optional`, so a second drive gets a nightly copy of every
download (R-1019). **[DESIGN]** The per-drive media bind is declared `excluded` for every media app — the family's
media is no single app's data; whether the library gets its own household-level backup is a separate decision.
## 8. Removal and export assume one drive per app
**[FACT]** Removal with „delete data" deletes every bind under the app's `HDD_PATH`, literal and `:ro` ones included —
removing a reader Plex deleted drive A's whole `userdata/media` (R-1027). **[DESIGN]** A media app's removal never
deletes the shared library; it deletes only its own config.
Call sites that assume ONE `HDD_PATH`/`USERDATA_PATH` per app (controller main acbbf4b):
`stacks/deploy.go` `withPathVars`; `stacks/manager.go:1566`; `stacks/delete.go` `appHDDPath`, `hddPathForRemoval`,
`ExportDataMounts`, `ParseComposeUserdataMounts`; `stacks/kept.go:226`; `stacks/migrate.go` `MigrateApp` (rewrites
`HDD_PATH`); `web/intermediary.go` `appsOnStoragePath`; `web/handlers.go` `appsUsingPath`; `backup/file_legs.go`,
`backup/backup.go:431`, `backup/offbox_capture.go`, `backup/offbox_placement.go`, `backup/offbox_restore.go`;
`report/dr_recipe.go:110`; `appexport/provider.go`.
## 9. From one drive to many
An installed media app keeps its `HDD_PATH` (its config's unit lives there) and gains binds for the other drives at its
next recreate. Because the paths are the same everywhere (§2 reason 2), nothing Sonarr or Radarr stored changes. A
household's existing files under `media/torrents` stay where they are.
## 10. What the box does on each drive event [FACT, measured 2026-10-14 night on Tester 1 + the bench; `09` §3 decision 245 — option A]
Evidence: `audits/media-drive-events-2026-10-14/` (K1–K7). „Idempotent" = a second run changes nothing (K2: run 2 left every entry, folder and library as it
was). „Household" = a step only the household can take.
| event → app | action | measured call | idempotent | household | evidence |
|---|---|---|---|---|---|
| **added** — every media app | the compose gains the drive's bind → `up -d` recreates it (the compose CHANGED, so today's restart works) | controller compose `up -d` | yes | no | K5-1: 1–10 s per app |
| added — FileBrowser | already: `SyncFileBrowserMounts` recreates on a new path | — | yes | no | K2-2 |
| added — the drive's folders | `torrents/{tv,movies}`, `transmission/{tv,movies}` made (the skeleton lacks them for a drill template; Sonarr raises a „remote path mapping" health error without them) | mkdir, owner 1000, 2775 | yes | no | K2-6 |
| added — qBittorrent | categories `tv-<d>`, `movies-<d>`: save path AND incomplete path on that drive; automatic torrent management ON | `createCategory`/`editCategory`, `setPreferences auto_tmm_enabled` | yes (re-writes equal values) | no | K2-6/7 |
| added — Transmission | no per-drive setting; one incomplete folder → OFF, written into `settings.json` at start (an RPC `session-set` was LOST after the 17:25 power cut) | entrypoint `jq` | yes | no | K1 note, wire run 3 |
| added — Sonarr, Radarr | root folder, tag `<d>`, „qBittorrent <d>" (category) + „Transmission <d>" (directory, category CLEARED), min free space | `/api/v3/rootfolder`, `/tag`, `/downloadclient` (Host: <app>) | yes (check before create) | no | K2-6/7 |
| added — Prowlarr | nothing per drive (it feeds Sonarr/Radarr) | — | — | indexers | H |
| added — Plex | the drive's folders added to the EXISTING libraries, a scan, „scan when a change is detected" ON | `PUT /library/sections/<id>?…&location=…` (all locations repeated), `/refresh`, `PUT /:/prefs FSEventLibraryUpdatesEnabled=1` | yes | no — but the token is the server's own `PlexOnlineToken` (a household-account token; 401 without one, K2-5) → R-1046 | K2-6 |
| added — Jellyfin | the drive's folders added, a scan; an API key for the box | `POST /Library/VirtualFolders/Paths`, `/Library/Refresh`, `POST /Auth/Keys?app=felhom` | yes | no IF the box made the first admin itself; else ONE step (an API key) | K2-6 |
| **safe removal** — qBittorrent, Transmission | pause ONLY the torrents saved on that drive | qBit `torrents/stop` by `save_path`; Transmission `torrent-stop` by `downloadDir` | yes | no | K3-2 |
| safe removal — every media app | **recreate WITHOUT the drive's bind BEFORE the unmount**: today's eject detaches the guest's bind and keeps the raw mount, while the running apps still HOLD the filesystem (they saw its files after the „safe" removal) | render binds for present drives; `up -d` | yes | no | K3-3 |
| safe removal — the drive | eject today also DEREGISTERS it; after a re-attach the agent mounts it again, but the household must „Meglévő meghajtó csatolása" (`register-mounted`) | — | — | **yes, today** | K3-5 |
| safe removal — Plex, Jellyfin | nothing (they keep every item, §5) | — | — | no | K1 |
| **pulled** — every app | NOTHING is lost: Plex (autoEmptyTrash on or off), Jellyfin, Sonarr/Radarr refresh + rescan (unmonitor on or off), torrents — the guest keeps a DEAD mount and every read is an I/O error, which every app treats as „unavailable" | — | — | no | K1 |
| pulled — the box | knows which apps hold a dead view WITHOUT disk I/O: the guest's `/proc/self/mountinfo` shows `shutdown` on the drive; each container's `/proc/<pid>/mountinfo` shows the old device + `shutdown` | read `/proc` only | yes | no | K4 |
| pulled — restart an app | still starts while the DEAD mount is there (K1: Jellyfin); FAILS (500, „error while creating mount source path … permission denied") once the path is gone (after an eject) — the app is down until the drive returns | — | — | no | K3-4 |
| **came back** — every media app + FileBrowser | stop+start, or `up -d --force-recreate` WITH the controller's environment (a bare `compose up` lost every `${…}` value: routes `Host(\`.\`)`, an empty password — K5-2); today's restart (`up -d`, compose unchanged) does nothing (R-1026) | `StopStack`+`StartStack`, or `composeExecCustomEnv(…, "up","-d","--force-recreate")` | yes | no | K5-2/3: 2–10 s per app, FileBrowser 5 s |
| came back — qBittorrent, Transmission | nothing: every torrent seeds again, no „missing files", no re-check | — | — | no | K1-c, K3-6 |
| came back — a registered drive | the gate re-binds it by itself; an EJECTED one needs the household's attach | — | — | after an eject | K1-c, K3-5 |
| came back — a playing stream | is CUT by the restart (curl exit 56), Jellyfin answers again 22 s later → offer „later", or check active sessions first | Jellyfin `/Sessions`, Plex `/status/sessions` | — | no | K5-4 |
**Guards the measurements show ARE needed:** (1) never start a media app with a bind to an absent path — render one bind per
PRESENT drive (the FileBrowser pattern); (2) a safe removal recreates the media apps without the drive, then unmounts the
raw device (R-1044); (3) every restart through the controller's environment; (4) a returned drive restarts every container that binds
it, FileBrowser included; (5) Transmission's settings written at start, not by RPC. **Guards NOT needed — do not build
them:** a scan or „empty trash" suppression for Plex/Jellyfin, an unmonitor guard for Sonarr/Radarr, pausing torrents on
a pull — K1 measured no loss in any of them while the dead mount stands. **The one condition behind that:** the guest keeps
a DEAD mount (I/O errors). R-1038's fix must NOT turn a pulled drive into an EMPTY folder that a scan could read as „all
files deleted" — keep the dead mount, or stop the media apps first.
**Old installs (K7):** Sonarr/Radarr from before app-catalog `bc54c01` keep their library at `/media/tv` / `/media/movies`;
after the update the root folder is missing (health „Missing root folder"), records stay. Repair through the apps' own API,
no file moved: add the same-path root folder; `PUT /series/editor` (`/movie/editor`) `rootFolderPath`, `moveFiles:false`;
rescan; delete the old root folder; Radarr's collections keep the old root (a separate setting) and the health page shows the
old warning until its next check. File and inode unchanged.
**Plex without the race (K6):** an unclaimed Plex starts with no code and answers every request from a private address
WITHOUT a token — it must never be routed publicly while unclaimed; `POST /myplex/claim?token=<code>` on the RUNNING server
claims it — PROVEN 2026-10-15 with a real code (`K6-2`): 200, `claimed` 0→1, `PlexOnlineToken` written, and the open door
closed (no token → 401). `ADVERTISE_IP` was not needed for the claim. So the box asks for the code only once the server is up:
no 4-minute race with the pull.
## 11. Built — controller v0.313.0 (2026-10-15) [FACT; evidence `audits/media-build-2026-10-15/`]
| Part | What is built | Where | Proven |
|---|---|---|---|
| §2/§3 the marker | `media_library:` (`folder:`, `read_only:`) on Sonarr, Radarr, qBittorrent, Transmission, Plex, Jellyfin, Emby (catalog `0188430`); one bind per PRESENT drive in the controller-owned `docker-compose.override.yml`, rewritten before every `compose up`; absent, dead (`shutdown`) and safely removed drives never bound; network shares never | `stacks/media_library.go` | G1: every stored path, library item, watch mark and all 69 files identical after the move; G7: controller 0.312.0 ignores the marker (one drive, no override) |
| §4 restart | `up -d --force-recreate` with the stack's env; start times before/after; an error when nothing started | `stacks/manager.go` `RestartStack` | G1/G2/G3: six apps restarted in ~30 s, each start time moved |
| §6 links | qBittorrent categories + automatic management; Transmission incomplete folder off (template entrypoint + belt); Sonarr/Radarr tag, root, clients, minimum free space; Prowlarr → Sonarr/Radarr; Plex folders + scan + „scan on change" with the server's own token read at use (`09` §3 decision 248); Jellyfin folders + scan with the API key named „felhom" read at use from Jellyfin's database (no key → one household step on the page) | `internal/mediawire` | G2: Media D wired into every app in one run; two more runs wrote nothing; G7: live catalog on 9201 |
| §7/§8 backup, export, removal | the override is read by no classifier, export or removal; „delete my data" never deletes a `:ro` or shared-library bind (R-1027); the safety list knows the userdata layout (R-1032) | `stacks/delete.go` | unit tests, each red-proved |
| §8 the wizard | a media app is not asked for a drive; its config goes on the default drive (R-1031) | `stacks/metadata.go`, `deploy.go` | unit test |
| §9 old installs | Sonarr/Radarr from before `bc54c01`: same-path root, `moveFiles:false`, rescan, old root removed, Radarr collections — only when `/media` is no longer bound | `mediawire` `repairOldRoot` | the K7 sequence as a unit test |
| §10 events | added / removed / returned recorded on the drive; the notice on the storage page and the dashboard with „Újraindítás most" (Plex/Jellyfin stream check first) and „Később" (03:30); per drive „restarted since"; containers holding a dead drive listed | `web/media_events.go`, `media_notice.html` | G2, G3: the notice within 6 s of a pull; one restart cured every app |
| §10 returned | the file browser is recreated at once (R-1038); a dead or removed drive is not bound into it | `web/handlers.go` | G3: `/srv/media_b` readable the second the drive was back |
| §10 safe removal | pause that drive's torrents → recreate the apps and the file browser without it → unmount → keep it registered; its return needs no household step; the torrents resume after the restart (R-1044) | `web/media_events.go`, `storage_handlers.go` | G4: only D's 2 torrents paused, every app let go before the unmount, back by itself 7 s after the disk, torrents seeding with no re-check |
| Plex | code asked after the start (`after_start` field) on the Plex page; the gate opens only when Plex says claimed, „Kész" refused before (R-1028, R-1045) | `web/media_events.go`, `setup_gate.go` | G5 on 9201: no code at install, gated, „Kész" 409; the claim itself: K6-2 by hand (the dashboard claim waits on a household code) |
**Measured traps, kept here because each would be rebuilt:** the agent's disk `state` stays „attached" for a registered
disk that is gone — presence is the backing device; the periodic drive gate must leave a safely removed drive alone (it
raced the 32-second removal); qBittorrent 5 answers a good login with 204; Sonarr creates a qBittorrent category with no
save path when its client is saved before the wiring; **a Plex status probe (`/library/sections`, done 401) OPENED an
unclaimed Plex because Plex answers 401 for a moment while it starts — never gate on a status a starting app also
returns** (G7f).