feat(media): share one metadata set between Jellyfin and Plex over an SSD overlay
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).
This commit is contained in:
1 parent
86f5903e74
commit
3e4b4f24f7
8 files changed
+465
-4
No files matched your search
Executable
+154
@@ -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 ]
|
||||
}
|
||||
Reference in new issue
Block a user