Both media servers read a Kodi-format sidecar (movie.nfo / tvshow.nfo / episode.nfo) only when it sits next to the media file, and the library lives on the RAID6 array, deliberately mounted read-only for every container and slow to seek for a thousand tiny XML reads. So neither server binds the array any more: both bind /mnt/media, an OverlayFS merge of the array (lowerdir, never written) with /var/lib/media-meta on the NVMe (upperdir, where every write lands). Jellyfin binds the view rw at /data and is the fleet's only metadata writer; Plex binds it ro at /mnt/entropy, the container path its own database already recorded, so only the host side of that bind moved and its library paths keep resolving. scripts/media-view.sh plans, installs, verifies, reports and reaps the view. It refuses to mount over an unmounted array, refuses an upper layer on rotational storage, and refuses a non-root install. deploy-remote.sh raises the view before it restarts either media server, both units carry ConditionPathIsMountPoint=/mnt/media so a missing view skips the start instead of letting a scan clear the library database, and the planner routes the installer and dankserver/host/** to the jellyfin+plex pair. tests/media_overlay.bats (11 seams) pins the resolved shape, both refusals, the two binds and their guard, the deployer's ordering, the planner mapping, and the reap behaviour against a fixture (ghosts listed, then moved to the SSD trash -- files that exist only above are sidecars, not ghosts, so only directories are candidates).
155 lines
7.2 KiB
Bash
Executable File
155 lines
7.2 KiB
Bash
Executable File
#!/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 ]
|
|
}
|