feat(media): share one metadata set between Jellyfin and Plex over an SSD overlay

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).
This commit is contained in:
Frank committed 2026-10-30 21:41:51 -07:00
1 parent 86f5903e74
commit 3e4b4f24f7
8 files changed
+465 -4

No files matched your search

+234
View File
@@ -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