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).
235 lines
9.0 KiB
Bash
Executable File
235 lines
9.0 KiB
Bash
Executable File
#!/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
|