#!/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" MOUNT=/var/lib/media-meta/view UNIT="$REPO_ROOT/dankserver/host/systemd/media-meta-view.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 -Fqx "mountpoint=$MOUNT" # systemd names a mount unit after its escaped path, backslash and all. printf '%s\n' "${lines[@]}" | grep -Fqx \ 'unit=/etc/systemd/system/var-lib-media\x2dmeta-view.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 the mount point 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 "$MOUNT"; then skip "$MOUNT is a real mountpoint on this host" fi run bash "$VIEW" verify [ "$status" -ne 0 ] [[ "$output" == *"$MOUNT"* ]] } @test "both media servers bind the view and refuse to start without it" { for unit in "$JELLY" "$PLEX"; do grep -q "^Volume=$MOUNT:" "$unit" || return 1 grep -q "^UnitConfigItem=ConditionPathIsMountPoint=$MOUNT$" "$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=$MOUNT:/data$" "$JELLY" grep -q "^Volume=$MOUNT:/mnt/entropy:ro$" "$PLEX" } @test "only the two media units reference the view" { run grep -rl --include='*.container' "$MOUNT" "$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 ] # The unit must mount exactly where the two containers bind, or the guard and # the bind disagree and a media server browses an empty directory. run grep -Fqx "Where=$MOUNT" "$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/media-meta-view.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 ] }