The merged door was /mnt/media, which read like a third volume and split the host state across two trees. It is now /var/lib/media-meta/view, so the array layer, the SSD upper layer, the kernel workdir, the reap trash and the merged tree are one directory — and the mount table gains no new /mnt entry beside the array. systemd names a mount unit after its ESCAPED path, so the repo copy keeps the readable name media-meta-view.mount while `install` writes the host file as var-lib-media\x2dmeta-view.mount; `plan` prints both and the page records them, so nobody hunts for a unit that "does not exist". install also creates the mount point itself, because systemd will not. The seam now additionally pins that the unit's Where= is exactly the path both containers bind — guard and bind cannot drift apart. The Quadlet Lifecycle phantom-path example is corrected too: it named an invented /mnt/media/*, while the real phantom is the legacy /opt/jellyfin/* that dankserver/compose.yaml still carries for a service whose roots moved to /home/dank/Containers/jellyfin.
242 lines
9.6 KiB
Bash
Executable File
242 lines
9.6 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|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"
|
|
# 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 ;;
|
|
install) do_install ;;
|
|
reap) do_reap ;;
|
|
status) do_status ;;
|
|
*) usage ;;
|
|
esac
|