`ensure_media_view` ran `install` on a host where the documented base directory
did not exist, and `install` refuses by design ("put it on the SSD first") — so
tonight's cutover would have died at the restart step with nobody beside the
box. `media-view.sh ensure-base` now makes the documented directory, and the
deployer calls it first; two seams cover it (the subcommand's behaviour, and the
ordering invariant inside `ensure_media_view`).
Docs flip with the deploy: the view is live on Dankserver (both media servers
verified resolving real media through it), so every "never been deployed" claim
in the view, Plex, Jellyfin and AGENTS pages is corrected, and three laws join
them — a container restart discards the in-flight library scan and nothing
resumes it; Jellyfin 12.4 authenticates only the `MediaBrowser Token` form, so a
401 says nothing about the key until the `ApiKeys` table is read; and Plex
answers 32400 over TLS only, which makes a plaintext health probe read as dead.
259 lines
10 KiB
Bash
Executable File
259 lines
10 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 /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:
|
|
# * 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=$UPPER_BASE/view
|
|
# 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
|
|
# 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/media-meta-view.mount"
|
|
|
|
die() {
|
|
printf 'media-view: %s\n' "$*" >&2
|
|
exit 1
|
|
}
|
|
|
|
usage() {
|
|
printf 'usage: %s plan|ensure-base|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)"
|
|
}
|
|
|
|
# The SSD placement of the metadata layer is a human choice, so `install` refuses
|
|
# to invent a base directory: a mistyped --upper-base must not quietly create a
|
|
# tree on whatever filesystem the caller landed on. The deployer owns the
|
|
# documented location, so it calls this first — measured 2026-10-31, when a
|
|
# fresh-host `install` died with "put it on the SSD first" and nothing in git
|
|
# would have created it.
|
|
do_ensure_base() {
|
|
if [ -d "$upper_base" ]; then
|
|
printf 'base: %s exists\n' "$upper_base"
|
|
return 0
|
|
fi
|
|
install -d -m 0755 "$upper_base" 2>/dev/null \
|
|
|| die "refused: cannot create $upper_base — it lives outside the checkout, run this as root"
|
|
printf 'base: created %s\n' "$upper_base"
|
|
}
|
|
|
|
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"
|
|
# 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 -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 ;;
|
|
ensure-base) do_ensure_base ;;
|
|
install) do_install ;;
|
|
reap) do_reap ;;
|
|
status) do_status ;;
|
|
*) usage ;;
|
|
esac
|