From 3b29e173c241f8f7f496ef8b165684700d589045 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 30 Oct 2026 22:05:31 -0700 Subject: [PATCH] refactor(media): mount the view inside /var/lib/media-meta instead of under /mnt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merged door was /mnt/media, which read like a third volume and split the host state across two trees. It is now /var/lib/media-meta/view, so the array layer, the SSD upper layer, the kernel workdir, the reap trash and the merged tree are one directory — and the mount table gains no new /mnt entry beside the array. systemd names a mount unit after its ESCAPED path, so the repo copy keeps the readable name media-meta-view.mount while `install` writes the host file as var-lib-media\x2dmeta-view.mount; `plan` prints both and the page records them, so nobody hunts for a unit that "does not exist". install also creates the mount point itself, because systemd will not. The seam now additionally pins that the unit's Where= is exactly the path both containers bind — guard and bind cannot drift apart. The Quadlet Lifecycle phantom-path example is corrected too: it named an invented /mnt/media/*, while the real phantom is the legacy /opt/jellyfin/* that dankserver/compose.yaml still carries for a service whose roots moved to /home/dank/Containers/jellyfin. --- AGENTS.md | 2 +- ...{mnt-media.mount => media-meta-view.mount} | 8 ++- .../jellyfin/quadlet/jellyfin.container | 14 ++--- dankserver/plex/quadlet/plex.container | 4 +- scripts/deploy-plan.py | 2 +- scripts/deploy-remote.sh | 7 ++- scripts/media-view.sh | 17 ++++-- .../Architecture/Quadlet Lifecycle.md | 5 +- serverAppConfigs-docs/Dankserver/Jellyfin.md | 4 +- .../Dankserver/Media Metadata View.md | 58 +++++++++++-------- .../Dankserver/Media Relayout.md | 2 +- serverAppConfigs-docs/Dankserver/Plex.md | 2 +- tests/media_overlay.bats | 35 ++++++----- 13 files changed, 93 insertions(+), 67 deletions(-) rename dankserver/host/systemd/{mnt-media.mount => media-meta-view.mount} (75%) diff --git a/AGENTS.md b/AGENTS.md index 9d36f00..1f4a587 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -851,6 +851,6 @@ history. | Benchmarking (only when ordered) | **Stop and mask the service first**, bench, unmask, restart, verify health. | | Deleting a file | **Move it to trash** — `.AGENTTRASH/` (in-repo) or `~/.AGENTTRASH/YYYYMMDD-topic`. Never `rm`. | | Relayout the movie library | **Census → plan → read the exceptions → `scripts/media-apply.py`** — renames only, journal before every move; never hand-rename, never copy. | -| Making media metadata visible to both media servers | **Bind `/mnt/media`** — the overlay `scripts/media-view.sh` installs (array below, NVMe above). Never write the array; never delete through the view. | +| Making media metadata visible to both media servers | **Bind `/var/lib/media-meta/view`** — the overlay `scripts/media-view.sh` installs (array below, NVMe above). Never write the array; never delete through the view. | | Tests exist | **Run them** — verify tests pass after every change. Do not commit failing tests. | | Real secrets | **Never commit** — host `.env` files; `.env.example` holds placeholders only. | diff --git a/dankserver/host/systemd/mnt-media.mount b/dankserver/host/systemd/media-meta-view.mount similarity index 75% rename from dankserver/host/systemd/mnt-media.mount rename to dankserver/host/systemd/media-meta-view.mount index 7bef3ed..b7a2463 100644 --- a/dankserver/host/systemd/mnt-media.mount +++ b/dankserver/host/systemd/media-meta-view.mount @@ -1,4 +1,6 @@ -# /etc/systemd/system/mnt-media.mount +# /etc/systemd/system/var-lib-media\x2dmeta-view.mount +# (repo copy: dankserver/host/systemd/media-meta-view.mount — systemd names a +# mount unit after its ESCAPED path, so the host file carries the \x2d spelling) # # Installed by `scripts/media-view.sh install` — the repo copy is the source of # truth (fleet law: never edit host state by hand). @@ -6,7 +8,7 @@ # An OverlayFS merge that shows the media library as one tree while splitting # where its bytes live: lowerdir is the RAID6 array (the source of truth, never # written), upperdir is a directory on the NVMe that receives every write. The -# two media servers bind the result at /mnt/media, so Jellyfin's Kodi-format +# two media servers bind the result at /var/lib/media-meta/view, so Jellyfin's # sidecars and artwork are SSD bytes — which is what makes them fast to browse # and what keeps Plex's NFO agent off the spinners — while the media itself stays # on the array untouched. @@ -20,7 +22,7 @@ RequiresMountsFor=/mnt/entropy [Mount] What=overlay -Where=/mnt/media +Where=/var/lib/media-meta/view Type=overlay Options=lowerdir=/mnt/entropy,upperdir=/var/lib/media-meta/upper,workdir=/var/lib/media-meta/work diff --git a/dankserver/jellyfin/quadlet/jellyfin.container b/dankserver/jellyfin/quadlet/jellyfin.container index c198013..4152131 100644 --- a/dankserver/jellyfin/quadlet/jellyfin.container +++ b/dankserver/jellyfin/quadlet/jellyfin.container @@ -4,9 +4,9 @@ # Unit name: jellyfin.service (podman 5.x drops the container- prefix) # # Data roots migrated from /opt/jellyfin to /home/dank/Containers/jellyfin at -# cutover. The library is seen through /mnt/media, the SSD-backed overlay view -# (scripts/media-view.sh): the array stays read-only and untouched, while the -# sidecars this server writes land on the NVMe. Every +# cutover. The library is seen through /var/lib/media-meta/view, the SSD-backed +# overlay view (scripts/media-view.sh): the array stays read-only and untouched, +# while the sidecars this server writes land on the NVMe. Every # creation-time environment variable of the compose-era container is kept as # an Environment= line (zero drift); none of them are secrets, so this unit # needs no EnvironmentFile. @@ -44,10 +44,10 @@ Volume=/home/dank/Containers/jellyfin/config:/config Volume=/home/dank/Containers/jellyfin/cache:/cache # The view, writable: Jellyfin is the metadata writer (movie.nfo / tvshow.nfo / # episode.nfo + artwork) and those bytes belong on the SSD. The condition is the -# fail-closed guard — starting against a plain /mnt/media directory would let a -# scan conclude the library is empty. -Volume=/mnt/media:/data -UnitConfigItem=ConditionPathIsMountPoint=/mnt/media +# fail-closed guard — starting against a plain, unmounted view directory would let +# a scan conclude the library is empty. +Volume=/var/lib/media-meta/view:/data +UnitConfigItem=ConditionPathIsMountPoint=/var/lib/media-meta/view AddDevice=/dev/dri GroupAdd=keep-groups HealthCmd=curl -f http://localhost:8096/health diff --git a/dankserver/plex/quadlet/plex.container b/dankserver/plex/quadlet/plex.container index d8d22ce..ca6354e 100644 --- a/dankserver/plex/quadlet/plex.container +++ b/dankserver/plex/quadlet/plex.container @@ -15,8 +15,8 @@ Volume=/var/lib/plexmediaserver:/config # its own database is written against it, so swapping the HOST source of this bind # to the overlay view re-points Plex at the sidecars without touching its DB. # Read-only: Plex consumes the NFO files Jellyfin writes and writes no metadata. -Volume=/mnt/media:/mnt/entropy:ro -UnitConfigItem=ConditionPathIsMountPoint=/mnt/media +Volume=/var/lib/media-meta/view:/mnt/entropy:ro +UnitConfigItem=ConditionPathIsMountPoint=/var/lib/media-meta/view PublishPort=32400:32400 [Service] diff --git a/scripts/deploy-plan.py b/scripts/deploy-plan.py index 3d1819d..eb293c3 100755 --- a/scripts/deploy-plan.py +++ b/scripts/deploy-plan.py @@ -422,7 +422,7 @@ def services_for_path(path: str, target: str) -> set[str]: } _add(services, *quadlet_services.get(parts[1], set())) if len(parts) >= 2 and parts[1] == "host": - # dankserver/host/systemd/mnt-media.mount is the overlay view the two + # dankserver/host/systemd/media-meta-view.mount is the overlay view the # media servers bind (scripts/media-view.sh installs it on the host), # so editing it restarts jellyfin and plex and nothing else. _add(services, "jellyfin", "plex") diff --git a/scripts/deploy-remote.sh b/scripts/deploy-remote.sh index f7ad82e..d9547bc 100755 --- a/scripts/deploy-remote.sh +++ b/scripts/deploy-remote.sh @@ -1204,9 +1204,10 @@ deploy_romm_stack() { ensure_media_view() { # Both media servers read their library through the SSD-backed overlay view - # (scripts/media-view.sh, dankserver/host/systemd/mnt-media.mount): the array - # below stays read-only and every sidecar Jellyfin writes lands on the NVMe. - # Raise it BEFORE either container starts — a start against a bare /mnt/media + # (scripts/media-view.sh, dankserver/host/systemd/media-meta-view.mount): the + # array below stays read-only and every sidecar Jellyfin writes lands on the + # NVMe. + # Raise it BEFORE either container starts — a start against a bare view # directory would let a scan clear the library database. The units also carry # ConditionPathIsMountPoint, so a missing view skips the start rather than # damaging anything. A relayout must be followed by `media-view.sh reap` diff --git a/scripts/media-view.sh b/scripts/media-view.sh index 16ade4c..125289f 100755 --- a/scripts/media-view.sh +++ b/scripts/media-view.sh @@ -11,7 +11,10 @@ # lowerdir /mnt/entropy RAID6 (ROTA=1) — source of truth, never written # upperdir /var/lib/media-meta/upper NVMe — every sidecar and image byte # workdir /var/lib/media-meta/work NVMe — kernel bookkeeping -# Where /mnt/media what jellyfin and plex bind +# Where /var/lib/media-meta/view the merged tree jellyfin and plex bind +# +# Everything the view needs — layers, bookkeeping, trash, the merged door — lives +# under the one directory /var/lib/media-meta, so nothing new appears under /mnt. # # Consequences worth knowing: # * A sidecar write is a few KB onto the SSD; the media bytes never move and the @@ -37,8 +40,11 @@ set -euo pipefail LOWER=/mnt/entropy UPPER_BASE=/var/lib/media-meta -VIEW=/mnt/media -UNIT=mnt-media.mount +VIEW=$UPPER_BASE/view +# systemd names a mount unit after its ESCAPED path ('/' -> '-', a literal '-' +# -> '\x2d'), so the unit on the host is var-lib-media\x2dmeta-view.mount. The +# repo copy keeps a readable name; install writes it under the escaped one. +UNIT='var-lib-media\x2dmeta-view.mount' UNIT_DIR=/etc/systemd/system # The rootless containers write as this host uid (the deploy user). The upper # layer has to accept those writes, so it is owned by them. @@ -46,7 +52,7 @@ MEDIA_UID="${MEDIA_VIEW_UID:-1000}" MEDIA_GID="${MEDIA_VIEW_GID:-1000}" REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -UNIT_SRC="$REPO_ROOT/dankserver/host/systemd/$UNIT" +UNIT_SRC="$REPO_ROOT/dankserver/host/systemd/media-meta-view.mount" die() { printf 'media-view: %s\n' "$*" >&2 @@ -141,7 +147,8 @@ do_install() { read -r src rota <<<"$(rotational_of "$upper_base")" [ "$rota" = 0 ] || die "refused: $upper_base is on $src, a rotational device — the metadata layer must be on the SSD" [ -f "$UNIT_SRC" ] || die "refused: $UNIT_SRC is missing from $REPO_ROOT" - install -d -m 0755 -o "$MEDIA_UID" -g "$MEDIA_GID" "$upper" "$work" + # systemd will not create a mount point; a missing directory fails the start. + install -d -m 0755 -o "$MEDIA_UID" -g "$MEDIA_GID" "$upper" "$work" "$VIEW" install -d -m 0700 "$upper_base/.AGENTTRASH" install -m 0644 "$UNIT_SRC" "$UNIT_DIR/$UNIT" systemctl daemon-reload diff --git a/serverAppConfigs-docs/Architecture/Quadlet Lifecycle.md b/serverAppConfigs-docs/Architecture/Quadlet Lifecycle.md index 379fa1c..410d35f 100644 --- a/serverAppConfigs-docs/Architecture/Quadlet Lifecycle.md +++ b/serverAppConfigs-docs/Architecture/Quadlet Lifecycle.md @@ -195,8 +195,9 @@ deploy.sh, never a live env poke. check every namespace (`sudo podman ps -a`) and native units (`systemctl list-units`) before declaring a service absent. Compare compose mount sources against the real filesystem — compose files name - phantom paths (e.g. `/mnt/media/*` vs the real LUKS array at - `/mnt/entropy`). Fix compose through git first, deploy second. + phantom paths (the legacy `dankserver/compose.yaml` still names `/opt/jellyfin/*` + for a service whose roots moved to `/home/dank/Containers/jellyfin`). Fix + compose through git first, deploy second. 2. **Pre-create bind-mount sources** (`sudo mkdir -p` + `chown 1000:1000`) or creation fails `statfs …: no such file or directory`. 3. **Staged bring-up:** infra first (`up -d mariadb redis`), poll diff --git a/serverAppConfigs-docs/Dankserver/Jellyfin.md b/serverAppConfigs-docs/Dankserver/Jellyfin.md index e93bd87..741a9f0 100644 --- a/serverAppConfigs-docs/Dankserver/Jellyfin.md +++ b/serverAppConfigs-docs/Dankserver/Jellyfin.md @@ -26,7 +26,7 @@ public vhost `jelly.franksdank.cloud` behind the [[Anubis]] guard chain → | Image | `ghcr.io/jellyfin/jellyfin:latest` | | Network | `dankserver_appnet`; publishes 8096/tcp, 8920/tcp, 7359/udp, 1900/udp | | Data roots | `/home/dank/Containers/jellyfin/config:/config`, `/home/dank/Containers/jellyfin/cache:/cache` | -| Media | `Volume=/mnt/media:/data` — the [[Media Metadata View]] overlay, **writable**; sidecars land on the NVMe, the [[Entropy Volume]] array underneath stays read-only and untouched | +| Media | `Volume=/var/lib/media-meta/view:/data` — the [[Media Metadata View]] overlay, **writable**; sidecars land on the NVMe, the [[Entropy Volume]] array underneath stays read-only and untouched | | GPU | none — no `AddDevice`; CPU decode only (jellyfin-ffmpeg path) | | Env quirks | `DOTNET_SYSTEM_IO_DISABLEFILELOCKING=true`, `LD_PRELOAD=libjemalloc.so.2`, `MALLOC_TRIM_THRESHOLD_=131072` kept verbatim from the compose-era container (zero-drift cutover) | | Health | `curl -f http://localhost:8096/health` every 30 s | @@ -36,7 +36,7 @@ public vhost `jelly.franksdank.cloud` behind the [[Anubis]] guard chain → ``` /home/dank/Containers/jellyfin/config -> /config (JELLYFIN_DATA_DIR) /home/dank/Containers/jellyfin/cache -> /cache (JELLYFIN_CACHE_DIR, XDG_CACHE_HOME) -/mnt/media -> /data (the SSD-backed overlay view) +/var/lib/media-meta/view -> /data (the SSD-backed overlay view) ``` Library folders point at `/data` paths, which are the view's paths: stable diff --git a/serverAppConfigs-docs/Dankserver/Media Metadata View.md b/serverAppConfigs-docs/Dankserver/Media Metadata View.md index 03c2845..296d112 100644 --- a/serverAppConfigs-docs/Dankserver/Media Metadata View.md +++ b/serverAppConfigs-docs/Dankserver/Media Metadata View.md @@ -8,12 +8,17 @@ timestamp: 2026-10-30T00:00:00Z # Media Metadata View -`/mnt/media` is an OverlayFS merge of the media library: the RAID6 array below, -a directory on the NVMe above. It exists so [[Jellyfin]] can write Kodi-format -sidecars (`movie.nfo`, `tvshow.nfo`, `episode.nfo`, artwork) that [[Plex]] can -read. Sidecar metadata is only useful sitting next to the media file, and the -array is read-only and slow — so the bytes stay there and the *writes* land on -SSD. One tree to both servers, one copy of the media. +`/var/lib/media-meta/view` is an OverlayFS merge of the media library: the RAID6 +array below, a directory on the NVMe above. It exists so [[Jellyfin]] can write +Kodi-format sidecars (`movie.nfo`, `tvshow.nfo`, `episode.nfo`, artwork) that +[[Plex]] can read. Sidecar metadata is only useful sitting next to the media +file, and the array is read-only and slow — so the media bytes stay there and the +*writes* land on SSD. One tree to both servers, one copy of the media. + +The mount point is not a volume and holds nothing while unmounted. It sits inside +`/var/lib/media-meta` so that layers, kernel bookkeeping, the reap trash and the +merged door are one directory — and so the mount table grows no new `/mnt` entry +next to the array. ## Shape @@ -22,20 +27,21 @@ SSD. One tree to both servers, one copy of the media. | Lower layer | `/mnt/entropy` — the RAID6 array, source of truth, never written | | Upper layer | `/var/lib/media-meta/upper` — NVMe; every write lands here | | Work dir | `/var/lib/media-meta/work` — overlayfs internals, same filesystem | -| Mount point | `/mnt/media`, unit `mnt-media.mount` (`WantedBy=local-fs.target`) | -| Repo source | `dankserver/host/systemd/mnt-media.mount` — host copy is generated state | +| Mount point | `/var/lib/media-meta/view` — the merged tree both servers bind | +| Unit (repo) | `dankserver/host/systemd/media-meta-view.mount` — the source of truth | +| Unit (host) | `var-lib-media\x2dmeta-view.mount` — systemd names a mount unit after its escaped path (`-` becomes `\x2d`); `media-view.sh install` writes the repo file under that name | | Installer | `scripts/media-view.sh` — `plan` / `install` / `verify` / `status` / `reap` | | Written by | Jellyfin only (Plex binds read-only); nothing else in the fleet touches it | Both consumers guard the view with -`UnitConfigItem=ConditionPathIsMountPoint=/mnt/media`, and `scripts/deploy.sh -dankserver --service jellyfin|plex` calls `media-view.sh install` + `verify` -before it restarts either container. +`UnitConfigItem=ConditionPathIsMountPoint=/var/lib/media-meta/view`, and +`scripts/deploy.sh dankserver --service jellyfin|plex` calls `media-view.sh +install` + `verify` before it restarts either container. | Consumer | Bind | Mode | Why that path | |---|---|---|---| -| [[Jellyfin]] | `/mnt/media:/data` | rw | It is the metadata writer; its sidecars become SSD bytes | -| [[Plex]] | `/mnt/media:/mnt/entropy` | ro | Every path in the PMS database was recorded as `/mnt/entropy/...`; only the HOST side of the bind moved, so its database still resolves | +| [[Jellyfin]] | `/var/lib/media-meta/view:/data` | rw | It is the metadata writer; its sidecars become SSD bytes | +| [[Plex]] | `/var/lib/media-meta/view:/mnt/entropy` | ro | Every path in the PMS database was recorded as `/mnt/entropy/...`; only the HOST side of the bind moved, so its database still resolves | Putting the sidecars inside Plex's reach is not the same as making Plex use them: a library parses `.nfo` only with its *Local media information* agent enabled, and @@ -46,14 +52,14 @@ this change touches no library setting. ```bash scripts/media-view.sh plan # print the resolved shape, touch nothing sudo scripts/media-view.sh install # create dirs, install the unit, mount, enable -scripts/media-view.sh verify # is /mnt/media really the overlay? (deploy gate) +scripts/media-view.sh verify # is the view really the overlay? (deploy gate) scripts/media-view.sh status # mount options + upper-layer size/file count scripts/media-view.sh reap # list ghost metadata (media moved, sidecars left) sudo scripts/media-view.sh reap --apply # move ghosts to the SSD trash, never delete ``` -`--upper-base DIR` and `--lower DIR` relocate the layers; the defaults are the -host values above. +`--upper-base DIR` and `--lower DIR` relocate the layers for `plan` and `reap`; +the defaults are the host values above. ## Laws @@ -64,8 +70,9 @@ host values above. - **Never mount the view over an unmounted lower.** `RequiresMountsFor=/mnt/entropy` plus the container `ConditionPathIsMountPoint` guards: an empty `lowerdir` presents an *empty library* as truth, and a media scan would then clear the - database of everything. -- **Never delete through `/mnt/media`.** A delete writes an overlayfs whiteout + database of everything. systemd does not create a mount point, so `install` + creates `view` itself; without it the mount simply fails. +- **Never delete through the view.** A delete writes an overlayfs whiteout (`.wh.`) in the upper layer, which hides the array's copy without removing it — the file reappears the moment the view is unmounted, and the upper layer now carries a landmine. Deletions belong on the raw path @@ -87,19 +94,20 @@ host values above. | Symptom | Meaning | |---|---| -| `jellyfin.service` / `plex.service` show `ConditionPathIsMountPoint=... was not met` and stay inactive | `/mnt/media` is not mounted. Do not start them anyway: `scripts/media-view.sh install`, then `verify`. | +| `jellyfin.service` / `plex.service` show `ConditionPathIsMountPoint=... was not met` and stay inactive | The view is not mounted. Do not start them anyway: `scripts/media-view.sh install`, then `verify`. | | Jellyfin log: `Read-only file system : '/data/Movies/…/movie.nfo'` | The container is binding the raw array, not the view — the quadlet drifted back or the view is gone. `media-view.sh status`. | | A folder appears in a media server with artwork/`.nfo` but no video | Ghost metadata from a relayout. `media-view.sh reap`. | -| `verify` prints `refused: /mnt/media is not a mountpoint` | Someone made a plain directory there. That is the fail-closed guard working; remove it and install. | +| `verify` prints `refused: …/view is not a mountpoint` | A plain directory is sitting where the overlay belongs. That is the fail-closed guard working; `install` on the box. | +| `systemctl status` finds no such unit | Look under the escaped name, `var-lib-media\x2dmeta-view.mount`, not the repo file name. | ## Rollback Revert the commit that pointed the two quadlets at the view and re-deploy both -services — they bind `/mnt/entropy` read-only again, which is the pre-view -shape. The NVMe upper layer can stay: nothing reads it once the binds are back, -and it holds no state either server needs. Removing the mount alone -(`systemctl stop mnt-media.mount`) is safe while no container binds it, and the -array is never modified by any of this. +services — they bind `/mnt/entropy` read-only again, which is the pre-view shape. +The NVMe upper layer can stay: nothing reads it once the binds are back, and it +holds no state either server needs. Unmounting alone +(`systemctl stop 'var-lib-media\x2dmeta-view.mount'`) is safe while no container +binds it, and the array is never modified by any of this. ## Links diff --git a/serverAppConfigs-docs/Dankserver/Media Relayout.md b/serverAppConfigs-docs/Dankserver/Media Relayout.md index a381612..f5bdaf5 100644 --- a/serverAppConfigs-docs/Dankserver/Media Relayout.md +++ b/serverAppConfigs-docs/Dankserver/Media Relayout.md @@ -257,7 +257,7 @@ show. A name whose marker is `Part 2` is a film and stays put. the same command with `--apply`: the sidecars left at the old coordinates become ghost folders — artwork and an `.nfo` with no video — that both servers browse and report as content. -- **Nothing deletes through `/mnt/media`.** A delete there writes an overlayfs +- **Nothing deletes through the metadata view.** A delete there writes an overlayfs whiteout in the upper layer, which hides the array's copy without removing it; the file returns when the view is unmounted. Deletions in this pipeline happen on the raw path only, which is what the journal can undo. diff --git a/serverAppConfigs-docs/Dankserver/Plex.md b/serverAppConfigs-docs/Dankserver/Plex.md index dd36582..ec35283 100644 --- a/serverAppConfigs-docs/Dankserver/Plex.md +++ b/serverAppConfigs-docs/Dankserver/Plex.md @@ -27,7 +27,7 @@ public traffic can be fronted by Caddy like every other vhost. | Runtime | podman quadlet, `plex.service` (user manager) — migrated from the native `plexmediaserver.service` OS-package unit (2026-09) | | Image | `lscr.io/linuxserver/plex:1.43.5.11029-6bf8af3f0-ls329` (pinned; verified == the registry's `latest` manifest digest at pin time: `sha256:8f655bad…e9e2334`) | | Data root | `/var/lib/plexmediaserver` bind-mounted **in place** as `/config` (RW) — the migration moved zero bytes | -| Media | `/mnt/media` (the [[Media Metadata View]] overlay) bound at the same absolute path `/mnt/entropy` **read-only** — every library path in the PMS database stayed valid, zero re-scan | +| Media | `/var/lib/media-meta/view` (the [[Media Metadata View]] overlay) bound at the same absolute path `/mnt/entropy` **read-only** — every library path in the PMS database stayed valid, zero re-scan | | Identity | fleet rootless model — container root maps to the `dank` user (1000:1000); the deployer chowns the data root on first cutover | | Port | `32400/tcp` published on `0.0.0.0` — zero client reconfiguration | | Network | `dankserver_appnet`, alias `plex`; Tautulli reaches it at `http://plex:32400` | diff --git a/tests/media_overlay.bats b/tests/media_overlay.bats index 0290d90..ee6a3e1 100755 --- a/tests/media_overlay.bats +++ b/tests/media_overlay.bats @@ -18,7 +18,8 @@ load helpers VIEW="$REPO_ROOT/scripts/media-view.sh" JELLY="$REPO_ROOT/dankserver/jellyfin/quadlet/jellyfin.container" PLEX="$REPO_ROOT/dankserver/plex/quadlet/plex.container" -UNIT="$REPO_ROOT/dankserver/host/systemd/mnt-media.mount" +MOUNT=/var/lib/media-meta/view +UNIT="$REPO_ROOT/dankserver/host/systemd/media-meta-view.mount" @test "the plan names an overlay with the spindle below and the SSD above" { run bash "$VIEW" plan @@ -28,8 +29,10 @@ UNIT="$REPO_ROOT/dankserver/host/systemd/mnt-media.mount" [ "${lines[0]}" = "lowerdir=/mnt/entropy" ] printf '%s\n' "${lines[@]}" | grep -qx 'upperdir=/var/lib/media-meta/upper' printf '%s\n' "${lines[@]}" | grep -qx 'workdir=/var/lib/media-meta/work' - printf '%s\n' "${lines[@]}" | grep -qx 'mountpoint=/mnt/media' - printf '%s\n' "${lines[@]}" | grep -qx 'unit=/etc/systemd/system/mnt-media.mount' + printf '%s\n' "${lines[@]}" | grep -Fqx "mountpoint=$MOUNT" + # systemd names a mount unit after its escaped path, backslash and all. + printf '%s\n' "${lines[@]}" | grep -Fqx \ + 'unit=/etc/systemd/system/var-lib-media\x2dmeta-view.mount' } @test "install refuses a caller who cannot write the unit directory or mount" { @@ -44,33 +47,33 @@ UNIT="$REPO_ROOT/dankserver/host/systemd/mnt-media.mount" } @test "verify refuses a view that is not mounted" { - # Every consumer trusts /mnt/media to BE the library. On a host where it is a - # plain empty directory, verify must fail loudly: that is the difference + # Every consumer trusts the mount point to BE the library. On a host where it + # is a plain empty directory, verify must fail loudly: that is the difference # between a scan that finds nothing and a scan that empties a library DB. - if mountpoint -q /mnt/media; then - skip "/mnt/media is a real mountpoint on this host" + if mountpoint -q "$MOUNT"; then + skip "$MOUNT is a real mountpoint on this host" fi run bash "$VIEW" verify [ "$status" -ne 0 ] - [[ "$output" == *"/mnt/media"* ]] + [[ "$output" == *"$MOUNT"* ]] } @test "both media servers bind the view and refuse to start without it" { for unit in "$JELLY" "$PLEX"; do - grep -q '^Volume=/mnt/media:' "$unit" || return 1 - grep -q '^UnitConfigItem=ConditionPathIsMountPoint=/mnt/media$' "$unit" || return 1 + grep -q "^Volume=$MOUNT:" "$unit" || return 1 + grep -q "^UnitConfigItem=ConditionPathIsMountPoint=$MOUNT$" "$unit" || return 1 # The raw array must no longer be a container source: that bind is what # made sidecar sharing impossible (read-only) and metadata slow (seeks). ! grep -q '^Volume=/mnt/entropy' "$unit" done # Jellyfin is the writer, so it needs the view writable; Plex only reads the # sidecars, and keeping its bind read-only costs nothing. - grep -q '^Volume=/mnt/media:/data$' "$JELLY" - grep -q '^Volume=/mnt/media:/mnt/entropy:ro$' "$PLEX" + grep -q "^Volume=$MOUNT:/data$" "$JELLY" + grep -q "^Volume=$MOUNT:/mnt/entropy:ro$" "$PLEX" } @test "only the two media units reference the view" { - run grep -rl --include='*.container' '/mnt/media' "$REPO_ROOT"/dankserver \ + run grep -rl --include='*.container' "$MOUNT" "$REPO_ROOT"/dankserver \ "$REPO_ROOT"/dankclaw "$REPO_ROOT"/frankbook-pro 2>/dev/null [ "$status" -eq 0 ] [ "$(printf '%s\n' "$output" | wc -l)" -eq 2 ] @@ -82,6 +85,10 @@ UNIT="$REPO_ROOT/dankserver/host/systemd/mnt-media.mount" [ -f "$UNIT" ] run grep -E '^Options=lowerdir=/mnt/entropy,upperdir=/var/lib/media-meta/upper,workdir=/var/lib/media-meta/work' "$UNIT" [ "$status" -eq 0 ] + # The unit must mount exactly where the two containers bind, or the guard and + # the bind disagree and a media server browses an empty directory. + run grep -Fqx "Where=$MOUNT" "$UNIT" + [ "$status" -eq 0 ] # It must wait for the array to be unlocked: mounting an overlay whose lower # dir is an empty /mnt/entropy would present an empty library, not an error. run grep -E '^RequiresMountsFor=/mnt/entropy$' "$UNIT" @@ -102,7 +109,7 @@ UNIT="$REPO_ROOT/dankserver/host/systemd/mnt-media.mount" @test "the planner routes the view files to both media services" { run python3 "$REPO_ROOT/scripts/deploy-plan.py" --target dankserver --paths \ - scripts/media-view.sh dankserver/host/systemd/mnt-media.mount + scripts/media-view.sh dankserver/host/systemd/media-meta-view.mount [ "$status" -eq 0 ] [ "$output" = "jellyfin plex" ]