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
Owner

Why

  • Both media servers read a Kodi-format sidecar (movie.nfo, tvshow.nfo,
    episode.nfo, artwork) only when it sits next to the media file. The
    library lives on the RAID6 array, deliberately mounted read-only for every
    container, and a thousand tiny XML reads across five spinners is what makes
    that path useless — Jellyfin's Local metadata writer failed on every item
    with Read-only file system, and Plex had nothing to read.
  • The ask: Jellyfin writes the metadata, Plex sees it, the bytes land on the
    NVMe, and the array is still never written.

What

One OverlayFS merge, mounted at /var/lib/media-meta/view — lowerdir=/mnt/entropy
(array, source of truth, never written) over upperdir=/var/lib/media-meta/{upper,work}
(NVMe, where every write lands). Nothing new appears under /mnt: layers,
kernel bookkeeping, the reap trash and the merged door are all inside
/var/lib/media-meta.

Consumer Bind Mode
Jellyfin /var/lib/media-meta/view:/data rw — the fleet's only metadata writer
Plex /var/lib/media-meta/view:/mnt/entropy ro — the container path its own database recorded, so only the host side of that bind moved and zero library paths change

scripts/media-view.sh owns the view (plan / install / verify / status /
reap). The unit's repo copy is dankserver/host/systemd/media-meta-view.mount;
systemd names a mount unit after its escaped path, so on the host it is
var-lib-media\x2dmeta-view.mount and install writes it under that name. The
script prints both, so nobody hunts for it.

Fail-closed guards

  • install refuses a non-root caller, an unmounted /mnt/entropy, and an upper
    base on rotational storage (lsblk -no ROTA): an overlay whose upper layer
    sits on the array is the original problem with extra steps. systemd does not
    create a mount point, so install creates view itself.
  • RequiresMountsFor=/mnt/entropy on the mount unit, and
    UnitConfigItem=ConditionPathIsMountPoint=/var/lib/media-meta/view on both
    quadlets. A missing view therefore skips the container start rather than
    letting a scan conclude the library is empty and clear the database.
  • ensure_media_view (install + verify) runs at the top of deploy_jellyfin and
    deploy_plex; the planner routes scripts/media-view.sh and
    dankserver/host/** to exactly that pair, so no unrelated container change can
    touch inference.
  • Nothing deletes through the view: an overlayfs whiteout hides the array's copy
    without removing it. reap lists upper-layer directories whose media was
    renamed away on the raw path (files are never candidates — a sidecar is supposed
    to exist only above) and --apply moves them into a dated .AGENTTRASH/ on the
    SSD. media-apply.py prints that follow-up after apply and rollback.

Evidence

  • ./tests/run.sh — all green (645 tests), including 11 seams in
    tests/media_overlay.bats: the resolved overlay shape, the refusals, the two
    binds and their guard, the mount unit mounting exactly where both containers
    bind, the deployer's ordering, the planner mapping, and reap against a fixture
    (ghosts named, then moved to the trash, live sidecars kept).
  • dankserver/compose.yaml no longer carries a pre-Quadlet jellyfin block: it
    named /opt/jellyfin/* and a read-only array bind, making it a second owner for
    a container its Quadlet already owns. The planner drops jellyfin from the
    compose service set, and tests/deployment_scoping.bats pins that a compose edit
    restarts neither media server.
  • Not yet run on the box: the first deploy performs the real mount. verify is
    the post-deploy check, and it is what the deployer runs before either restart.

Merge and deploy — needs an explicit OK

  1. Merge --no-ff (not on my word — yours).
  2. In the deploy checkout: git merge --ff-only origin/main, then
    scripts/deploy.sh dankserver --service jellyfin and --service plex.
    Service-scoped, not a fleet drain.
  3. scripts/media-view.sh status on Dankserver → view: /var/lib/media-meta/view (…overlay…) with the upper layer on the NVMe; then one Jellyfin library scan.
  4. Turn on Local metadata per Jellyfin library. After this lands, the
    Read-only file system : '/data/…/movie.nfo' log line stops being expected
    noise and becomes the signature of a missing view.

Deliberately not here

  • Plex library settings are untouched. Plex parses an .nfo only with its
    Local media information agent enabled per library; this change puts the files
    in reach and changes no setting.
  • dankserver/compose.yaml still carries the pre-quadlet jellyfin block with
    /opt/jellyfin/* and the old read-only array bind. It collides on container
    name (so it fails loudly and is inert today), but it should be retired in its
    own commit. Its Quadlet Lifecycle example was corrected to name that real
    phantom path instead of an invented one.

Docs: new owner page serverAppConfigs-docs/Dankserver/Media Metadata View.md
(shape, commands, laws, failure signatures, rollback — including the escaped unit
name); Jellyfin.md, Plex.md, Media Relayout.md, AGENTS.md and the
generated index re-pointed at it.


cwd: /home/blank/.omp/wt/wt-20261030-163847-6d1fc1f · session: 01a18f2e-18b1-7573-bcd5-ac99636f8bcb

## Why * Both media servers read a Kodi-format sidecar (`movie.nfo`, `tvshow.nfo`, `episode.nfo`, artwork) **only when it sits next to the media file**. The library lives on the RAID6 array, deliberately mounted read-only for every container, and a thousand tiny XML reads across five spinners is what makes that path useless — Jellyfin's `Local metadata` writer failed on every item with `Read-only file system`, and Plex had nothing to read. * The ask: Jellyfin writes the metadata, Plex sees it, the bytes land on the NVMe, and the array is still never written. ## What One OverlayFS merge, mounted at **`/var/lib/media-meta/view`** — `lowerdir=/mnt/entropy` (array, source of truth, never written) over `upperdir=/var/lib/media-meta/{upper,work}` (NVMe, where every write lands). Nothing new appears under `/mnt`: layers, kernel bookkeeping, the reap trash and the merged door are all inside `/var/lib/media-meta`. | Consumer | Bind | Mode | |---|---|---| | Jellyfin | `/var/lib/media-meta/view:/data` | rw — the fleet's only metadata writer | | Plex | `/var/lib/media-meta/view:/mnt/entropy` | ro — the container path its own database recorded, so **only the host side of that bind moved** and zero library paths change | `scripts/media-view.sh` owns the view (`plan` / `install` / `verify` / `status` / `reap`). The unit's repo copy is `dankserver/host/systemd/media-meta-view.mount`; systemd names a mount unit after its **escaped** path, so on the host it is `var-lib-media\x2dmeta-view.mount` and `install` writes it under that name. The script prints both, so nobody hunts for it. ## Fail-closed guards * `install` refuses a non-root caller, an unmounted `/mnt/entropy`, and an upper base on **rotational** storage (`lsblk -no ROTA`): an overlay whose upper layer sits on the array is the original problem with extra steps. systemd does not create a mount point, so `install` creates `view` itself. * `RequiresMountsFor=/mnt/entropy` on the mount unit, and `UnitConfigItem=ConditionPathIsMountPoint=/var/lib/media-meta/view` on both quadlets. A missing view therefore **skips the container start** rather than letting a scan conclude the library is empty and clear the database. * `ensure_media_view` (install + verify) runs at the top of `deploy_jellyfin` and `deploy_plex`; the planner routes `scripts/media-view.sh` and `dankserver/host/**` to exactly that pair, so no unrelated container change can touch inference. * Nothing deletes through the view: an overlayfs whiteout hides the array's copy without removing it. `reap` lists upper-layer **directories** whose media was renamed away on the raw path (files are never candidates — a sidecar is supposed to exist only above) and `--apply` moves them into a dated `.AGENTTRASH/` on the SSD. `media-apply.py` prints that follow-up after `apply` and `rollback`. ## Evidence * `./tests/run.sh` — **all green** (645 tests), including 11 seams in `tests/media_overlay.bats`: the resolved overlay shape, the refusals, the two binds and their guard, the mount unit mounting exactly where both containers bind, the deployer's ordering, the planner mapping, and reap against a fixture (ghosts named, then moved to the trash, live sidecars kept). * `dankserver/compose.yaml` no longer carries a pre-Quadlet `jellyfin` block: it named `/opt/jellyfin/*` and a read-only array bind, making it a second owner for a container its Quadlet already owns. The planner drops `jellyfin` from the compose service set, and `tests/deployment_scoping.bats` pins that a compose edit restarts neither media server. * Not yet run on the box: the first deploy performs the real mount. `verify` is the post-deploy check, and it is what the deployer runs before either restart. ## Merge and deploy — needs an explicit OK 1. Merge `--no-ff` (not on my word — yours). 2. In the deploy checkout: `git merge --ff-only origin/main`, then `scripts/deploy.sh dankserver --service jellyfin` and `--service plex`. **Service-scoped, not a fleet drain.** 3. `scripts/media-view.sh status` on Dankserver → `view: /var/lib/media-meta/view (…overlay…)` with the upper layer on the NVMe; then one Jellyfin library scan. 4. Turn on *Local metadata* per Jellyfin library. After this lands, the `Read-only file system : '/data/…/movie.nfo'` log line stops being expected noise and becomes the signature of a missing view. ## Deliberately not here * **Plex library settings are untouched.** Plex parses an `.nfo` only with its *Local media information* agent enabled per library; this change puts the files in reach and changes no setting. * `dankserver/compose.yaml` still carries the pre-quadlet `jellyfin` block with `/opt/jellyfin/*` and the old read-only array bind. It collides on container name (so it fails loudly and is inert today), but it should be retired in its own commit. Its `Quadlet Lifecycle` example was corrected to name that real phantom path instead of an invented one. Docs: new owner page `serverAppConfigs-docs/Dankserver/Media Metadata View.md` (shape, commands, laws, failure signatures, rollback — including the escaped unit name); `Jellyfin.md`, `Plex.md`, `Media Relayout.md`, `AGENTS.md` and the generated index re-pointed at it. --- cwd: /home/blank/.omp/wt/wt-20261030-163847-6d1fc1f · session: 01a18f2e-18b1-7573-bcd5-ac99636f8bcb
dank added 3 commits 2026-10-30 21:42:55 -07:00
Both media servers read a Kodi-format sidecar (movie.nfo / tvshow.nfo /
episode.nfo) only when it sits next to the media file, and the library lives on
the RAID6 array, deliberately mounted read-only for every container and slow to
seek for a thousand tiny XML reads. So neither server binds the array any more:
both bind /mnt/media, an OverlayFS merge of the array (lowerdir, never written)
with /var/lib/media-meta on the NVMe (upperdir, where every write lands).

Jellyfin binds the view rw at /data and is the fleet's only metadata writer;
Plex binds it ro at /mnt/entropy, the container path its own database already
recorded, so only the host side of that bind moved and its library paths keep
resolving.

scripts/media-view.sh plans, installs, verifies, reports and reaps the view. It
refuses to mount over an unmounted array, refuses an upper layer on rotational
storage, and refuses a non-root install. deploy-remote.sh raises the view before
it restarts either media server, both units carry
ConditionPathIsMountPoint=/mnt/media so a missing view skips the start instead of
letting a scan clear the library database, and the planner routes the installer
and dankserver/host/** to the jellyfin+plex pair.

tests/media_overlay.bats (11 seams) pins the resolved shape, both refusals, the
two binds and their guard, the deployer's ordering, the planner mapping, and the
reap behaviour against a fixture (ghosts listed, then moved to the SSD trash --
files that exist only above are sidecars, not ghosts, so only directories are
candidates).
A relayout renames on the raw array; the sidecars the media servers wrote live in
the NVMe upper layer of the view and stay at the old coordinates, which is how a
ghost folder (artwork and an .nfo, no video) is born. apply and rollback now
print the follow-up — scripts/media-view.sh reap — at the moment the operator
needs it, and the existing apply seam asserts the hint is in the output.
New owner page Dankserver/Media Metadata View.md: the shape, the two binds and
why Plex keeps the /mnt/entropy container path, the commands, the laws (SSD-only
upper layer, never mount over an unmounted array, never delete through the view
because a whiteout only hides the array's copy, reap after any relayout, inotify
does not cross the merge), the failure signatures, and the rollback.

Jellyfin.md: /data is the view, so Local metadata works; the
"Read-only file system : '/data/…/movie.nfo'" line that used to be expected noise
is now the signature of a missing view. Plex.md: same bind, one fact changed.
Media Relayout.md: two new laws for the metadata that does not follow a rename.
AGENTS.md: runbook-index row plus a summary row. Root index regenerated.
dank added 3 commits 2026-10-30 22:17:44 -07:00
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.
The container has had one owner since the Quadlet cutover
(dankserver/jellyfin/quadlet/jellyfin.container). The block was a second owner,
and wrong on every path: jellyfin/jellyfin:latest instead of the pinned ghcr
image, /opt/jellyfin/* data roots that no longer exist, and a read-only
/mnt/entropy:/data bind that the metadata view has since replaced.

The planner no longer counts jellyfin as a compose service, so editing the
inventory cannot restart a media server; tests/deployment_scoping.bats pins that
contract for both jellyfin and plex.
dank merged commit 38737ab84e into main 2026-10-30 22:18:27 -07:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: dank/serverAppConfigs#97