Media metadata view: Jellyfin writes, Plex reads, the array stays read-only #97

Merged
dank merged 6 commits from feat/media-metadata-view into main 2026-10-30 22:18:27 -07:00
13 changed files with 93 additions and 67 deletions
Showing only changes of commit 3b29e173c2 - Show all commits

No files matched your search

+1 -1
View File
@@ -851,6 +851,6 @@ history.
| Benchmarking (only when ordered) | **Stop and mask the service first**, bench, unmask, restart, verify health. | | 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`. | | 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. | | 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. | | 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. | | Real secrets | **Never commit** — host `.env` files; `.env.example` holds placeholders only. |
@@ -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 # Installed by `scripts/media-view.sh install` — the repo copy is the source of
# truth (fleet law: never edit host state by hand). # 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 # 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 # 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 # 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 # 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 # and what keeps Plex's NFO agent off the spinners — while the media itself stays
# on the array untouched. # on the array untouched.
@@ -20,7 +22,7 @@ RequiresMountsFor=/mnt/entropy
[Mount] [Mount]
What=overlay What=overlay
Where=/mnt/media Where=/var/lib/media-meta/view
Type=overlay Type=overlay
Options=lowerdir=/mnt/entropy,upperdir=/var/lib/media-meta/upper,workdir=/var/lib/media-meta/work Options=lowerdir=/mnt/entropy,upperdir=/var/lib/media-meta/upper,workdir=/var/lib/media-meta/work
@@ -4,9 +4,9 @@
# Unit name: jellyfin.service (podman 5.x drops the container- prefix) # Unit name: jellyfin.service (podman 5.x drops the container- prefix)
# #
# Data roots migrated from /opt/jellyfin to /home/dank/Containers/jellyfin at # 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 # cutover. The library is seen through /var/lib/media-meta/view, the SSD-backed
# (scripts/media-view.sh): the array stays read-only and untouched, while the # overlay view (scripts/media-view.sh): the array stays read-only and untouched,
# sidecars this server writes land on the NVMe. Every # while the sidecars this server writes land on the NVMe. Every
# creation-time environment variable of the compose-era container is kept as # 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 # an Environment= line (zero drift); none of them are secrets, so this unit
# needs no EnvironmentFile. # needs no EnvironmentFile.
@@ -44,10 +44,10 @@ Volume=/home/dank/Containers/jellyfin/config:/config
Volume=/home/dank/Containers/jellyfin/cache:/cache Volume=/home/dank/Containers/jellyfin/cache:/cache
# The view, writable: Jellyfin is the metadata writer (movie.nfo / tvshow.nfo / # 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 # 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 # fail-closed guard — starting against a plain, unmounted view directory would let
# scan conclude the library is empty. # a scan conclude the library is empty.
Volume=/mnt/media:/data Volume=/var/lib/media-meta/view:/data
UnitConfigItem=ConditionPathIsMountPoint=/mnt/media UnitConfigItem=ConditionPathIsMountPoint=/var/lib/media-meta/view
AddDevice=/dev/dri AddDevice=/dev/dri
GroupAdd=keep-groups GroupAdd=keep-groups
HealthCmd=curl -f http://localhost:8096/health HealthCmd=curl -f http://localhost:8096/health
+2 -2
View File
@@ -15,8 +15,8 @@ Volume=/var/lib/plexmediaserver:/config
# its own database is written against it, so swapping the HOST source of this bind # 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. # 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. # Read-only: Plex consumes the NFO files Jellyfin writes and writes no metadata.
Volume=/mnt/media:/mnt/entropy:ro Volume=/var/lib/media-meta/view:/mnt/entropy:ro
UnitConfigItem=ConditionPathIsMountPoint=/mnt/media UnitConfigItem=ConditionPathIsMountPoint=/var/lib/media-meta/view
PublishPort=32400:32400 PublishPort=32400:32400
[Service] [Service]
+1 -1
View File
@@ -422,7 +422,7 @@ def services_for_path(path: str, target: str) -> set[str]:
} }
_add(services, *quadlet_services.get(parts[1], set())) _add(services, *quadlet_services.get(parts[1], set()))
if len(parts) >= 2 and parts[1] == "host": 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), # media servers bind (scripts/media-view.sh installs it on the host),
# so editing it restarts jellyfin and plex and nothing else. # so editing it restarts jellyfin and plex and nothing else.
_add(services, "jellyfin", "plex") _add(services, "jellyfin", "plex")
+4 -3
View File
@@ -1204,9 +1204,10 @@ deploy_romm_stack() {
ensure_media_view() { ensure_media_view() {
# Both media servers read their library through the SSD-backed overlay 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 # (scripts/media-view.sh, dankserver/host/systemd/media-meta-view.mount): the
# below stays read-only and every sidecar Jellyfin writes lands on the NVMe. # array below stays read-only and every sidecar Jellyfin writes lands on the
# Raise it BEFORE either container starts — a start against a bare /mnt/media # 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 # directory would let a scan clear the library database. The units also carry
# ConditionPathIsMountPoint, so a missing view skips the start rather than # ConditionPathIsMountPoint, so a missing view skips the start rather than
# damaging anything. A relayout must be followed by `media-view.sh reap` # damaging anything. A relayout must be followed by `media-view.sh reap`
+12 -5
View File
@@ -11,7 +11,10 @@
# lowerdir /mnt/entropy RAID6 (ROTA=1) — source of truth, never written # lowerdir /mnt/entropy RAID6 (ROTA=1) — source of truth, never written
# upperdir /var/lib/media-meta/upper NVMe — every sidecar and image byte # upperdir /var/lib/media-meta/upper NVMe — every sidecar and image byte
# workdir /var/lib/media-meta/work NVMe — kernel bookkeeping # 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: # Consequences worth knowing:
# * A sidecar write is a few KB onto the SSD; the media bytes never move and the # * 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 LOWER=/mnt/entropy
UPPER_BASE=/var/lib/media-meta UPPER_BASE=/var/lib/media-meta
VIEW=/mnt/media VIEW=$UPPER_BASE/view
UNIT=mnt-media.mount # 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 UNIT_DIR=/etc/systemd/system
# The rootless containers write as this host uid (the deploy user). The upper # 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. # 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}" MEDIA_GID="${MEDIA_VIEW_GID:-1000}"
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" 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() { die() {
printf 'media-view: %s\n' "$*" >&2 printf 'media-view: %s\n' "$*" >&2
@@ -141,7 +147,8 @@ do_install() {
read -r src rota <<<"$(rotational_of "$upper_base")" 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" [ "$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" [ -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 -d -m 0700 "$upper_base/.AGENTTRASH"
install -m 0644 "$UNIT_SRC" "$UNIT_DIR/$UNIT" install -m 0644 "$UNIT_SRC" "$UNIT_DIR/$UNIT"
systemctl daemon-reload systemctl daemon-reload
@@ -195,8 +195,9 @@ deploy.sh, never a live env poke.
check every namespace (`sudo podman ps -a`) and native units check every namespace (`sudo podman ps -a`) and native units
(`systemctl list-units`) before declaring a service absent. Compare (`systemctl list-units`) before declaring a service absent. Compare
compose mount sources against the real filesystem — compose files name compose mount sources against the real filesystem — compose files name
phantom paths (e.g. `/mnt/media/*` vs the real LUKS array at phantom paths (the legacy `dankserver/compose.yaml` still names `/opt/jellyfin/*`
`/mnt/entropy`). Fix compose through git first, deploy second. 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`) 2. **Pre-create bind-mount sources** (`sudo mkdir -p` + `chown 1000:1000`)
or creation fails `statfs …: no such file or directory`. or creation fails `statfs …: no such file or directory`.
3. **Staged bring-up:** infra first (`up -d mariadb redis`), poll 3. **Staged bring-up:** infra first (`up -d mariadb redis`), poll
+2 -2
View File
@@ -26,7 +26,7 @@ public vhost `jelly.franksdank.cloud` behind the [[Anubis]] guard chain →
| Image | `ghcr.io/jellyfin/jellyfin:latest` | | Image | `ghcr.io/jellyfin/jellyfin:latest` |
| Network | `dankserver_appnet`; publishes 8096/tcp, 8920/tcp, 7359/udp, 1900/udp | | 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` | | 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) | | 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) | | 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 | | 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/config -> /config (JELLYFIN_DATA_DIR)
/home/dank/Containers/jellyfin/cache -> /cache (JELLYFIN_CACHE_DIR, XDG_CACHE_HOME) /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 Library folders point at `/data` paths, which are the view's paths: stable
@@ -8,12 +8,17 @@ timestamp: 2026-10-30T00:00:00Z
# Media Metadata View # Media Metadata View
`/mnt/media` is an OverlayFS merge of the media library: the RAID6 array below, `/var/lib/media-meta/view` is an OverlayFS merge of the media library: the RAID6
a directory on the NVMe above. It exists so [[Jellyfin]] can write Kodi-format array below, a directory on the NVMe above. It exists so [[Jellyfin]] can write
sidecars (`movie.nfo`, `tvshow.nfo`, `episode.nfo`, artwork) that [[Plex]] can Kodi-format sidecars (`movie.nfo`, `tvshow.nfo`, `episode.nfo`, artwork) that
read. Sidecar metadata is only useful sitting next to the media file, and the [[Plex]] can read. Sidecar metadata is only useful sitting next to the media
array is read-only and slow — so the bytes stay there and the *writes* land on file, and the array is read-only and slow — so the media bytes stay there and the
SSD. One tree to both servers, one copy of the media. *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 ## 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 | | Lower layer | `/mnt/entropy` — the RAID6 array, source of truth, never written |
| Upper layer | `/var/lib/media-meta/upper` — NVMe; every write lands here | | Upper layer | `/var/lib/media-meta/upper` — NVMe; every write lands here |
| Work dir | `/var/lib/media-meta/work` — overlayfs internals, same filesystem | | Work dir | `/var/lib/media-meta/work` — overlayfs internals, same filesystem |
| Mount point | `/mnt/media`, unit `mnt-media.mount` (`WantedBy=local-fs.target`) | | Mount point | `/var/lib/media-meta/view` — the merged tree both servers bind |
| Repo source | `dankserver/host/systemd/mnt-media.mount` — host copy is generated state | | 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` | | 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 | | Written by | Jellyfin only (Plex binds read-only); nothing else in the fleet touches it |
Both consumers guard the view with Both consumers guard the view with
`UnitConfigItem=ConditionPathIsMountPoint=/mnt/media`, and `scripts/deploy.sh `UnitConfigItem=ConditionPathIsMountPoint=/var/lib/media-meta/view`, and
dankserver --service jellyfin|plex` calls `media-view.sh install` + `verify` `scripts/deploy.sh dankserver --service jellyfin|plex` calls `media-view.sh
before it restarts either container. install` + `verify` before it restarts either container.
| Consumer | Bind | Mode | Why that path | | Consumer | Bind | Mode | Why that path |
|---|---|---|---| |---|---|---|---|
| [[Jellyfin]] | `/mnt/media:/data` | rw | It is the metadata writer; its sidecars become SSD bytes | | [[Jellyfin]] | `/var/lib/media-meta/view:/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 | | [[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: 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 a library parses `.nfo` only with its *Local media information* agent enabled, and
@@ -46,14 +52,14 @@ this change touches no library setting.
```bash ```bash
scripts/media-view.sh plan # print the resolved shape, touch nothing scripts/media-view.sh plan # print the resolved shape, touch nothing
sudo scripts/media-view.sh install # create dirs, install the unit, mount, enable 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 status # mount options + upper-layer size/file count
scripts/media-view.sh reap # list ghost metadata (media moved, sidecars left) 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 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 `--upper-base DIR` and `--lower DIR` relocate the layers for `plan` and `reap`;
host values above. the defaults are the host values above.
## Laws ## Laws
@@ -64,8 +70,9 @@ host values above.
- **Never mount the view over an unmounted lower.** `RequiresMountsFor=/mnt/entropy` - **Never mount the view over an unmounted lower.** `RequiresMountsFor=/mnt/entropy`
plus the container `ConditionPathIsMountPoint` guards: an empty `lowerdir` plus the container `ConditionPathIsMountPoint` guards: an empty `lowerdir`
presents an *empty library* as truth, and a media scan would then clear the presents an *empty library* as truth, and a media scan would then clear the
database of everything. database of everything. systemd does not create a mount point, so `install`
- **Never delete through `/mnt/media`.** A delete writes an overlayfs whiteout creates `view` itself; without it the mount simply fails.
- **Never delete through the view.** A delete writes an overlayfs whiteout
(`.wh.<name>`) in the upper layer, which hides the array's copy without (`.wh.<name>`) in the upper layer, which hides the array's copy without
removing it — the file reappears the moment the view is unmounted, and the 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 upper layer now carries a landmine. Deletions belong on the raw path
@@ -87,19 +94,20 @@ host values above.
| Symptom | Meaning | | 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`. | | 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`. | | 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 ## Rollback
Revert the commit that pointed the two quadlets at the view and re-deploy both 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 services — they bind `/mnt/entropy` read-only again, which is the pre-view shape.
shape. The NVMe upper layer can stay: nothing reads it once the binds are back, The NVMe upper layer can stay: nothing reads it once the binds are back, and it
and it holds no state either server needs. Removing the mount alone holds no state either server needs. Unmounting alone
(`systemctl stop mnt-media.mount`) is safe while no container binds it, and the (`systemctl stop 'var-lib-media\x2dmeta-view.mount'`) is safe while no container
array is never modified by any of this. binds it, and the array is never modified by any of this.
## Links ## Links
@@ -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 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 ghost folders — artwork and an `.nfo` with no video — that both servers browse
and report as content. 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; 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 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. on the raw path only, which is what the journal can undo.
+1 -1
View File
@@ -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) | | 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`) | | 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 | | 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 | | 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 | | Port | `32400/tcp` published on `0.0.0.0` — zero client reconfiguration |
| Network | `dankserver_appnet`, alias `plex`; Tautulli reaches it at `http://plex:32400` | | Network | `dankserver_appnet`, alias `plex`; Tautulli reaches it at `http://plex:32400` |
+21 -14
View File
@@ -18,7 +18,8 @@ load helpers
VIEW="$REPO_ROOT/scripts/media-view.sh" VIEW="$REPO_ROOT/scripts/media-view.sh"
JELLY="$REPO_ROOT/dankserver/jellyfin/quadlet/jellyfin.container" JELLY="$REPO_ROOT/dankserver/jellyfin/quadlet/jellyfin.container"
PLEX="$REPO_ROOT/dankserver/plex/quadlet/plex.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" { @test "the plan names an overlay with the spindle below and the SSD above" {
run bash "$VIEW" plan run bash "$VIEW" plan
@@ -28,8 +29,10 @@ UNIT="$REPO_ROOT/dankserver/host/systemd/mnt-media.mount"
[ "${lines[0]}" = "lowerdir=/mnt/entropy" ] [ "${lines[0]}" = "lowerdir=/mnt/entropy" ]
printf '%s\n' "${lines[@]}" | grep -qx 'upperdir=/var/lib/media-meta/upper' 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 'workdir=/var/lib/media-meta/work'
printf '%s\n' "${lines[@]}" | grep -qx 'mountpoint=/mnt/media' printf '%s\n' "${lines[@]}" | grep -Fqx "mountpoint=$MOUNT"
printf '%s\n' "${lines[@]}" | grep -qx 'unit=/etc/systemd/system/mnt-media.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" { @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" { @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 # Every consumer trusts the mount point to BE the library. On a host where it
# plain empty directory, verify must fail loudly: that is the difference # 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. # between a scan that finds nothing and a scan that empties a library DB.
if mountpoint -q /mnt/media; then if mountpoint -q "$MOUNT"; then
skip "/mnt/media is a real mountpoint on this host" skip "$MOUNT is a real mountpoint on this host"
fi fi
run bash "$VIEW" verify run bash "$VIEW" verify
[ "$status" -ne 0 ] [ "$status" -ne 0 ]
[[ "$output" == *"/mnt/media"* ]] [[ "$output" == *"$MOUNT"* ]]
} }
@test "both media servers bind the view and refuse to start without it" { @test "both media servers bind the view and refuse to start without it" {
for unit in "$JELLY" "$PLEX"; do for unit in "$JELLY" "$PLEX"; do
grep -q '^Volume=/mnt/media:' "$unit" || return 1 grep -q "^Volume=$MOUNT:" "$unit" || return 1
grep -q '^UnitConfigItem=ConditionPathIsMountPoint=/mnt/media$' "$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 # The raw array must no longer be a container source: that bind is what
# made sidecar sharing impossible (read-only) and metadata slow (seeks). # made sidecar sharing impossible (read-only) and metadata slow (seeks).
! grep -q '^Volume=/mnt/entropy' "$unit" ! grep -q '^Volume=/mnt/entropy' "$unit"
done done
# Jellyfin is the writer, so it needs the view writable; Plex only reads the # Jellyfin is the writer, so it needs the view writable; Plex only reads the
# sidecars, and keeping its bind read-only costs nothing. # sidecars, and keeping its bind read-only costs nothing.
grep -q '^Volume=/mnt/media:/data$' "$JELLY" grep -q "^Volume=$MOUNT:/data$" "$JELLY"
grep -q '^Volume=/mnt/media:/mnt/entropy:ro$' "$PLEX" grep -q "^Volume=$MOUNT:/mnt/entropy:ro$" "$PLEX"
} }
@test "only the two media units reference the view" { @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 "$REPO_ROOT"/dankclaw "$REPO_ROOT"/frankbook-pro 2>/dev/null
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[ "$(printf '%s\n' "$output" | wc -l)" -eq 2 ] [ "$(printf '%s\n' "$output" | wc -l)" -eq 2 ]
@@ -82,6 +85,10 @@ UNIT="$REPO_ROOT/dankserver/host/systemd/mnt-media.mount"
[ -f "$UNIT" ] [ -f "$UNIT" ]
run grep -E '^Options=lowerdir=/mnt/entropy,upperdir=/var/lib/media-meta/upper,workdir=/var/lib/media-meta/work' "$UNIT" run grep -E '^Options=lowerdir=/mnt/entropy,upperdir=/var/lib/media-meta/upper,workdir=/var/lib/media-meta/work' "$UNIT"
[ "$status" -eq 0 ] [ "$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 # 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. # dir is an empty /mnt/entropy would present an empty library, not an error.
run grep -E '^RequiresMountsFor=/mnt/entropy$' "$UNIT" 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" { @test "the planner routes the view files to both media services" {
run python3 "$REPO_ROOT/scripts/deploy-plan.py" --target dankserver --paths \ 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 ] [ "$status" -eq 0 ]
[ "$output" = "jellyfin [ "$output" = "jellyfin
plex" ] plex" ]