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
Merge --no-ff (not on my word — yours).
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.
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.
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.
## 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
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.
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 main2026-10-30 22:18:27 -07:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Why
movie.nfo,tvshow.nfo,episode.nfo, artwork) only when it sits next to the media file. Thelibrary 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 metadatawriter failed on every itemwith
Read-only file system, and Plex had nothing to read.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./var/lib/media-meta/view:/data/var/lib/media-meta/view:/mnt/entropyscripts/media-view.showns the view (plan/install/verify/status/reap). The unit's repo copy isdankserver/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.mountandinstallwrites it under that name. Thescript prints both, so nobody hunts for it.
Fail-closed guards
installrefuses a non-root caller, an unmounted/mnt/entropy, and an upperbase on rotational storage (
lsblk -no ROTA): an overlay whose upper layersits on the array is the original problem with extra steps. systemd does not
create a mount point, so
installcreatesviewitself.RequiresMountsFor=/mnt/entropyon the mount unit, andUnitConfigItem=ConditionPathIsMountPoint=/var/lib/media-meta/viewon bothquadlets. 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 ofdeploy_jellyfinanddeploy_plex; the planner routesscripts/media-view.shanddankserver/host/**to exactly that pair, so no unrelated container change cantouch inference.
without removing it.
reaplists upper-layer directories whose media wasrenamed away on the raw path (files are never candidates — a sidecar is supposed
to exist only above) and
--applymoves them into a dated.AGENTTRASH/on theSSD.
media-apply.pyprints that follow-up afterapplyandrollback.Evidence
./tests/run.sh— all green (645 tests), including 11 seams intests/media_overlay.bats: the resolved overlay shape, the refusals, the twobinds 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.yamlno longer carries a pre-Quadletjellyfinblock: itnamed
/opt/jellyfin/*and a read-only array bind, making it a second owner fora container its Quadlet already owns. The planner drops
jellyfinfrom thecompose service set, and
tests/deployment_scoping.batspins that a compose editrestarts neither media server.
verifyisthe post-deploy check, and it is what the deployer runs before either restart.
Merge and deploy — needs an explicit OK
--no-ff(not on my word — yours).git merge --ff-only origin/main, thenscripts/deploy.sh dankserver --service jellyfinand--service plex.Service-scoped, not a fleet drain.
scripts/media-view.sh statuson Dankserver →view: /var/lib/media-meta/view (…overlay…)with the upper layer on the NVMe; then one Jellyfin library scan.Read-only file system : '/data/…/movie.nfo'log line stops being expectednoise and becomes the signature of a missing view.
Deliberately not here
.nfoonly with itsLocal media information agent enabled per library; this change puts the files
in reach and changes no setting.
dankserver/compose.yamlstill carries the pre-quadletjellyfinblock with/opt/jellyfin/*and the old read-only array bind. It collides on containername (so it fails loudly and is inert today), but it should be retired in its
own commit. Its
Quadlet Lifecycleexample was corrected to name that realphantom 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.mdand thegenerated index re-pointed at it.
cwd: /home/blank/.omp/wt/wt-20261030-163847-6d1fc1f · session: 01a18f2e-18b1-7573-bcd5-ac99636f8bcb