diff --git a/AGENTS.md b/AGENTS.md index eb0b9ea..1f4a587 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -808,6 +808,7 @@ per-attempt page and no per-swap ledger anywhere else. | Dankclaw kernel posture (`iommu=pt` since 2026-10-18) | `serverAppConfigs-docs/LLM/dankclaw-iommu-translation-off.md` | | Deployment flow | `serverAppConfigs-docs/Architecture/Deployment Flow.md` | | Movie-library relayout (Jellyfin format, Dankserver) | `serverAppConfigs-docs/Dankserver/Media Relayout.md` | +| SSD-backed metadata view (Jellyfin writes, Plex reads, Dankserver) | `serverAppConfigs-docs/Dankserver/Media Metadata View.md` | There is no page for a retired endpoint and no change diary. A payload swap appends one row to the live endpoint's **Eras** table; when the endpoint itself @@ -850,5 +851,6 @@ history. | Benchmarking (only when ordered) | **Stop and mask the service first**, bench, unmask, restart, verify health. | | Deleting a file | **Move it to trash** — `.AGENTTRASH/` (in-repo) or `~/.AGENTTRASH/YYYYMMDD-topic`. Never `rm`. | | Relayout the movie library | **Census → plan → read the exceptions → `scripts/media-apply.py`** — renames only, journal before every move; never hand-rename, never copy. | +| Making media metadata visible to both media servers | **Bind `/var/lib/media-meta/view`** — the overlay `scripts/media-view.sh` installs (array below, NVMe above). Never write the array; never delete through the view. | | Tests exist | **Run them** — verify tests pass after every change. Do not commit failing tests. | | Real secrets | **Never commit** — host `.env` files; `.env.example` holds placeholders only. | diff --git a/dankserver/compose.yaml b/dankserver/compose.yaml index 5dd3dfe..67ee0d8 100644 --- a/dankserver/compose.yaml +++ b/dankserver/compose.yaml @@ -94,34 +94,12 @@ services: timeout: 10s retries: 3 - # ═══════════════════════════════════════════════ - # Jellyfin — media server (movies, TV, music) - # ═══════════════════════════════════════════════ - jellyfin: - image: jellyfin/jellyfin:latest - container_name: jellyfin - restart: unless-stopped - ports: - - "8096:8096/tcp" - - "8920:8920/tcp" - - "7359:7359/udp" - - "1900:1900/udp" - environment: - - JELLYFIN_PublishedServerUrl=http://192.168.0.235:8096 - - DOTNET_SYSTEM_IO_DISABLEFILELOCKING=true - volumes: - - /opt/jellyfin/config:/config - - /opt/jellyfin/cache:/cache - - /mnt/entropy:/data:ro - devices: - - /dev/dri:/dev/dri - group_add: - - "992" - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8096/health"] - interval: 30s - timeout: 10s - retries: 3 + # Jellyfin left this file when it became a Quadlet: the container is owned by + # dankserver/jellyfin/quadlet/jellyfin.container, its data roots are under + # /home/dank/Containers/jellyfin, and its library arrives through the SSD + # overlay at /var/lib/media-meta/view (scripts/media-view.sh). The block that + # was here named /opt/jellyfin/* and a read-only array bind — a second owner for + # the container name `jellyfin`, wrong on every path. One owner per container. # ═══════════════════════════════════════════════ # Calibre-Web — e-book web frontend diff --git a/dankserver/host/systemd/media-meta-view.mount b/dankserver/host/systemd/media-meta-view.mount new file mode 100644 index 0000000..b7a2463 --- /dev/null +++ b/dankserver/host/systemd/media-meta-view.mount @@ -0,0 +1,30 @@ +# /etc/systemd/system/var-lib-media\x2dmeta-view.mount +# (repo copy: dankserver/host/systemd/media-meta-view.mount — systemd names a +# mount unit after its ESCAPED path, so the host file carries the \x2d spelling) +# +# 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 /var/lib/media-meta/view, so Jellyfin's +# 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=/var/lib/media-meta/view +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..4152131 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 /var/lib/media-meta/view, 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, unmounted view directory would let +# a scan conclude the library is empty. +Volume=/var/lib/media-meta/view:/data +UnitConfigItem=ConditionPathIsMountPoint=/var/lib/media-meta/view 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..ca6354e 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=/var/lib/media-meta/view:/mnt/entropy:ro +UnitConfigItem=ConditionPathIsMountPoint=/var/lib/media-meta/view PublishPort=32400:32400 [Service] diff --git a/scripts/deploy-plan.py b/scripts/deploy-plan.py index 009fb72..02b7985 100755 --- a/scripts/deploy-plan.py +++ b/scripts/deploy-plan.py @@ -155,13 +155,14 @@ DANKCLAW_COMPOSE_SERVICES = set() # Quadlet unit now — the last compose services were the hermes trio, whose units # have owned their containers since 2026-09-12 and whose compose blocks were # dead weight that could only create a duplicate container. +# jellyfin left the Dankserver file on 2026-10-30 for the same reason: its Quadlet +# has owned the container since the cutover, so a compose edit must not restart it. DANKSERVER_COMPOSE_SERVICES = { "mariadb", "redis", "romm", "copyparty", - "jellyfin", "calibre-web", } COMPOSE_NAME_MAP = {"mariadb": "romm-mariadb", "redis": "romm-redis"} @@ -421,6 +422,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/media-meta-view.mount is the overlay view the + # 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 +472,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..d9547bc 100755 --- a/scripts/deploy-remote.sh +++ b/scripts/deploy-remote.sh @@ -1202,7 +1202,22 @@ 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/media-meta-view.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 view + # 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 +1305,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-apply.py b/scripts/media-apply.py index 8b66f7b..cf7fe65 100755 --- a/scripts/media-apply.py +++ b/scripts/media-apply.py @@ -114,6 +114,13 @@ def read_plan(path, root, force): return mkdirs, keeps, moves +# A relayout moves bytes on the raw array; the sidecars the media servers wrote +# live in the SSD overlay and stay at the old coordinates. Naming the follow-up +# here is the point of use for that fact — see the Media Metadata View page. +METADATA_HINT = ("note: sidecars written through the metadata view did not follow" + " these moves — next: scripts/media-view.sh reap") + + def do_apply(args, root, journal, mkdirs, keeps, moves): """Preflight everything, then folders, then renames. Never a partial start.""" problems = [] @@ -195,6 +202,7 @@ def do_apply(args, root, journal, mkdirs, keeps, moves): f" {dirs_new} folders created, {len(keeps)} files stayed put") print(f"journal: {journal.path} — undo with: media-apply.py rollback" f" --journal {journal.path} --root {root} --apply") + print(METADATA_HINT) return 0 @@ -378,6 +386,7 @@ def do_rollback(args, root, journal): t=datetime.datetime.now(datetime.timezone.utc).isoformat()) print(f"rolled back: {undone} files back where the census found them," f" {dirs_gone} empty folders removed, {recreated} swept folders recreated") + print(METADATA_HINT) return 0 diff --git a/scripts/media-view.sh b/scripts/media-view.sh new file mode 100755 index 0000000..125289f --- /dev/null +++ b/scripts/media-view.sh @@ -0,0 +1,241 @@ +#!/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 /var/lib/media-meta/view the merged tree jellyfin and plex bind +# +# Everything the view needs — layers, bookkeeping, trash, the merged door — lives +# under the one directory /var/lib/media-meta, so nothing new appears under /mnt. +# +# 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=$UPPER_BASE/view +# systemd names a mount unit after its ESCAPED path ('/' -> '-', a literal '-' +# -> '\x2d'), so the unit on the host is var-lib-media\x2dmeta-view.mount. The +# repo copy keeps a readable name; install writes it under the escaped one. +UNIT='var-lib-media\x2dmeta-view.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/media-meta-view.mount" + +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" + # systemd will not create a mount point; a missing directory fails the start. + install -d -m 0755 -o "$MEDIA_UID" -g "$MEDIA_GID" "$upper" "$work" "$VIEW" + 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/serverAppConfigs-docs/Architecture/Quadlet Lifecycle.md b/serverAppConfigs-docs/Architecture/Quadlet Lifecycle.md index 379fa1c..410d35f 100644 --- a/serverAppConfigs-docs/Architecture/Quadlet Lifecycle.md +++ b/serverAppConfigs-docs/Architecture/Quadlet Lifecycle.md @@ -195,8 +195,9 @@ deploy.sh, never a live env poke. check every namespace (`sudo podman ps -a`) and native units (`systemctl list-units`) before declaring a service absent. Compare compose mount sources against the real filesystem — compose files name - phantom paths (e.g. `/mnt/media/*` vs the real LUKS array at - `/mnt/entropy`). Fix compose through git first, deploy second. + phantom paths (the legacy `dankserver/compose.yaml` still names `/opt/jellyfin/*` + for a service whose roots moved to `/home/dank/Containers/jellyfin`). Fix + compose through git first, deploy second. 2. **Pre-create bind-mount sources** (`sudo mkdir -p` + `chown 1000:1000`) or creation fails `statfs …: no such file or directory`. 3. **Staged bring-up:** infra first (`up -d mariadb redis`), poll diff --git a/serverAppConfigs-docs/Dankserver/Jellyfin.md b/serverAppConfigs-docs/Dankserver/Jellyfin.md index ddeb7e1..741a9f0 100644 --- a/serverAppConfigs-docs/Dankserver/Jellyfin.md +++ b/serverAppConfigs-docs/Dankserver/Jellyfin.md @@ -1,9 +1,9 @@ --- type: reference title: Jellyfin -description: "Jellyfin on Dankserver: quadlet unit, entropy-volume data layout, ports, client access, hardware-transcode status." +description: "Jellyfin on Dankserver: quadlet unit, overlay metadata view layout, ports, access, hardware-transcode status." tags: [jellyfin, dankserver, media-server] -timestamp: 2026-09-03T00:00:00Z +timestamp: 2026-10-30T00:00:00Z --- # Jellyfin @@ -26,7 +26,7 @@ public vhost `jelly.franksdank.cloud` behind the [[Anubis]] guard chain → | Image | `ghcr.io/jellyfin/jellyfin:latest` | | Network | `dankserver_appnet`; publishes 8096/tcp, 8920/tcp, 7359/udp, 1900/udp | | Data roots | `/home/dank/Containers/jellyfin/config:/config`, `/home/dank/Containers/jellyfin/cache:/cache` | -| Media | `Volume=/mnt/entropy:/data:ro` — the whole [[Entropy Volume]] mounted read-only; nothing automated writes to it | +| Media | `Volume=/var/lib/media-meta/view:/data` — the [[Media Metadata View]] overlay, **writable**; sidecars land on the NVMe, the [[Entropy Volume]] array underneath stays read-only and untouched | | GPU | none — no `AddDevice`; CPU decode only (jellyfin-ffmpeg path) | | Env quirks | `DOTNET_SYSTEM_IO_DISABLEFILELOCKING=true`, `LD_PRELOAD=libjemalloc.so.2`, `MALLOC_TRIM_THRESHOLD_=131072` kept verbatim from the compose-era container (zero-drift cutover) | | Health | `curl -f http://localhost:8096/health` every 30 s | @@ -36,20 +36,23 @@ public vhost `jelly.franksdank.cloud` behind the [[Anubis]] guard chain → ``` /home/dank/Containers/jellyfin/config -> /config (JELLYFIN_DATA_DIR) /home/dank/Containers/jellyfin/cache -> /cache (JELLYFIN_CACHE_DIR, XDG_CACHE_HOME) -/mnt/entropy -> /data (read-only media volume) +/var/lib/media-meta/view -> /data (the SSD-backed overlay view) ``` -Library folders point at `/data` paths; because the mount uses the same -absolute path as on the host, library paths survive container recreation. +Library folders point at `/data` paths, which are the view's paths: stable +across container recreation, and identical to the raw array layout below it. -`Local metadata` — save metadata to media folders — cannot work against a `ro` -mount, and it is enabled: every item update logs -`System.IO.IOException: Read-only file system : '/data/Movies//movie.nfo'`. -The exception lands in the sidecar saver, after the database row is written, so -indexing proceeds anyway — a 2026-10-30 scan dropped stale records and added -episodes while these lines filled the log. Expected noise, not a fault: if it -ever needs silencing, turn the saver off under *Library → Metadata* rather than -making `/data` writable. +`Local metadata` — save metadata to media folders — is enabled, and it works +because `/data` is the [[Media Metadata View]] rather than the read-only array: +`movie.nfo`, `tvshow.nfo`, `episode.nfo` and artwork are written once, on the +NVMe upper layer, and [[Plex]] reads the same files. Jellyfin is the fleet's +only metadata writer. + +**Failure signature:** `System.IO.IOException: Read-only file system : +'/data/Movies//movie.nfo'` means the container is binding the raw array +instead of the view — the view is unmounted or the quadlet drifted. That used to +be the expected state (pre-view, every item update logged it); today it is a +fault. Run `scripts/media-view.sh status`. ## Ports & access diff --git a/serverAppConfigs-docs/Dankserver/Media Metadata View.md b/serverAppConfigs-docs/Dankserver/Media Metadata View.md new file mode 100644 index 0000000..296d112 --- /dev/null +++ b/serverAppConfigs-docs/Dankserver/Media Metadata View.md @@ -0,0 +1,117 @@ +--- +type: reference +title: Media Metadata View +description: "The SSD-backed overlay the two media servers read and write: shape, guards, commands, laws, rollback." +tags: [dankserver, media-server, jellyfin, plex, storage] +timestamp: 2026-10-30T00:00:00Z +--- + +# Media Metadata View + +`/var/lib/media-meta/view` is an OverlayFS merge of the media library: the RAID6 +array below, a directory on the NVMe above. It exists so [[Jellyfin]] can write +Kodi-format sidecars (`movie.nfo`, `tvshow.nfo`, `episode.nfo`, artwork) that +[[Plex]] can read. Sidecar metadata is only useful sitting next to the media +file, and the array is read-only and slow — so the media bytes stay there and the +*writes* land on SSD. One tree to both servers, one copy of the media. + +The mount point is not a volume and holds nothing while unmounted. It sits inside +`/var/lib/media-meta` so that layers, kernel bookkeeping, the reap trash and the +merged door are one directory — and so the mount table grows no new `/mnt` entry +next to the array. + +## Shape + +| Item | Value | +|---|---| +| Lower layer | `/mnt/entropy` — the RAID6 array, source of truth, never written | +| Upper layer | `/var/lib/media-meta/upper` — NVMe; every write lands here | +| Work dir | `/var/lib/media-meta/work` — overlayfs internals, same filesystem | +| Mount point | `/var/lib/media-meta/view` — the merged tree both servers bind | +| Unit (repo) | `dankserver/host/systemd/media-meta-view.mount` — the source of truth | +| Unit (host) | `var-lib-media\x2dmeta-view.mount` — systemd names a mount unit after its escaped path (`-` becomes `\x2d`); `media-view.sh install` writes the repo file under that name | +| Installer | `scripts/media-view.sh` — `plan` / `install` / `verify` / `status` / `reap` | +| Written by | Jellyfin only (Plex binds read-only); nothing else in the fleet touches it | + +Both consumers guard the view with +`UnitConfigItem=ConditionPathIsMountPoint=/var/lib/media-meta/view`, and +`scripts/deploy.sh dankserver --service jellyfin|plex` calls `media-view.sh +install` + `verify` before it restarts either container. + +| Consumer | Bind | Mode | Why that path | +|---|---|---|---| +| [[Jellyfin]] | `/var/lib/media-meta/view:/data` | rw | It is the metadata writer; its sidecars become SSD bytes | +| [[Plex]] | `/var/lib/media-meta/view:/mnt/entropy` | ro | Every path in the PMS database was recorded as `/mnt/entropy/...`; only the HOST side of the bind moved, so its database still resolves | + +Putting the sidecars inside Plex's reach is not the same as making Plex use them: +a library parses `.nfo` only with its *Local media information* agent enabled, and +this change touches no library setting. + +## Commands + +```bash +scripts/media-view.sh plan # print the resolved shape, touch nothing +sudo scripts/media-view.sh install # create dirs, install the unit, mount, enable +scripts/media-view.sh verify # is the view really the overlay? (deploy gate) +scripts/media-view.sh status # mount options + upper-layer size/file count +scripts/media-view.sh reap # list ghost metadata (media moved, sidecars left) +sudo scripts/media-view.sh reap --apply # move ghosts to the SSD trash, never delete +``` + +`--upper-base DIR` and `--lower DIR` relocate the layers for `plan` and `reap`; +the defaults are the host values above. + +## Laws + +- **The upper layer must sit on non-rotational storage.** `install` and `verify` + refuse via `lsblk -dno ROTA` if the upper base lives on the array: an overlay + whose upper is the spinners is the original problem with extra steps, and it + silently turns every metadata write back into a seek. +- **Never mount the view over an unmounted lower.** `RequiresMountsFor=/mnt/entropy` + plus the container `ConditionPathIsMountPoint` guards: an empty `lowerdir` + presents an *empty library* as truth, and a media scan would then clear the + database of everything. systemd does not create a mount point, so `install` + creates `view` itself; without it the mount simply fails. +- **Never delete through the view.** A delete writes an overlayfs whiteout + (`.wh.`) in the upper layer, which hides the array's copy without + removing it — the file reappears the moment the view is unmounted, and the + upper layer now carries a landmine. Deletions belong on the raw path + (`/mnt/entropy`), which the relayout tool already treats as the only writer. + `reap` refuses to remove whiteouts and only warns about them. +- **A relayout must be followed by `reap`.** Renames happen on the raw path, so + sidecars the view already wrote stay at the old coordinates: a ghost folder + the servers browse and find nothing in. `reap` lists upper-layer directories + with no counterpart below and moves them (with their subtree) into a dated + `.AGENTTRASH/` under the SSD base. It is recursive and file-safe: a sidecar + that exists only above is normal, so only *directories* are candidates. +- **File-watcher events do not cross the overlay.** Plex's NFO agent and + Jellyfin's real-time monitor see inotify events for writes that land *in the + upper layer*, not for changes on the array underneath. After any relayout or + externally-written metadata, trigger a library refresh — see + [[Media Relayout]] for the refresh procedure. + +## Failure signatures + +| Symptom | Meaning | +|---|---| +| `jellyfin.service` / `plex.service` show `ConditionPathIsMountPoint=... was not met` and stay inactive | The view is not mounted. Do not start them anyway: `scripts/media-view.sh install`, then `verify`. | +| Jellyfin log: `Read-only file system : '/data/Movies/…/movie.nfo'` | The container is binding the raw array, not the view — the quadlet drifted back or the view is gone. `media-view.sh status`. | +| A folder appears in a media server with artwork/`.nfo` but no video | Ghost metadata from a relayout. `media-view.sh reap`. | +| `verify` prints `refused: …/view is not a mountpoint` | A plain directory is sitting where the overlay belongs. That is the fail-closed guard working; `install` on the box. | +| `systemctl status` finds no such unit | Look under the escaped name, `var-lib-media\x2dmeta-view.mount`, not the repo file name. | + +## Rollback + +Revert the commit that pointed the two quadlets at the view and re-deploy both +services — they bind `/mnt/entropy` read-only again, which is the pre-view shape. +The NVMe upper layer can stay: nothing reads it once the binds are back, and it +holds no state either server needs. Unmounting alone +(`systemctl stop 'var-lib-media\x2dmeta-view.mount'`) is safe while no container +binds it, and the array is never modified by any of this. + +## Links + +- [[Jellyfin]] — the metadata writer; its data layout section. +- [[Plex]] — the read-only consumer; in-place bind-mount history. +- [[Media Relayout]] — the tool that moves media on the raw path and the reason `reap` exists. +- [[Entropy Volume]] — the array underneath. diff --git a/serverAppConfigs-docs/Dankserver/Media Relayout.md b/serverAppConfigs-docs/Dankserver/Media Relayout.md index 64e824c..f5bdaf5 100644 --- a/serverAppConfigs-docs/Dankserver/Media Relayout.md +++ b/serverAppConfigs-docs/Dankserver/Media Relayout.md @@ -250,6 +250,18 @@ show. A name whose marker is `Part 2` is a film and stays put. path outside `--root`, and a journal shared with the movie relayout would undo both runs when only one of them is wanted. +- **Sidecar metadata does not follow a rename, because it lives on another + filesystem.** The media servers write `.nfo` and artwork through the + [[Media Metadata View]] (NVMe upper layer); this tool moves bytes on the raw + array path. After any sweep or TV move, run `scripts/media-view.sh reap`, then + the same command with `--apply`: the sidecars left at the old coordinates become + ghost folders — artwork and an `.nfo` with no video — that both servers browse + and report as content. +- **Nothing deletes through the metadata view.** A delete there writes an overlayfs + whiteout in the upper layer, which hides the array's copy without removing it; + the file returns when the view is unmounted. Deletions in this pipeline happen + on the raw path only, which is what the journal can undo. + ## Rollback One command, from the journal of the run: @@ -260,7 +272,7 @@ file cannot be accounted for. A rolled-back journal refuses to roll back twice. ## See Also -[[Jellyfin]], [[Plex]], [[Entropy Volume]], [[Testing Policy]], [[Storage Policy]] +[[Jellyfin]], [[Plex]], [[Media Metadata View]], [[Entropy Volume]], [[Testing Policy]], [[Storage Policy]] ## Citations diff --git a/serverAppConfigs-docs/Dankserver/Plex.md b/serverAppConfigs-docs/Dankserver/Plex.md index a6d79a1..ec35283 100644 --- a/serverAppConfigs-docs/Dankserver/Plex.md +++ b/serverAppConfigs-docs/Dankserver/Plex.md @@ -4,7 +4,7 @@ description: "Plex Media Server is a **containerized media server on Dankserver* tags: - dankserver - plex -timestamp: 2026-10-21 +timestamp: 2026-10-30 --- # Plex @@ -13,7 +13,8 @@ Plex Media Server is a **containerized media server on Dankserver**: a podman quadlet (`plex.service`) under the systemd **user** generator, generated from `dankserver/plex/quadlet/plex.container`. It shares the host with a second live media server, [[Jellyfin]] (`jellyfin.service`, listening on 8096); both serve -from the same read-only Entropy Volume mount. +from the same tree — the [[Media Metadata View]] overlay — so the Kodi-format +sidecars Jellyfin writes are the files Plex reads, with no copy and no sync. **Host:** Dankserver — **Ports:** 32400/tcp (PMS web + API), 32400 is the only port PMS needs for client streaming; LAN clients connect directly, @@ -26,7 +27,7 @@ public traffic can be fronted by Caddy like every other vhost. | Runtime | podman quadlet, `plex.service` (user manager) — migrated from the native `plexmediaserver.service` OS-package unit (2026-09) | | Image | `lscr.io/linuxserver/plex:1.43.5.11029-6bf8af3f0-ls329` (pinned; verified == the registry's `latest` manifest digest at pin time: `sha256:8f655bad…e9e2334`) | | Data root | `/var/lib/plexmediaserver` bind-mounted **in place** as `/config` (RW) — the migration moved zero bytes | -| Media | `/mnt/entropy` mounted at the same absolute path **read-only** — every library path in the PMS database stayed valid, zero re-scan | +| Media | `/var/lib/media-meta/view` (the [[Media Metadata View]] overlay) bound at the same absolute path `/mnt/entropy` **read-only** — every library path in the PMS database stayed valid, zero re-scan | | Identity | fleet rootless model — container root maps to the `dank` user (1000:1000); the deployer chowns the data root on first cutover | | Port | `32400/tcp` published on `0.0.0.0` — zero client reconfiguration | | Network | `dankserver_appnet`, alias `plex`; Tautulli reaches it at `http://plex:32400` | @@ -38,7 +39,8 @@ public traffic can be fronted by Caddy like every other vhost. The migration moved zero bytes: the container bind-mounts the native data root where it already lived, and the media archive re-mounts at -`/mnt/entropy` inside the container, so every absolute library path in the +`/mnt/entropy` inside the container — only the HOST side of that bind has moved +(array, then the overlay view), so every absolute library path in the PMS database keeps resolving. The one metadata change: rootless podman cannot map the real host uid 997, so the fleet model applies — container root runs as the `dank` user, and the deployer adopts the data root with diff --git a/serverAppConfigs-docs/index.md b/serverAppConfigs-docs/index.md index e11da97..a5e7551 100644 --- a/serverAppConfigs-docs/index.md +++ b/serverAppConfigs-docs/index.md @@ -49,8 +49,9 @@ is a locator: one row, one clause, no numbers. | [[Copyparty]] | Copyparty is a lightweight file server and media streamer that runs as a single Python binary. | | [[Dankserver Stack]] | Service inventory and routing overview for the media and library host. | | [[Entropy Volume]] | The Dankserver entropy volume is a RAID-backed LUKS filesystem mounted at | -| [[Jellyfin]] | Jellyfin on Dankserver: quadlet unit, entropy-volume data layout, ports, client access, hardware-transcode status. | +| [[Jellyfin]] | Jellyfin on Dankserver: quadlet unit, overlay metadata view layout, ports, access, hardware-transcode status. | | [[mariadb]] | The MariaDB backend for RomM: quadlet unit, network alias, env-file secret handling, and the backup path. | +| [[Media Metadata View]] | The SSD-backed overlay the two media servers read and write: shape, guards, commands, laws, rollback. | | [[Media Relayout]] | Census, plan and apply the movie library into Jellyfin format, and move TV out of it: the gates, refusals, rollback. | | [[Plex]] | Plex Media Server is a **containerized media server on Dankserver**: a podman | | [[redis]] | The Redis cache and task queue for RomM: quadlet unit, network alias topology, and the backup path. | diff --git a/tests/deployment_scoping.bats b/tests/deployment_scoping.bats index 3b957fa..f250030 100644 --- a/tests/deployment_scoping.bats +++ b/tests/deployment_scoping.bats @@ -133,6 +133,16 @@ REMOTE_DEPLOY="$REPO_ROOT/scripts/deploy-remote.sh" [ -z "$output" ] } +@test "a compose edit never restarts a Quadlet-owned media server" { + # dankserver/compose.yaml is an inventory; the media servers' Quadlets own + # their containers. A compose edit that restarted them would cut real viewers + # for a change that cannot affect them. + run python3 "$PLANNER" --target dankserver --paths dankserver/compose.yaml + [ "$status" -eq 0 ] + ! printf '%s\n' "$output" | grep -qx jellyfin + ! printf '%s\n' "$output" | grep -qx plex +} + @test "compose planner narrows a compose edit to the changed service" { local base head base=$(git -C "$REPO_ROOT" rev-parse HEAD) diff --git a/tests/media_overlay.bats b/tests/media_overlay.bats new file mode 100755 index 0000000..ee6a3e1 --- /dev/null +++ b/tests/media_overlay.bats @@ -0,0 +1,161 @@ +#!/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 ] +} diff --git a/tests/media_relayout.bats b/tests/media_relayout.bats index 20a1a1b..b6afef0 100755 --- a/tests/media_relayout.bats +++ b/tests/media_relayout.bats @@ -402,6 +402,9 @@ apply_plan() { moved=$(python3 -c 'import json,sys print(sum(1 for l in open(sys.argv[1]) if json.loads(l).get("op") == "move"))' "$JOURNAL") [ "$moved" -eq "$planned" ] + # The media servers' sidecars live in the metadata overlay and did not follow + # these moves, so the run names that cleanup at the moment it is needed. + [[ "$output" == *"media-view.sh reap"* ]] run python3 "$APPLY" verify --plan "$OUT" --root "$LIB" --journal "$JOURNAL" [ "$status" -eq 0 ] }