diff --git a/AGENTS.md b/AGENTS.md index 363e88e..f3d4a40 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -860,6 +860,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. **Not yet deployed on Dankserver: the live quadlets still bind the array read-only — see [[Media Metadata View]].** | +| 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. **Deployed on Dankserver 2026-10-31 — both media servers bind it; a restart for any reason discards the in-flight library scan, so re-trigger the refresh — see [[Media Metadata 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/scripts/deploy-remote.sh b/scripts/deploy-remote.sh index 04790e5..a4f00a1 100755 --- a/scripts/deploy-remote.sh +++ b/scripts/deploy-remote.sh @@ -1212,6 +1212,10 @@ ensure_media_view() { # scripts/wait-for-mount.sh, which waits and then refuses the start. A # relayout must be followed by `media-view.sh reap` # (see the Media Relayout page), which this is not. + # A fresh host has no upper base, and `install` refuses to invent one — so + # without this the media deploy dies on a rebuilt machine with + # "put it on the SSD first" (measured 2026-10-31). + run_privileged bash scripts/media-view.sh ensure-base run_privileged bash scripts/media-view.sh install bash scripts/media-view.sh verify } diff --git a/scripts/media-view.sh b/scripts/media-view.sh index 125289f..9476fe4 100755 --- a/scripts/media-view.sh +++ b/scripts/media-view.sh @@ -60,7 +60,7 @@ die() { } usage() { - printf 'usage: %s plan|verify|install|reap|status [options]\n' "$0" >&2 + printf 'usage: %s plan|ensure-base|verify|install|reap|status [options]\n' "$0" >&2 exit 2 } @@ -98,6 +98,22 @@ require_upper_base() { || die "refused: upper base $upper_base does not exist (put it on the SSD first)" } +# The SSD placement of the metadata layer is a human choice, so `install` refuses +# to invent a base directory: a mistyped --upper-base must not quietly create a +# tree on whatever filesystem the caller landed on. The deployer owns the +# documented location, so it calls this first — measured 2026-10-31, when a +# fresh-host `install` died with "put it on the SSD first" and nothing in git +# would have created it. +do_ensure_base() { + if [ -d "$upper_base" ]; then + printf 'base: %s exists\n' "$upper_base" + return 0 + fi + install -d -m 0755 "$upper_base" 2>/dev/null \ + || die "refused: cannot create $upper_base — it lives outside the checkout, run this as root" + printf 'base: created %s\n' "$upper_base" +} + do_plan() { printf 'lowerdir=%s\nupperdir=%s\nworkdir=%s\nmountpoint=%s\nunit=%s/%s\n' \ "$lower" "$upper" "$work" "$VIEW" "$UNIT_DIR" "$UNIT" @@ -234,6 +250,7 @@ do_status() { case "$cmd" in plan) do_plan ;; verify) do_verify ;; + ensure-base) do_ensure_base ;; install) do_install ;; reap) do_reap ;; status) do_status ;; diff --git a/serverAppConfigs-docs/Dankserver/Jellyfin.md b/serverAppConfigs-docs/Dankserver/Jellyfin.md index d511ffd..890b32b 100644 --- a/serverAppConfigs-docs/Dankserver/Jellyfin.md +++ b/serverAppConfigs-docs/Dankserver/Jellyfin.md @@ -42,21 +42,20 @@ public vhost `jelly.franksdank.cloud` behind the [[Anubis]] guard chain → Library folders point at `/data` paths: stable across container recreation and identical to the raw array layout below. Which side of that path the container -is on is the open question on this host — the installed unit binds the read-only -array, and `scripts/media-view.sh status` reports `NOT MOUNTED` (2026-10-31). +is the [[Media Metadata View]] overlay, live since the 2026-10-31 cutover: +`/var/lib/media-meta/view:/data`, read-write. `Local metadata` — save metadata to media folders — is enabled, and it is the reason the view exists: `movie.nfo`, `tvshow.nfo`, `episode.nfo` and artwork -written once on the NVMe upper layer are files [[Plex]] can read too. While -`/data` is bound read-only every item update logs the error below and no sidecar -is written; Jellyfin becomes the fleet's only metadata writer with the deploy -that raises the view. +written once on the NVMe upper layer are files [[Plex]] can read too — and since +the 2026-10-31 cutover that is what happens: Jellyfin is the fleet's only +metadata writer, and every sidecar it saves is an SSD byte the array never sees. **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. On Dankserver that is today's expected state — the view has -never been installed (`scripts/media-view.sh status`). Once it is deployed, the -same line is a fault: the quadlet drifted or the mount died. +instead of the view — always a fault on this host since the 2026-10-31 cutover. +`scripts/media-view.sh status` says which: the quadlet drifted back to the raw +array, or the mount died. ## Ports & access @@ -212,8 +211,7 @@ receives `Authorization: MediaBrowser Token=` with nothing after it (`\\"` answe One accepted refresh is the whole procedure. A second one while a scan runs moves `Scan Media Library` to `Cancelling`, and once the runner holds `Cancelling` every later `204` is accepted and then discarded — the status code stops meaning -anything. The fix is `systemctl --user restart jellyfin.service` (never -`deploy.sh`: that runs the never-tested metadata-view cutover), one POST after +anything. The fix is `systemctl --user restart jellyfin.service`, one POST after health answers, and then believe `ScheduledTasks` State at 130 % CPU, not the response code. diff --git a/serverAppConfigs-docs/Dankserver/Media Metadata View.md b/serverAppConfigs-docs/Dankserver/Media Metadata View.md index 3a06509..c17c10e 100644 --- a/serverAppConfigs-docs/Dankserver/Media Metadata View.md +++ b/serverAppConfigs-docs/Dankserver/Media Metadata View.md @@ -20,18 +20,22 @@ The mount point is not a volume and holds nothing while unmounted. It sits insid merged door are one directory — and so the mount table grows no new `/mnt` entry next to the array. -**Not deployed on Dankserver — verified 2026-10-31.** `scripts/media-view.sh -status` answers `view: /var/lib/media-meta/view NOT MOUNTED`, -`/var/lib/media-meta` does not exist, and the installed quadlets under -`~/.config/containers/systemd/` still carry the pre-view binds — `/mnt/entropy:/data:ro` -for [[Jellyfin]], `/mnt/entropy:/mnt/entropy:ro` for [[Plex]]. Everything below -is the shape git describes, not the shape the servers run. The cutover is an -ordinary deploy: `deploy.sh dankserver --service jellyfin|plex` runs -`ensure_media_view` (`install` then `verify`) before it restarts the container, -so it must not be fired through the middle of a library scan. Until that deploy -lands there is no upper layer, so no relayout can leave ghost metadata and -`reap` has nothing to do — `media-apply.py` prints that follow-up only when the -view really is a mountpoint. +**Deployed on Dankserver — 2026-10-31, both servers.** `plex` binds +`/var/lib/media-meta/view:/mnt/entropy:ro` and `jellyfin` binds +`/var/lib/media-meta/view:/data:rw`; `findmnt` reports the overlay with +`lowerdir=/mnt/entropy upperdir=/var/lib/media-meta/upper`, and each container +was proven to resolve a real media file through it — `podman exec ls -la +` is the whole proof, because both servers keep the paths their +databases recorded. Two service-scoped deploys, each gated by +`media-view.sh verify` and the Playwright suite. + +Moving a bind means restarting the server, and **a restart discards that +server's in-flight library scan** — Jellyfin's 7 h 25 m walk ended +`"Status": "Aborted"` in `data/ScheduledTasks/.js` and nothing resumes +it. Renames made on the array while the view was unmounted are invisible to both +watchers too, so the additive index has to be asked for by hand; the door is an +API key, and the row in the failure table below says what to check when it +refuses. ## Shape @@ -122,6 +126,9 @@ the defaults are the host values above. | 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. | +| `install` prints `refused: upper base /var/lib/media-meta does not exist (put it on the SSD first)` | A fresh host. `install` deliberately never creates the base — a mistyped `--upper-base` must not build a metadata tree on the wrong filesystem — so `scripts/media-view.sh ensure-base` makes the documented one, which the deployer calls first. | +| A media server's scan record reads `"Status": "Aborted"` | Its container restarted (a cutover, an image bump, an OOM) and the walk was discarded; systemd's `Restart=always` restarts the *server*, never the scan. Trigger a refresh — see [[Media Relayout]]. | +| The Jellyfin API answers 401 to every token header | Almost always the header **form**, not the key: 12.4 authenticates only `Authorization: MediaBrowser Token="…"` and refuses a bare `X-Emby-Token` for every key, valid or not — and one collapsed backslash in the curl config produces the same 401. `[[Jellyfin]]` owns the block that works. Before concluding anything, prove the key against the server's own table: `select count(*) from ApiKeys where AccessToken = ?` on `jellyfin.db` opened `mode=ro&immutable=1`. | | `systemctl status` finds no such unit | Look under the escaped name, `var-lib-media\x2dmeta-view.mount`, not the repo file name. | ## Rollback diff --git a/tests/media_overlay.bats b/tests/media_overlay.bats index 1bf526f..093d70f 100755 --- a/tests/media_overlay.bats +++ b/tests/media_overlay.bats @@ -10,7 +10,8 @@ # 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; +# 6. the deployer creates the upper base and brings the view up BEFORE it +# restarts either server; # 7. the planner routes a change to these files to both media services. load helpers @@ -110,6 +111,30 @@ UNIT="$REPO_ROOT/dankserver/host/systemd/media-meta-view.mount" done } +@test "ensure-base creates a base directory that install refuses to invent" { + base="$BATS_TEST_TMPDIR/fresh-base" + run bash "$VIEW" ensure-base --upper-base "$base" + [ "$status" -eq 0 ] + [ -d "$base" ] + # The deployer runs this on every media deploy, so a second call must be a + # no-op and must say so rather than recreate anything. + run bash "$VIEW" ensure-base --upper-base "$base" + [ "$status" -eq 0 ] + [[ "${lines[0]}" == *"exists"* ]] +} + +@test "the media deployer creates the upper base before it installs the view" { + body="$(sed -n '/^ensure_media_view()/,/^\}/p' "$REPO_ROOT/scripts/deploy-remote.sh")" + [ -n "$body" ] + base="$(printf '%s\n' "$body" | grep -n 'media-view.sh ensure-base' | head -1 | cut -d: -f1)" + inst="$(printf '%s\n' "$body" | grep -n 'media-view.sh install' | head -1 | cut -d: -f1)" + # Order is the whole test: install refuses a missing base, so an installer + # that runs first dies on a rebuilt host (measured 2026-10-31). + [ -n "$base" ] + [ -n "$inst" ] + [ "$base" -lt "$inst" ] +} + @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