Media metadata view: Jellyfin writes, Plex reads, the array stays read-only #97
No files matched your search
@@ -0,0 +1,28 @@
|
|||||||
|
# /etc/systemd/system/mnt-media.mount
|
||||||
|
#
|
||||||
|
# Installed by `scripts/media-view.sh install` — the repo copy is the source of
|
||||||
|
# truth (fleet law: never edit host state by hand).
|
||||||
|
#
|
||||||
|
# An OverlayFS merge that shows the media library as one tree while splitting
|
||||||
|
# where its bytes live: lowerdir is the RAID6 array (the source of truth, never
|
||||||
|
# written), upperdir is a directory on the NVMe that receives every write. The
|
||||||
|
# two media servers bind the result at /mnt/media, so Jellyfin's Kodi-format
|
||||||
|
# sidecars and artwork are SSD bytes — which is what makes them fast to browse
|
||||||
|
# and what keeps Plex's NFO agent off the spinners — while the media itself stays
|
||||||
|
# on the array untouched.
|
||||||
|
#
|
||||||
|
# RequiresMountsFor is load-bearing: without the array mounted, lowerdir is an
|
||||||
|
# empty directory and the view would present an empty library as truth.
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=SSD-backed overlay view of the media library (array + NVMe metadata layer)
|
||||||
|
RequiresMountsFor=/mnt/entropy
|
||||||
|
|
||||||
|
[Mount]
|
||||||
|
What=overlay
|
||||||
|
Where=/mnt/media
|
||||||
|
Type=overlay
|
||||||
|
Options=lowerdir=/mnt/entropy,upperdir=/var/lib/media-meta/upper,workdir=/var/lib/media-meta/work
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=local-fs.target
|
||||||
@@ -4,8 +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. All of /mnt/entropy is mounted read-only at /data: nothing automated
|
# cutover. The library is seen through /mnt/media, the SSD-backed overlay view
|
||||||
# anywhere writes to the entropy volume. Every
|
# (scripts/media-view.sh): the array stays read-only and untouched, while the
|
||||||
|
# sidecars this server writes land on the NVMe. Every
|
||||||
# creation-time environment variable of the compose-era container is kept as
|
# 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.
|
||||||
@@ -41,7 +42,12 @@ PublishPort=7359:7359/udp
|
|||||||
PublishPort=1900:1900/udp
|
PublishPort=1900:1900/udp
|
||||||
Volume=/home/dank/Containers/jellyfin/config:/config
|
Volume=/home/dank/Containers/jellyfin/config:/config
|
||||||
Volume=/home/dank/Containers/jellyfin/cache:/cache
|
Volume=/home/dank/Containers/jellyfin/cache:/cache
|
||||||
Volume=/mnt/entropy:/data:ro
|
# The view, writable: Jellyfin is the metadata writer (movie.nfo / tvshow.nfo /
|
||||||
|
# episode.nfo + artwork) and those bytes belong on the SSD. The condition is the
|
||||||
|
# fail-closed guard — starting against a plain /mnt/media directory would let a
|
||||||
|
# scan conclude the library is empty.
|
||||||
|
Volume=/mnt/media:/data
|
||||||
|
UnitConfigItem=ConditionPathIsMountPoint=/mnt/media
|
||||||
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
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ Environment=PUID=0
|
|||||||
Environment=PGID=0
|
Environment=PGID=0
|
||||||
Environment=TZ=America/Los_Angeles
|
Environment=TZ=America/Los_Angeles
|
||||||
Volume=/var/lib/plexmediaserver:/config
|
Volume=/var/lib/plexmediaserver:/config
|
||||||
Volume=/mnt/entropy:/mnt/entropy:ro
|
# The container path stays /mnt/entropy on purpose: every path Plex recorded in
|
||||||
|
# its own database is written against it, so swapping the HOST source of this bind
|
||||||
|
# to the overlay view re-points Plex at the sidecars without touching its DB.
|
||||||
|
# Read-only: Plex consumes the NFO files Jellyfin writes and writes no metadata.
|
||||||
|
Volume=/mnt/media:/mnt/entropy:ro
|
||||||
|
UnitConfigItem=ConditionPathIsMountPoint=/mnt/media
|
||||||
PublishPort=32400:32400
|
PublishPort=32400:32400
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
|
|||||||
@@ -421,6 +421,11 @@ def services_for_path(path: str, target: str) -> set[str]:
|
|||||||
"tautulli": {"tautulli"},
|
"tautulli": {"tautulli"},
|
||||||
}
|
}
|
||||||
_add(services, *quadlet_services.get(parts[1], set()))
|
_add(services, *quadlet_services.get(parts[1], set()))
|
||||||
|
if len(parts) >= 2 and parts[1] == "host":
|
||||||
|
# dankserver/host/systemd/mnt-media.mount is the overlay view the two
|
||||||
|
# media servers bind (scripts/media-view.sh installs it on the host),
|
||||||
|
# so editing it restarts jellyfin and plex and nothing else.
|
||||||
|
_add(services, "jellyfin", "plex")
|
||||||
if len(parts) >= 2 and parts[1] == "observability":
|
if len(parts) >= 2 and parts[1] == "observability":
|
||||||
# Dankserver's node-exporter became a Quadlet unit on 2026-09-16
|
# Dankserver's node-exporter became a Quadlet unit on 2026-09-16
|
||||||
# (serverAppConfigs-docs/Infrastructure/Observability.md): its
|
# (serverAppConfigs-docs/Infrastructure/Observability.md): its
|
||||||
@@ -466,6 +471,10 @@ def services_for_path(path: str, target: str) -> set[str]:
|
|||||||
# that service (target_services then keeps it only on the host being
|
# that service (target_services then keeps it only on the host being
|
||||||
# planned). Editing the collector restarts the running exporter.
|
# planned). Editing the collector restarts the running exporter.
|
||||||
_add(services, "gpu-exporter")
|
_add(services, "gpu-exporter")
|
||||||
|
if name == "media-view.sh":
|
||||||
|
# The installer is what creates, mounts and reaps the view both media
|
||||||
|
# servers read, so a change to it is a restart of that pair.
|
||||||
|
_add(services, "jellyfin", "plex")
|
||||||
if name in {"frank-zealot.env", "frank-zealot.service", "serve-frank-zealot.sh"}:
|
if name in {"frank-zealot.env", "frank-zealot.service", "serve-frank-zealot.sh"}:
|
||||||
# llama.cpp frank-zealot (2026-09-16) keeps its unit, env and serve
|
# llama.cpp frank-zealot (2026-09-16) keeps its unit, env and serve
|
||||||
# files here; each one selects frank-zealot only. The ik_llama.cpp
|
# files here; each one selects frank-zealot only. The ik_llama.cpp
|
||||||
|
|||||||
@@ -1202,7 +1202,21 @@ deploy_romm_stack() {
|
|||||||
systemctl --user restart "$service.service"
|
systemctl --user restart "$service.service"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensure_media_view() {
|
||||||
|
# Both media servers read their library through the SSD-backed overlay view
|
||||||
|
# (scripts/media-view.sh, dankserver/host/systemd/mnt-media.mount): the array
|
||||||
|
# below stays read-only and every sidecar Jellyfin writes lands on the NVMe.
|
||||||
|
# Raise it BEFORE either container starts — a start against a bare /mnt/media
|
||||||
|
# directory would let a scan clear the library database. The units also carry
|
||||||
|
# ConditionPathIsMountPoint, so a missing view skips the start rather than
|
||||||
|
# damaging anything. A relayout must be followed by `media-view.sh reap`
|
||||||
|
# (see the Media Relayout page), which this is not.
|
||||||
|
run_privileged bash scripts/media-view.sh install
|
||||||
|
bash scripts/media-view.sh verify
|
||||||
|
}
|
||||||
|
|
||||||
deploy_jellyfin() {
|
deploy_jellyfin() {
|
||||||
|
ensure_media_view
|
||||||
cp "dankserver/jellyfin/quadlet/jellyfin.container" "$quadlet_unit_dir/"
|
cp "dankserver/jellyfin/quadlet/jellyfin.container" "$quadlet_unit_dir/"
|
||||||
systemctl --user daemon-reload
|
systemctl --user daemon-reload
|
||||||
if ! systemctl --user is-active -q jellyfin.service; then
|
if ! systemctl --user is-active -q jellyfin.service; then
|
||||||
@@ -1290,6 +1304,7 @@ deploy_copyparty() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deploy_plex() {
|
deploy_plex() {
|
||||||
|
ensure_media_view
|
||||||
cp "dankserver/plex/quadlet/plex.container" "$quadlet_unit_dir/"
|
cp "dankserver/plex/quadlet/plex.container" "$quadlet_unit_dir/"
|
||||||
systemctl --user daemon-reload
|
systemctl --user daemon-reload
|
||||||
if ! systemctl --user is-active -q plex.service; then
|
if ! systemctl --user is-active -q plex.service; then
|
||||||
|
|||||||
Executable
+234
@@ -0,0 +1,234 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# The SSD-backed metadata view that lets Jellyfin and Plex share one metadata set.
|
||||||
|
#
|
||||||
|
# Both servers read a Kodi-format sidecar (movie.nfo / tvshow.nfo / episode.nfo)
|
||||||
|
# ONLY when it sits next to the media file. The media lives on the RAID6 array at
|
||||||
|
# /mnt/entropy — five spinning disks, deliberately mounted read-only for every
|
||||||
|
# container, and slow to seek for a thousand tiny XML reads. So neither media
|
||||||
|
# server binds the array any more: they bind an OverlayFS merge of it with a
|
||||||
|
# writable layer on the NVMe.
|
||||||
|
#
|
||||||
|
# lowerdir /mnt/entropy RAID6 (ROTA=1) — source of truth, never written
|
||||||
|
# upperdir /var/lib/media-meta/upper NVMe — every sidecar and image byte
|
||||||
|
# workdir /var/lib/media-meta/work NVMe — kernel bookkeeping
|
||||||
|
# Where /mnt/media what jellyfin and plex bind
|
||||||
|
#
|
||||||
|
# Consequences worth knowing:
|
||||||
|
# * A sidecar write is a few KB onto the SSD; the media bytes never move and the
|
||||||
|
# array is still never written by anything automated.
|
||||||
|
# * Deleting through the view writes a whiteout into the upper layer instead of
|
||||||
|
# deleting from the array. Nothing should delete through the view: deletions
|
||||||
|
# go to .AGENTTRASH on the RAW path, and `reap` clears the resulting ghosts.
|
||||||
|
# * inotify events on the lower layer do not cross the merge, so a file that
|
||||||
|
# arrives on the raw array is invisible to a file-system watcher in the view:
|
||||||
|
# trigger a library refresh (see the Media Relayout page).
|
||||||
|
#
|
||||||
|
# Subcommands
|
||||||
|
# plan [--upper-base D] [--lower D] print the resolved shape, touch nothing
|
||||||
|
# verify prove the live mount IS that shape
|
||||||
|
# install [--upper-base D] create the layers, install+enable+start the unit
|
||||||
|
# reap [--apply] [--upper-base D] [--lower D]
|
||||||
|
# list upper-layer entries whose media is gone
|
||||||
|
# status one line per fact a human wants
|
||||||
|
#
|
||||||
|
# install must run as root (it writes /etc/systemd/system and mounts); the others
|
||||||
|
# are read-only and safe unprivileged.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
LOWER=/mnt/entropy
|
||||||
|
UPPER_BASE=/var/lib/media-meta
|
||||||
|
VIEW=/mnt/media
|
||||||
|
UNIT=mnt-media.mount
|
||||||
|
UNIT_DIR=/etc/systemd/system
|
||||||
|
# The rootless containers write as this host uid (the deploy user). The upper
|
||||||
|
# layer has to accept those writes, so it is owned by them.
|
||||||
|
MEDIA_UID="${MEDIA_VIEW_UID:-1000}"
|
||||||
|
MEDIA_GID="${MEDIA_VIEW_GID:-1000}"
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
UNIT_SRC="$REPO_ROOT/dankserver/host/systemd/$UNIT"
|
||||||
|
|
||||||
|
die() {
|
||||||
|
printf 'media-view: %s\n' "$*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
printf 'usage: %s plan|verify|install|reap|status [options]\n' "$0" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd="${1:-}"
|
||||||
|
[ -n "$cmd" ] || usage
|
||||||
|
shift || true
|
||||||
|
|
||||||
|
upper_base="$UPPER_BASE"
|
||||||
|
lower="$LOWER"
|
||||||
|
apply=0
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--upper-base) [ $# -ge 2 ] || usage; upper_base="$2"; shift 2 ;;
|
||||||
|
--lower) [ $# -ge 2 ] || usage; lower="$2"; shift 2 ;;
|
||||||
|
--apply) apply=1; shift ;;
|
||||||
|
*) usage ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
upper="$upper_base/upper"
|
||||||
|
work="$upper_base/work"
|
||||||
|
|
||||||
|
# The device backing a path, and whether the kernel calls it rotational.
|
||||||
|
rotational_of() {
|
||||||
|
local src rota
|
||||||
|
src="$(findmnt -no SOURCE --target "$1" 2>/dev/null || true)"
|
||||||
|
[ -n "$src" ] || die "cannot find a block device behind $1"
|
||||||
|
rota="$(lsblk -no ROTA "$src" 2>/dev/null | head -1 || true)"
|
||||||
|
[ -n "$rota" ] || die "cannot read the rotational flag of $src"
|
||||||
|
printf '%s %s\n' "$src" "$rota"
|
||||||
|
}
|
||||||
|
|
||||||
|
require_upper_base() {
|
||||||
|
[ -d "$upper_base" ] \
|
||||||
|
|| die "refused: upper base $upper_base does not exist (put it on the SSD first)"
|
||||||
|
}
|
||||||
|
|
||||||
|
do_plan() {
|
||||||
|
printf 'lowerdir=%s\nupperdir=%s\nworkdir=%s\nmountpoint=%s\nunit=%s/%s\n' \
|
||||||
|
"$lower" "$upper" "$work" "$VIEW" "$UNIT_DIR" "$UNIT"
|
||||||
|
}
|
||||||
|
|
||||||
|
do_verify() {
|
||||||
|
local fail=0 fst opts src rota
|
||||||
|
if ! mountpoint -q "$VIEW"; then
|
||||||
|
printf 'refused: %s is not a mountpoint — a plain directory here is a\n' "$VIEW" >&2
|
||||||
|
printf ' library that scans itself empty, not a metadata view.\n' >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fst="$(findmnt -no FSTYPE --mountpoint "$VIEW" 2>/dev/null || true)"
|
||||||
|
if [ "$fst" != overlay ]; then
|
||||||
|
printf 'refused: %s is mounted %s, not overlay\n' "$VIEW" "${fst:-nothing}" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
opts="$(findmnt -no OPTIONS --mountpoint "$VIEW" 2>/dev/null || true)"
|
||||||
|
local want
|
||||||
|
for want in "lowerdir=$LOWER" "upperdir=$UPPER_BASE/upper" "workdir=$UPPER_BASE/work"; do
|
||||||
|
case ",$opts," in
|
||||||
|
*",${want},"*) ;;
|
||||||
|
*) printf 'refused: %s options lack %s (got: %s)\n' "$VIEW" "$want" "$opts" >&2
|
||||||
|
fail=1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
read -r src rota <<<"$(rotational_of "$UPPER_BASE")"
|
||||||
|
if [ "$rota" != 0 ]; then
|
||||||
|
printf 'refused: the upper layer sits on %s which is rotational — metadata\n' "$src" >&2
|
||||||
|
printf ' belongs on the SSD, not on the array.\n' >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
if [ ! -w "$UPPER_BASE/upper" ]; then
|
||||||
|
printf 'refused: %s is not writable\n' "$upper" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
[ "$fail" -eq 0 ] || return 1
|
||||||
|
printf 'view ok: %s <- overlay(%s,%s)\n' "$VIEW" "$LOWER" "$UPPER_BASE/upper"
|
||||||
|
}
|
||||||
|
|
||||||
|
do_install() {
|
||||||
|
[ "$(id -u)" -eq 0 ] || die "install must run as root (writes $UNIT_DIR and mounts)"
|
||||||
|
require_upper_base
|
||||||
|
mountpoint -q "$LOWER" \
|
||||||
|
|| die "refused: $LOWER is not mounted; overlaying an empty directory would present an empty library"
|
||||||
|
local src rota
|
||||||
|
read -r src rota <<<"$(rotational_of "$upper_base")"
|
||||||
|
[ "$rota" = 0 ] || die "refused: $upper_base is on $src, a rotational device — the metadata layer must be on the SSD"
|
||||||
|
[ -f "$UNIT_SRC" ] || die "refused: $UNIT_SRC is missing from $REPO_ROOT"
|
||||||
|
install -d -m 0755 -o "$MEDIA_UID" -g "$MEDIA_GID" "$upper" "$work"
|
||||||
|
install -d -m 0700 "$upper_base/.AGENTTRASH"
|
||||||
|
install -m 0644 "$UNIT_SRC" "$UNIT_DIR/$UNIT"
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable "$UNIT" >/dev/null 2>&1 || true
|
||||||
|
systemctl start "$UNIT"
|
||||||
|
do_verify
|
||||||
|
}
|
||||||
|
|
||||||
|
# An upper-layer DIRECTORY with no counterpart below is metadata for a media
|
||||||
|
# folder that has been moved or renamed on the RAW path — a ghost the media
|
||||||
|
# servers would still browse. Files are never candidates on their own: a sidecar
|
||||||
|
# (movie.nfo, an .srt) exists ONLY above, by design, and is exactly what the view
|
||||||
|
# is for. A copy-up whose lower file was deleted by hand is the one case this
|
||||||
|
# leaves in place; the raw path is where deletions belong, so it stays visible.
|
||||||
|
do_reap() {
|
||||||
|
[ -d "$upper" ] || die "refused: $upper does not exist (no upper layer at $upper_base)"
|
||||||
|
stamp="$upper_base/.AGENTTRASH/$(date -u +%Y%m%d-%H%M%S)"
|
||||||
|
count=0
|
||||||
|
ghosts=0
|
||||||
|
reap_walk ""
|
||||||
|
if [ "$count" -eq 0 ] && [ "$ghosts" -eq 0 ]; then
|
||||||
|
printf 'reap: no ghosts under %s\n' "$upper"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [ "$ghosts" -gt 0 ]; then
|
||||||
|
printf 'reap: %d whiteout%s above — someone deleted THROUGH the view.\n' \
|
||||||
|
"$ghosts" "$([ "$ghosts" -eq 1 ] || printf s)" >&2
|
||||||
|
printf ' Leaving them: removing a whiteout un-hides the file it hides.\n' >&2
|
||||||
|
printf ' Deletions belong in .AGENTTRASH on the raw path.\n' >&2
|
||||||
|
fi
|
||||||
|
if [ "$count" -eq 0 ]; then return 0; fi
|
||||||
|
if [ "$apply" -eq 1 ]; then
|
||||||
|
printf 'reap: moved %d entr%s to %s\n' "$count" \
|
||||||
|
"$([ "$count" -eq 1 ] && printf y || printf ies)" "$stamp"
|
||||||
|
else
|
||||||
|
printf 'reap: %d ghost entr%s above; re-run with --apply to move them to %s\n' \
|
||||||
|
"$count" "$([ "$count" -eq 1 ] && printf y || printf ies)" "$stamp"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Descend only where the lower layer still has the directory: a directory that is
|
||||||
|
# missing below is itself the ghost, and its whole subtree goes with it.
|
||||||
|
reap_walk() {
|
||||||
|
local rel="$1" entries abs r name
|
||||||
|
entries="$(find "$upper${rel:+/$rel}" -mindepth 1 -maxdepth 1 -print 2>/dev/null || true)"
|
||||||
|
[ -n "$entries" ] || return 0
|
||||||
|
while IFS= read -r abs; do
|
||||||
|
r="${abs#"$upper"/}"
|
||||||
|
name="${abs##*/}"
|
||||||
|
if [ -c "$abs" ] || printf '%s' "$name" | grep -q '^\.wh\.'; then
|
||||||
|
ghosts=$((ghosts + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [ -e "$lower/$r" ]; then
|
||||||
|
[ -d "$abs" ] && reap_walk "$r"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
[ -d "$abs" ] || continue
|
||||||
|
count=$((count + 1))
|
||||||
|
printf '%s\n' "$r"
|
||||||
|
if [ "$apply" -eq 1 ]; then
|
||||||
|
mkdir -p "$stamp/$(dirname "$r")"
|
||||||
|
mv "$abs" "$stamp/$r"
|
||||||
|
fi
|
||||||
|
done <<<"$entries"
|
||||||
|
}
|
||||||
|
|
||||||
|
do_status() {
|
||||||
|
if mountpoint -q "$VIEW"; then
|
||||||
|
printf 'view: %s (%s)\n' "$VIEW" "$(findmnt -no OPTIONS --mountpoint "$VIEW")"
|
||||||
|
else
|
||||||
|
printf 'view: %s NOT MOUNTED\n' "$VIEW"
|
||||||
|
fi
|
||||||
|
if [ -d "$upper" ]; then
|
||||||
|
printf 'upper: %s %s files %s\n' "$upper" \
|
||||||
|
"$(find "$upper" -type f 2>/dev/null | wc -l)" "$(du -sh "$upper" 2>/dev/null | cut -f1)"
|
||||||
|
else
|
||||||
|
printf 'upper: %s missing\n' "$upper"
|
||||||
|
fi
|
||||||
|
systemctl is-active "$UNIT" 2>/dev/null | sed 's/^/unit: /' || printf 'unit: %s unknown\n' "$UNIT"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$cmd" in
|
||||||
|
plan) do_plan ;;
|
||||||
|
verify) do_verify ;;
|
||||||
|
install) do_install ;;
|
||||||
|
reap) do_reap ;;
|
||||||
|
status) do_status ;;
|
||||||
|
*) usage ;;
|
||||||
|
esac
|
||||||
@@ -94,6 +94,16 @@ for path in "${CHANGED[@]}"; do
|
|||||||
dankserver/.env.example|dankclaw/.env.example)
|
dankserver/.env.example|dankclaw/.env.example)
|
||||||
add_test tests/env_consistency.bats
|
add_test tests/env_consistency.bats
|
||||||
;;
|
;;
|
||||||
|
# The SSD-backed metadata view: the installer, the host mount unit, and the
|
||||||
|
# two media units that bind the view rather than the raw array.
|
||||||
|
scripts/media-view.sh|dankserver/host/*)
|
||||||
|
add_test tests/media_overlay.bats
|
||||||
|
add_test tests/dankserver_quadlet.bats
|
||||||
|
add_test tests/deployment_scoping.bats
|
||||||
|
;;
|
||||||
|
dankserver/jellyfin/quadlet/*|dankserver/plex/quadlet/*)
|
||||||
|
add_test tests/media_overlay.bats
|
||||||
|
;;
|
||||||
shared/homepage/*)
|
shared/homepage/*)
|
||||||
add_test tests/homepage_caddy.bats
|
add_test tests/homepage_caddy.bats
|
||||||
add_test tests/homepage_config.bats
|
add_test tests/homepage_config.bats
|
||||||
|
|||||||
Executable
+154
@@ -0,0 +1,154 @@
|
|||||||
|
#!/usr/bin/env bats
|
||||||
|
# Seam: the SSD-backed metadata view (OverlayFS) that lets Jellyfin write
|
||||||
|
# Kodi-format sidecars for Plex to read, without ever writing to the spindle
|
||||||
|
# array and without letting either media server start against a missing view.
|
||||||
|
#
|
||||||
|
# The contracts, in the order the seam tests them:
|
||||||
|
# 1. the plan puts the writable layer on the SSD and the media layer below it;
|
||||||
|
# 2. bad input is refused by name instead of mounting something surprising;
|
||||||
|
# 3. verify refuses a view that is not mounted (so nothing trusts a directory);
|
||||||
|
# 4. both media units bind the VIEW and refuse to start without it;
|
||||||
|
# 5. only those two units touch the view — the LLM and library tenants keep the
|
||||||
|
# raw volume, so the blast radius is two containers;
|
||||||
|
# 6. the deployer brings the view up BEFORE it restarts either server;
|
||||||
|
# 7. the planner routes a change to these files to both media services.
|
||||||
|
|
||||||
|
load helpers
|
||||||
|
|
||||||
|
VIEW="$REPO_ROOT/scripts/media-view.sh"
|
||||||
|
JELLY="$REPO_ROOT/dankserver/jellyfin/quadlet/jellyfin.container"
|
||||||
|
PLEX="$REPO_ROOT/dankserver/plex/quadlet/plex.container"
|
||||||
|
UNIT="$REPO_ROOT/dankserver/host/systemd/mnt-media.mount"
|
||||||
|
|
||||||
|
@test "the plan names an overlay with the spindle below and the SSD above" {
|
||||||
|
run bash "$VIEW" plan
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
# Lower = the array; upper/work = the NVMe. A user asked for metadata to be
|
||||||
|
# SSD-resident, so a spindle upper layer would defeat the whole mechanism.
|
||||||
|
[ "${lines[0]}" = "lowerdir=/mnt/entropy" ]
|
||||||
|
printf '%s\n' "${lines[@]}" | grep -qx 'upperdir=/var/lib/media-meta/upper'
|
||||||
|
printf '%s\n' "${lines[@]}" | grep -qx 'workdir=/var/lib/media-meta/work'
|
||||||
|
printf '%s\n' "${lines[@]}" | grep -qx 'mountpoint=/mnt/media'
|
||||||
|
printf '%s\n' "${lines[@]}" | grep -qx 'unit=/etc/systemd/system/mnt-media.mount'
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "install refuses a caller who cannot write the unit directory or mount" {
|
||||||
|
# Half an install is worse than none: the unit would exist without the
|
||||||
|
# directories behind it. Refuse by name before anything is created.
|
||||||
|
if [ "$(id -u)" -eq 0 ]; then
|
||||||
|
skip "this seam is running as root"
|
||||||
|
fi
|
||||||
|
run bash "$VIEW" install
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$output" == *"root"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "verify refuses a view that is not mounted" {
|
||||||
|
# Every consumer trusts /mnt/media to BE the library. On a host where it is a
|
||||||
|
# plain empty directory, verify must fail loudly: that is the difference
|
||||||
|
# between a scan that finds nothing and a scan that empties a library DB.
|
||||||
|
if mountpoint -q /mnt/media; then
|
||||||
|
skip "/mnt/media is a real mountpoint on this host"
|
||||||
|
fi
|
||||||
|
run bash "$VIEW" verify
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$output" == *"/mnt/media"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "both media servers bind the view and refuse to start without it" {
|
||||||
|
for unit in "$JELLY" "$PLEX"; do
|
||||||
|
grep -q '^Volume=/mnt/media:' "$unit" || return 1
|
||||||
|
grep -q '^UnitConfigItem=ConditionPathIsMountPoint=/mnt/media$' "$unit" || return 1
|
||||||
|
# The raw array must no longer be a container source: that bind is what
|
||||||
|
# made sidecar sharing impossible (read-only) and metadata slow (seeks).
|
||||||
|
! grep -q '^Volume=/mnt/entropy' "$unit"
|
||||||
|
done
|
||||||
|
# Jellyfin is the writer, so it needs the view writable; Plex only reads the
|
||||||
|
# sidecars, and keeping its bind read-only costs nothing.
|
||||||
|
grep -q '^Volume=/mnt/media:/data$' "$JELLY"
|
||||||
|
grep -q '^Volume=/mnt/media:/mnt/entropy:ro$' "$PLEX"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "only the two media units reference the view" {
|
||||||
|
run grep -rl --include='*.container' '/mnt/media' "$REPO_ROOT"/dankserver \
|
||||||
|
"$REPO_ROOT"/dankclaw "$REPO_ROOT"/frankbook-pro 2>/dev/null
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ "$(printf '%s\n' "$output" | wc -l)" -eq 2 ]
|
||||||
|
printf '%s\n' "$output" | grep -q 'jellyfin/quadlet/jellyfin.container'
|
||||||
|
printf '%s\n' "$output" | grep -q 'plex/quadlet/plex.container'
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the mount unit is an overlay whose upper layer lives on the SSD" {
|
||||||
|
[ -f "$UNIT" ]
|
||||||
|
run grep -E '^Options=lowerdir=/mnt/entropy,upperdir=/var/lib/media-meta/upper,workdir=/var/lib/media-meta/work' "$UNIT"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
# It must wait for the array to be unlocked: mounting an overlay whose lower
|
||||||
|
# dir is an empty /mnt/entropy would present an empty library, not an error.
|
||||||
|
run grep -E '^RequiresMountsFor=/mnt/entropy$' "$UNIT"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the deployer raises the view before it restarts either media server" {
|
||||||
|
for svc in jellyfin plex; do
|
||||||
|
body="$(sed -n "/^deploy_${svc}()/,/^\}/p" "$REPO_ROOT/scripts/deploy-remote.sh")"
|
||||||
|
[ -n "$body" ]
|
||||||
|
up="$(printf '%s\n' "$body" | grep -n 'ensure_media_view' | head -1 | cut -d: -f1)"
|
||||||
|
up_line="${up:-999}"
|
||||||
|
restart="$(printf '%s\n' "$body" | grep -n "systemctl --user restart $svc.service" | head -1 | cut -d: -f1)"
|
||||||
|
restart_line="${restart:-0}"
|
||||||
|
[ "$up_line" -lt "$restart_line" ]
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the planner routes the view files to both media services" {
|
||||||
|
run python3 "$REPO_ROOT/scripts/deploy-plan.py" --target dankserver --paths \
|
||||||
|
scripts/media-view.sh dankserver/host/systemd/mnt-media.mount
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ "$output" = "jellyfin
|
||||||
|
plex" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a docs-only change still plans nothing for the view" {
|
||||||
|
run python3 "$REPO_ROOT/scripts/deploy-plan.py" --target dankserver --paths \
|
||||||
|
"serverAppConfigs-docs/Dankserver/Media Metadata View.md"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ -z "$output" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "reap names the metadata whose media has moved and touches nothing" {
|
||||||
|
# A relayout moves media on the RAW path; the sidecars the view already wrote
|
||||||
|
# stay behind at the old coordinates. Left alone they become folders a media
|
||||||
|
# server browses and finds nothing in.
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
mkdir -p "$tmp/meta/upper/Movies/Ghost (2020)" "$tmp/meta/upper/Movies/Keeper (2020)" \
|
||||||
|
"$tmp/meta/upper/TVShows/Big Little Lies/Season 01" "$tmp/meta/work" \
|
||||||
|
"$tmp/array/Movies/Keeper (2020)" "$tmp/array/TVShows"
|
||||||
|
: > "$tmp/meta/upper/Movies/Ghost (2020)/movie.nfo"
|
||||||
|
: > "$tmp/meta/upper/Movies/Keeper (2020)/movie.nfo"
|
||||||
|
: > "$tmp/meta/upper/TVShows/Big Little Lies/Season 01/show.nfo"
|
||||||
|
: > "$tmp/array/Movies/Keeper (2020)/Keeper (2020).mkv"
|
||||||
|
|
||||||
|
run bash "$VIEW" reap --upper-base "$tmp/meta" --lower "$tmp/array"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
printf '%s\n' "$output" | grep -qxF 'Movies/Ghost (2020)'
|
||||||
|
# The series folder is still below, so the ghost is the season inside it — and
|
||||||
|
# it is reported at the level that is actually missing, not deeper.
|
||||||
|
printf '%s\n' "$output" | grep -qxF 'TVShows/Big Little Lies'
|
||||||
|
! printf '%s\n' "$output" | grep -q 'Keeper'
|
||||||
|
[ -f "$tmp/meta/upper/Movies/Ghost (2020)/movie.nfo" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "reap --apply moves ghosts to the SSD trash and keeps live metadata" {
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
mkdir -p "$tmp/meta/upper/Movies/Ghost (2020)" "$tmp/meta/upper/Movies/Keeper (2020)" \
|
||||||
|
"$tmp/array/Movies/Keeper (2020)"
|
||||||
|
: > "$tmp/meta/upper/Movies/Ghost (2020)/movie.nfo"
|
||||||
|
: > "$tmp/meta/upper/Movies/Keeper (2020)/movie.nfo"
|
||||||
|
|
||||||
|
run bash "$VIEW" reap --apply --upper-base "$tmp/meta" --lower "$tmp/array"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ ! -e "$tmp/meta/upper/Movies/Ghost (2020)" ]
|
||||||
|
[ -f "$tmp/meta/upper/Movies/Keeper (2020)/movie.nfo" ]
|
||||||
|
# Recoverable, not deleted: the item lands in a dated trash under the SSD base.
|
||||||
|
[ "$(find "$tmp/meta/.AGENTTRASH" -name movie.nfo | wc -l)" -eq 1 ]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user