182 lines
14 KiB
Markdown
182 lines
14 KiB
Markdown
# 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: DRAFT** — measured end to end on Tester 1; the layout of §2 is RULED (decision 242) and live for one drive.
|
||
|
||
## 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 — pending S6 — a drive
|
||
that came back. 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.
|