diff --git a/dankserver/host/systemd/mnt-media.mount b/dankserver/host/systemd/mnt-media.mount new file mode 100644 index 0000000..7bef3ed --- /dev/null +++ b/dankserver/host/systemd/mnt-media.mount @@ -0,0 +1,28 @@ +# /etc/systemd/system/mnt-media.mount +# +# Installed by `scripts/media-view.sh install` — the repo copy is the source of +# truth (fleet law: never edit host state by hand). +# +# An OverlayFS merge that shows the media library as one tree while splitting +# where its bytes live: lowerdir is the RAID6 array (the source of truth, never +# written), upperdir is a directory on the NVMe that receives every write. The +# two media servers bind the result at /mnt/media, so Jellyfin's Kodi-format +# sidecars and artwork are SSD bytes — which is what makes them fast to browse +# and what keeps Plex's NFO agent off the spinners — while the media itself stays +# on the array untouched. +# +# RequiresMountsFor is load-bearing: without the array mounted, lowerdir is an +# empty directory and the view would present an empty library as truth. + +[Unit] +Description=SSD-backed overlay view of the media library (array + NVMe metadata layer) +RequiresMountsFor=/mnt/entropy + +[Mount] +What=overlay +Where=/mnt/media +Type=overlay +Options=lowerdir=/mnt/entropy,upperdir=/var/lib/media-meta/upper,workdir=/var/lib/media-meta/work + +[Install] +WantedBy=local-fs.target diff --git a/dankserver/jellyfin/quadlet/jellyfin.container b/dankserver/jellyfin/quadlet/jellyfin.container index afa45a5..c198013 100644 --- a/dankserver/jellyfin/quadlet/jellyfin.container +++ b/dankserver/jellyfin/quadlet/jellyfin.container @@ -4,8 +4,9 @@ # Unit name: jellyfin.service (podman 5.x drops the container- prefix) # # Data roots migrated from /opt/jellyfin to /home/dank/Containers/jellyfin at -# cutover. All of /mnt/entropy is mounted read-only at /data: nothing automated -# anywhere writes to the entropy volume. Every +# cutover. The library is seen through /mnt/media, the SSD-backed overlay view +# (scripts/media-view.sh): the array stays read-only and untouched, while the +# sidecars this server writes land on the NVMe. Every # creation-time environment variable of the compose-era container is kept as # an Environment= line (zero drift); none of them are secrets, so this unit # needs no EnvironmentFile. @@ -41,7 +42,12 @@ PublishPort=7359:7359/udp PublishPort=1900:1900/udp Volume=/home/dank/Containers/jellyfin/config:/config Volume=/home/dank/Containers/jellyfin/cache:/cache -Volume=/mnt/entropy:/data:ro +# The view, writable: Jellyfin is the metadata writer (movie.nfo / tvshow.nfo / +# episode.nfo + artwork) and those bytes belong on the SSD. The condition is the +# fail-closed guard — starting against a plain /mnt/media directory would let a +# scan conclude the library is empty. +Volume=/mnt/media:/data +UnitConfigItem=ConditionPathIsMountPoint=/mnt/media AddDevice=/dev/dri GroupAdd=keep-groups HealthCmd=curl -f http://localhost:8096/health diff --git a/dankserver/plex/quadlet/plex.container b/dankserver/plex/quadlet/plex.container index 60a36d6..d8d22ce 100644 --- a/dankserver/plex/quadlet/plex.container +++ b/dankserver/plex/quadlet/plex.container @@ -11,7 +11,12 @@ Environment=PUID=0 Environment=PGID=0 Environment=TZ=America/Los_Angeles Volume=/var/lib/plexmediaserver:/config -Volume=/mnt/entropy:/mnt/entropy:ro +# The container path stays /mnt/entropy on purpose: every path Plex recorded in +# its own database is written against it, so swapping the HOST source of this bind +# to the overlay view re-points Plex at the sidecars without touching its DB. +# Read-only: Plex consumes the NFO files Jellyfin writes and writes no metadata. +Volume=/mnt/media:/mnt/entropy:ro +UnitConfigItem=ConditionPathIsMountPoint=/mnt/media PublishPort=32400:32400 [Service] diff --git a/scripts/deploy-plan.py b/scripts/deploy-plan.py index 009fb72..3d1819d 100755 --- a/scripts/deploy-plan.py +++ b/scripts/deploy-plan.py @@ -421,6 +421,11 @@ def services_for_path(path: str, target: str) -> set[str]: "tautulli": {"tautulli"}, } _add(services, *quadlet_services.get(parts[1], set())) + if len(parts) >= 2 and parts[1] == "host": + # dankserver/host/systemd/mnt-media.mount is the overlay view the two + # media servers bind (scripts/media-view.sh installs it on the host), + # so editing it restarts jellyfin and plex and nothing else. + _add(services, "jellyfin", "plex") if len(parts) >= 2 and parts[1] == "observability": # Dankserver's node-exporter became a Quadlet unit on 2026-09-16 # (serverAppConfigs-docs/Infrastructure/Observability.md): its @@ -466,6 +471,10 @@ def services_for_path(path: str, target: str) -> set[str]: # that service (target_services then keeps it only on the host being # planned). Editing the collector restarts the running exporter. _add(services, "gpu-exporter") + if name == "media-view.sh": + # The installer is what creates, mounts and reaps the view both media + # servers read, so a change to it is a restart of that pair. + _add(services, "jellyfin", "plex") if name in {"frank-zealot.env", "frank-zealot.service", "serve-frank-zealot.sh"}: # llama.cpp frank-zealot (2026-09-16) keeps its unit, env and serve # files here; each one selects frank-zealot only. The ik_llama.cpp diff --git a/scripts/deploy-remote.sh b/scripts/deploy-remote.sh index 3042835..f7ad82e 100755 --- a/scripts/deploy-remote.sh +++ b/scripts/deploy-remote.sh @@ -1202,7 +1202,21 @@ deploy_romm_stack() { systemctl --user restart "$service.service" } +ensure_media_view() { + # Both media servers read their library through the SSD-backed overlay view + # (scripts/media-view.sh, dankserver/host/systemd/mnt-media.mount): the array + # below stays read-only and every sidecar Jellyfin writes lands on the NVMe. + # Raise it BEFORE either container starts — a start against a bare /mnt/media + # directory would let a scan clear the library database. The units also carry + # ConditionPathIsMountPoint, so a missing view skips the start rather than + # damaging anything. A relayout must be followed by `media-view.sh reap` + # (see the Media Relayout page), which this is not. + run_privileged bash scripts/media-view.sh install + bash scripts/media-view.sh verify +} + deploy_jellyfin() { + ensure_media_view cp "dankserver/jellyfin/quadlet/jellyfin.container" "$quadlet_unit_dir/" systemctl --user daemon-reload if ! systemctl --user is-active -q jellyfin.service; then @@ -1290,6 +1304,7 @@ deploy_copyparty() { } deploy_plex() { + ensure_media_view cp "dankserver/plex/quadlet/plex.container" "$quadlet_unit_dir/" systemctl --user daemon-reload if ! systemctl --user is-active -q plex.service; then diff --git a/scripts/media-view.sh b/scripts/media-view.sh new file mode 100755 index 0000000..16ade4c --- /dev/null +++ b/scripts/media-view.sh @@ -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 diff --git a/scripts/test-changed.sh b/scripts/test-changed.sh index 4c5b9aa..3d95496 100755 --- a/scripts/test-changed.sh +++ b/scripts/test-changed.sh @@ -94,6 +94,16 @@ for path in "${CHANGED[@]}"; do dankserver/.env.example|dankclaw/.env.example) add_test tests/env_consistency.bats ;; + # The SSD-backed metadata view: the installer, the host mount unit, and the + # two media units that bind the view rather than the raw array. + scripts/media-view.sh|dankserver/host/*) + add_test tests/media_overlay.bats + add_test tests/dankserver_quadlet.bats + add_test tests/deployment_scoping.bats + ;; + dankserver/jellyfin/quadlet/*|dankserver/plex/quadlet/*) + add_test tests/media_overlay.bats + ;; shared/homepage/*) add_test tests/homepage_caddy.bats add_test tests/homepage_config.bats diff --git a/tests/media_overlay.bats b/tests/media_overlay.bats new file mode 100755 index 0000000..0290d90 --- /dev/null +++ b/tests/media_overlay.bats @@ -0,0 +1,154 @@ +#!/usr/bin/env bats +# Seam: the SSD-backed metadata view (OverlayFS) that lets Jellyfin write +# Kodi-format sidecars for Plex to read, without ever writing to the spindle +# array and without letting either media server start against a missing view. +# +# The contracts, in the order the seam tests them: +# 1. the plan puts the writable layer on the SSD and the media layer below it; +# 2. bad input is refused by name instead of mounting something surprising; +# 3. verify refuses a view that is not mounted (so nothing trusts a directory); +# 4. both media units bind the VIEW and refuse to start without it; +# 5. only those two units touch the view — the LLM and library tenants keep the +# raw volume, so the blast radius is two containers; +# 6. the deployer brings the view up BEFORE it restarts either server; +# 7. the planner routes a change to these files to both media services. + +load helpers + +VIEW="$REPO_ROOT/scripts/media-view.sh" +JELLY="$REPO_ROOT/dankserver/jellyfin/quadlet/jellyfin.container" +PLEX="$REPO_ROOT/dankserver/plex/quadlet/plex.container" +UNIT="$REPO_ROOT/dankserver/host/systemd/mnt-media.mount" + +@test "the plan names an overlay with the spindle below and the SSD above" { + run bash "$VIEW" plan + [ "$status" -eq 0 ] + # Lower = the array; upper/work = the NVMe. A user asked for metadata to be + # SSD-resident, so a spindle upper layer would defeat the whole mechanism. + [ "${lines[0]}" = "lowerdir=/mnt/entropy" ] + printf '%s\n' "${lines[@]}" | grep -qx 'upperdir=/var/lib/media-meta/upper' + printf '%s\n' "${lines[@]}" | grep -qx 'workdir=/var/lib/media-meta/work' + printf '%s\n' "${lines[@]}" | grep -qx 'mountpoint=/mnt/media' + printf '%s\n' "${lines[@]}" | grep -qx 'unit=/etc/systemd/system/mnt-media.mount' +} + +@test "install refuses a caller who cannot write the unit directory or mount" { + # Half an install is worse than none: the unit would exist without the + # directories behind it. Refuse by name before anything is created. + if [ "$(id -u)" -eq 0 ]; then + skip "this seam is running as root" + fi + run bash "$VIEW" install + [ "$status" -ne 0 ] + [[ "$output" == *"root"* ]] +} + +@test "verify refuses a view that is not mounted" { + # Every consumer trusts /mnt/media to BE the library. On a host where it is a + # plain empty directory, verify must fail loudly: that is the difference + # between a scan that finds nothing and a scan that empties a library DB. + if mountpoint -q /mnt/media; then + skip "/mnt/media is a real mountpoint on this host" + fi + run bash "$VIEW" verify + [ "$status" -ne 0 ] + [[ "$output" == *"/mnt/media"* ]] +} + +@test "both media servers bind the view and refuse to start without it" { + for unit in "$JELLY" "$PLEX"; do + grep -q '^Volume=/mnt/media:' "$unit" || return 1 + grep -q '^UnitConfigItem=ConditionPathIsMountPoint=/mnt/media$' "$unit" || return 1 + # The raw array must no longer be a container source: that bind is what + # made sidecar sharing impossible (read-only) and metadata slow (seeks). + ! grep -q '^Volume=/mnt/entropy' "$unit" + done + # Jellyfin is the writer, so it needs the view writable; Plex only reads the + # sidecars, and keeping its bind read-only costs nothing. + grep -q '^Volume=/mnt/media:/data$' "$JELLY" + grep -q '^Volume=/mnt/media:/mnt/entropy:ro$' "$PLEX" +} + +@test "only the two media units reference the view" { + run grep -rl --include='*.container' '/mnt/media' "$REPO_ROOT"/dankserver \ + "$REPO_ROOT"/dankclaw "$REPO_ROOT"/frankbook-pro 2>/dev/null + [ "$status" -eq 0 ] + [ "$(printf '%s\n' "$output" | wc -l)" -eq 2 ] + printf '%s\n' "$output" | grep -q 'jellyfin/quadlet/jellyfin.container' + printf '%s\n' "$output" | grep -q 'plex/quadlet/plex.container' +} + +@test "the mount unit is an overlay whose upper layer lives on the SSD" { + [ -f "$UNIT" ] + run grep -E '^Options=lowerdir=/mnt/entropy,upperdir=/var/lib/media-meta/upper,workdir=/var/lib/media-meta/work' "$UNIT" + [ "$status" -eq 0 ] + # It must wait for the array to be unlocked: mounting an overlay whose lower + # dir is an empty /mnt/entropy would present an empty library, not an error. + run grep -E '^RequiresMountsFor=/mnt/entropy$' "$UNIT" + [ "$status" -eq 0 ] +} + +@test "the deployer raises the view before it restarts either media server" { + for svc in jellyfin plex; do + body="$(sed -n "/^deploy_${svc}()/,/^\}/p" "$REPO_ROOT/scripts/deploy-remote.sh")" + [ -n "$body" ] + up="$(printf '%s\n' "$body" | grep -n 'ensure_media_view' | head -1 | cut -d: -f1)" + up_line="${up:-999}" + restart="$(printf '%s\n' "$body" | grep -n "systemctl --user restart $svc.service" | head -1 | cut -d: -f1)" + restart_line="${restart:-0}" + [ "$up_line" -lt "$restart_line" ] + done +} + +@test "the planner routes the view files to both media services" { + run python3 "$REPO_ROOT/scripts/deploy-plan.py" --target dankserver --paths \ + scripts/media-view.sh dankserver/host/systemd/mnt-media.mount + [ "$status" -eq 0 ] + [ "$output" = "jellyfin +plex" ] +} + +@test "a docs-only change still plans nothing for the view" { + run python3 "$REPO_ROOT/scripts/deploy-plan.py" --target dankserver --paths \ + "serverAppConfigs-docs/Dankserver/Media Metadata View.md" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "reap names the metadata whose media has moved and touches nothing" { + # A relayout moves media on the RAW path; the sidecars the view already wrote + # stay behind at the old coordinates. Left alone they become folders a media + # server browses and finds nothing in. + tmp="$(mktemp -d)" + mkdir -p "$tmp/meta/upper/Movies/Ghost (2020)" "$tmp/meta/upper/Movies/Keeper (2020)" \ + "$tmp/meta/upper/TVShows/Big Little Lies/Season 01" "$tmp/meta/work" \ + "$tmp/array/Movies/Keeper (2020)" "$tmp/array/TVShows" + : > "$tmp/meta/upper/Movies/Ghost (2020)/movie.nfo" + : > "$tmp/meta/upper/Movies/Keeper (2020)/movie.nfo" + : > "$tmp/meta/upper/TVShows/Big Little Lies/Season 01/show.nfo" + : > "$tmp/array/Movies/Keeper (2020)/Keeper (2020).mkv" + + run bash "$VIEW" reap --upper-base "$tmp/meta" --lower "$tmp/array" + [ "$status" -eq 0 ] + printf '%s\n' "$output" | grep -qxF 'Movies/Ghost (2020)' + # The series folder is still below, so the ghost is the season inside it — and + # it is reported at the level that is actually missing, not deeper. + printf '%s\n' "$output" | grep -qxF 'TVShows/Big Little Lies' + ! printf '%s\n' "$output" | grep -q 'Keeper' + [ -f "$tmp/meta/upper/Movies/Ghost (2020)/movie.nfo" ] +} + +@test "reap --apply moves ghosts to the SSD trash and keeps live metadata" { + tmp="$(mktemp -d)" + mkdir -p "$tmp/meta/upper/Movies/Ghost (2020)" "$tmp/meta/upper/Movies/Keeper (2020)" \ + "$tmp/array/Movies/Keeper (2020)" + : > "$tmp/meta/upper/Movies/Ghost (2020)/movie.nfo" + : > "$tmp/meta/upper/Movies/Keeper (2020)/movie.nfo" + + run bash "$VIEW" reap --apply --upper-base "$tmp/meta" --lower "$tmp/array" + [ "$status" -eq 0 ] + [ ! -e "$tmp/meta/upper/Movies/Ghost (2020)" ] + [ -f "$tmp/meta/upper/Movies/Keeper (2020)/movie.nfo" ] + # Recoverable, not deleted: the item lands in a dated trash under the SSD base. + [ "$(find "$tmp/meta/.AGENTTRASH" -name movie.nfo | wc -l)" -eq 1 ] +}