Media metadata view: Jellyfin writes, Plex reads, the array stays read-only #97
No files matched your search
@@ -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. |
|
||||||
+5
-3
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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" ]
|
||||||
|
|||||||
Reference in new issue
Block a user