#!/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