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.
162 lines
7.6 KiB
Bash
Executable File
162 lines
7.6 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"
|
|
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 ]
|
|
}
|