Compare commits

...
Author SHA1 Message Date
dank 010b9affc7 Merge pull request 'media-verify reads the WAL; the metadata view is live; duplicates get a page' (#115) from fix/media-verify-wal-and-prune-record into main 2026-11-01 01:42:20 -07:00
Frank ed5eed6d54 docs(media): the deferred Plex leg, and the guards a detritus sweep must respect 2026-11-01 01:42:02 -07:00
Frank 14f340370c Merge remote-tracking branch 'origin/main' into feat/jellyfin-provider-keys
# Conflicts:
#	AGENTS.md
#	serverAppConfigs-docs/Dankserver/Media Metadata View.md
#	serverAppConfigs-docs/index.md
2026-11-01 01:36:35 -07:00
Frank 50e0c5a786 docs(media): the metadata view is live, and duplicate pruning gets a page
`Media Metadata View.md` said "Not deployed on Dankserver — verified
2026-10-31". That was true yesterday and is false now: the overlay is mounted
and both installed quadlets bind it (`view:/data` rw for Jellyfin,
`view:/mnt/entropy:ro` for Plex). The page now states the live shape and the
order that proves it — `media-view.sh verify`, then the `Volume=` lines —
because the whole point of the page is to answer "is the view really serving?".
`AGENTS.md` carried the same stale claim in its summary table.

`Media Duplicate Prune.md` is new: the screen ladder (size, then head+tail
blake2b, then a sparse-window confirmation before anything moves), which copy
survives and why, the guards that each encode a file that would otherwise have
been destroyed (qBittorrent `.!qB` state, year-required with digits kept,
feature length, disc/part exclusion, media containers only), and the law that a
hold on the same volume frees nothing until it is purged. It also carries the
different way `media-verify.py` has to be READ for a prune — `landed: 0` is the
correct end state because no server may index the hold — and the planner bug
that class explains: when the planner re-derived "largest wins" from the hold
list alone it dropped every keeper out of its own group and pruned 17 files
instead of 205.

`Media Relayout.md` keeps a one-line pointer; carrying the whole procedure there
pushed it past the vault's 500-line ceiling, which is the linter telling the
truth about progressive disclosure.
2026-11-01 01:11:26 -07:00
Frank 649bbafa17 fix(media-verify): read the WAL the server commits through
`mode=ro&immutable=1` claims the file never changes, so sqlite skips the
write-ahead log entirely: every row the server has committed but not yet
checkpointed reads as MISSING, and a read beside a running scanner can fail
outright as "database disk image is malformed". Both servers keep their
libraries in WAL, so plain `mode=ro` is now the first door and `immutable=1`
only the fallback for a database whose `-shm` cannot be read.

The verdict also stopped guessing from the database alone. A file still at its
`src` is REAL (this pass's own damage); a file at neither path is `gone` (a
later pass or the server renamed it); a file sitting at `dst` with no row
naming it is something the server does not index. A row that still names `src`
is none of those — it is the server's stale pointer, and it costs a re-scan,
never a restore.
2026-11-01 01:04:22 -07:00
Frank b7c19592e5 docs(jellyfin): record the live view bind and the library-coverage law
The page still described the pre-cutover generation ("the live unit is
still pre-view", "/mnt/entropy -> /data (live today, read-only)") after
the 2026-10-31 cutover deployed the overlay. Corrected, and added the law
the census tonight taught: Jellyfin's four libraries cover /data/Movies,
TVShows, Music and the collections dir, so anything under /data/media-park
is invisible by configuration — a recursive refresh there returns 204 and
indexes nothing. Check coverage before re-scanning.
2026-10-31 19:43:35 -07:00
dank 6c115c1394 Merge pull request 'fix(deploy): make the view's upper base before installing the view' (#113) from fix/media-view-ensure-base into main 2026-10-31 19:36:52 -07:00
dank 6d79709cc0 fix(deploy): make the view's upper base before installing the view
`ensure_media_view` ran `install` on a host where the documented base directory
did not exist, and `install` refuses by design ("put it on the SSD first") — so
tonight's cutover would have died at the restart step with nobody beside the
box. `media-view.sh ensure-base` now makes the documented directory, and the
deployer calls it first; two seams cover it (the subcommand's behaviour, and the
ordering invariant inside `ensure_media_view`).

Docs flip with the deploy: the view is live on Dankserver (both media servers
verified resolving real media through it), so every "never been deployed" claim
in the view, Plex, Jellyfin and AGENTS pages is corrected, and three laws join
them — a container restart discards the in-flight library scan and nothing
resumes it; Jellyfin 12.4 authenticates only the `MediaBrowser Token` form, so a
401 says nothing about the key until the `ApiKeys` table is read; and Plex
answers 32400 over TLS only, which makes a plaintext health probe read as dead.
2026-10-31 19:33:16 -07:00
dank 0336f42e2a Merge pull request 'test(scoping): stop pinning a purgeable trash archive' (#112) from fix/scoping-seam-disposable-archive into main 2026-10-31 19:30:35 -07:00
dank 0f4a527337 test(scoping): stop pinning a purgeable trash archive
The seam asserted `[ -f scripts/.AGENTTRASH/chat-template.jinja.ik-llama-qwen-sharp ]`
so that the planning assertion "could not pass by the file having vanished". That
reasoning was wrong twice over: the planner's rule is a PATH rule, so it plans
nothing for that path no matter where the bytes sit, and the trash is a purgeable
hold — #111 untracked it, so a fresh worktree or CI checkout has no such file and
the suite went red on a byte that carries no meaning.

Measured: fresh worktree off origin/main, deployment_scoping.bats:39 fails. After
the change: that file 28 ok / 0 fail, ./tests/run.sh 789 ok exit 0.
2026-10-31 19:27:56 -07:00
dank 1d92627935 Merge pull request 'chore: stop tracking .AGENTTRASH so a purge cannot dirty a checkout' (#111) from chore/untrack-agenttrash into main 2026-10-31 18:49:41 -07:00
11 changed files with 274 additions and 38 deletions

No files matched your search

+2 -1
View File
@@ -818,6 +818,7 @@ per-attempt page and no per-swap ledger anywhere else.
| Deployment flow | `serverAppConfigs-docs/Architecture/Deployment Flow.md` | | Deployment flow | `serverAppConfigs-docs/Architecture/Deployment Flow.md` |
| Movie-library relayout (Jellyfin format, Dankserver) | `serverAppConfigs-docs/Dankserver/Media Relayout.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` | | SSD-backed metadata view (Jellyfin writes, Plex reads, Dankserver) | `serverAppConfigs-docs/Dankserver/Media Metadata View.md` |
| Duplicate films and the purge hold (Dankserver) | `serverAppConfigs-docs/Dankserver/Media Duplicate Prune.md` |
There is no page for a retired endpoint and no change diary. A payload swap 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 appends one row to the live endpoint's **Eras** table; when the endpoint itself
@@ -860,6 +861,6 @@ history.
| Benchmarking (only when ordered) | **Stop and mask the service first**, bench, unmask, restart, verify health. | | 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`. | | 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. | | 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, re-verified 2026-11-01: both quadlets bind the view, not the raw array. A restart for any reason discards that server's 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. | | 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. | | Real secrets | **Never commit** — host `.env` files; `.env.example` holds placeholders only. |
+4
View File
@@ -1212,6 +1212,10 @@ ensure_media_view() {
# scripts/wait-for-mount.sh, which waits and then refuses the start. A # scripts/wait-for-mount.sh, which waits and then refuses the start. A
# relayout must be followed by `media-view.sh reap` # relayout must be followed by `media-view.sh reap`
# (see the Media Relayout page), which this is not. # (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 run_privileged bash scripts/media-view.sh install
bash scripts/media-view.sh verify bash scripts/media-view.sh verify
} }
+18 -1
View File
@@ -60,7 +60,7 @@ die() {
} }
usage() { 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 exit 2
} }
@@ -98,6 +98,22 @@ require_upper_base() {
|| die "refused: upper base $upper_base does not exist (put it on the SSD first)" || 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() { do_plan() {
printf 'lowerdir=%s\nupperdir=%s\nworkdir=%s\nmountpoint=%s\nunit=%s/%s\n' \ printf 'lowerdir=%s\nupperdir=%s\nworkdir=%s\nmountpoint=%s\nunit=%s/%s\n' \
"$lower" "$upper" "$work" "$VIEW" "$UNIT_DIR" "$UNIT" "$lower" "$upper" "$work" "$VIEW" "$UNIT_DIR" "$UNIT"
@@ -234,6 +250,7 @@ do_status() {
case "$cmd" in case "$cmd" in
plan) do_plan ;; plan) do_plan ;;
verify) do_verify ;; verify) do_verify ;;
ensure-base) do_ensure_base ;;
install) do_install ;; install) do_install ;;
reap) do_reap ;; reap) do_reap ;;
status) do_status ;; status) do_status ;;
+21 -14
View File
@@ -18,7 +18,7 @@ clients.
public vhost `jelly.franksdank.cloud` behind the [[Anubis]] guard chain → public vhost `jelly.franksdank.cloud` behind the [[Anubis]] guard chain →
[[Authentik]] outpost (post-auth route proxies `localhost:8096`). [[Authentik]] outpost (post-auth route proxies `localhost:8096`).
## Facts (the repo quadlet; the live unit is still the pre-view generation — see Data layout) ## Facts (the repo quadlet — deployed as the live unit on 2026-10-31)
| Item | Value | | Item | Value |
|------|-------| |------|-------|
@@ -26,7 +26,7 @@ public vhost `jelly.franksdank.cloud` behind the [[Anubis]] guard chain →
| Image | `ghcr.io/jellyfin/jellyfin:latest` | | Image | `ghcr.io/jellyfin/jellyfin:latest` |
| Network | `dankserver_appnet`; publishes 8096/tcp, 8920/tcp, 7359/udp, 1900/udp | | 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` | | Data roots | `/home/dank/Containers/jellyfin/config:/config`, `/home/dank/Containers/jellyfin/cache:/cache` |
| Media | `Volume=/var/lib/media-meta/view:/data` in git — the [[Media Metadata View]] overlay, **writable**, sidecars land on the NVMe and the [[Entropy Volume]] array underneath stays untouched. **The live unit is still pre-view: `/mnt/entropy:/data:ro`** — nothing is written into the library until the view is deployed | | Media | `Volume=/var/lib/media-meta/view:/data` — the [[Media Metadata View]] overlay, **writable**, live since the 2026-10-31 cutover: sidecars land on the NVMe and the [[Entropy Volume]] array underneath stays untouched |
| GPU | none — no `AddDevice`; CPU decode only (jellyfin-ffmpeg path) | | 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) | | 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 | | Health | `curl -f http://localhost:8096/health` every 30 s |
@@ -37,26 +37,34 @@ public vhost `jelly.franksdank.cloud` behind the [[Anubis]] guard chain →
/home/dank/Containers/jellyfin/config -> /config (JELLYFIN_DATA_DIR) /home/dank/Containers/jellyfin/config -> /config (JELLYFIN_DATA_DIR)
/home/dank/Containers/jellyfin/cache -> /cache (JELLYFIN_CACHE_DIR, XDG_CACHE_HOME) /home/dank/Containers/jellyfin/cache -> /cache (JELLYFIN_CACHE_DIR, XDG_CACHE_HOME)
/var/lib/media-meta/view -> /data (git: the overlay view) /var/lib/media-meta/view -> /data (git: the overlay view)
/mnt/entropy -> /data (live today: the array, read-only) /mnt/entropy -> /data (the overlay's lower layer; also the rollback bind)
``` ```
Library folders point at `/data` paths: stable across container recreation and 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 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 sees is the [[Media Metadata View]] overlay, live since the 2026-10-31 cutover:
array, and `scripts/media-view.sh status` reports `NOT MOUNTED` (2026-10-31). `/var/lib/media-meta/view:/data`, read-write.
`Local metadata` — save metadata to media folders — is enabled, and it is the `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 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 written once on the NVMe upper layer are files [[Plex]] can read too — and since
`/data` is bound read-only every item update logs the error below and no sidecar the 2026-10-31 cutover that is what happens: Jellyfin is the fleet's only
is written; Jellyfin becomes the fleet's only metadata writer with the deploy metadata writer, and every sidecar it saves is an SSD byte the array never sees.
that raises the view.
**Library coverage is the first thing to check when a title is missing.** The
four libraries are `movies → /data/Movies`, `tvshows → /data/TVShows`,
`music → /data/Music`, `boxsets → /config/data/collections`
(`GET /Library/VirtualFolders`). `/data/media-park` is in **none** of them, so
everything filed under it — the `_unsorted` release bundles and the `extras/`
folders the relayout wrote there — is invisible to Jellyfin by configuration,
not by fault: a recursive refresh answers 204, burns CPU for minutes and
indexes nothing, because it never looks there. Check coverage before re-scanning.
**Failure signature:** `System.IO.IOException: Read-only file system : **Failure signature:** `System.IO.IOException: Read-only file system :
'/data/Movies/<film>/movie.nfo'` means the container is binding the raw array '/data/Movies/<film>/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 instead of the view — always a fault on this host since the 2026-10-31 cutover.
never been installed (`scripts/media-view.sh status`). Once it is deployed, the `scripts/media-view.sh status` says which: the quadlet drifted back to the raw
same line is a fault: the quadlet drifted or the mount died. array, or the mount died.
## Ports & access ## Ports & access
@@ -212,8 +220,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 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 `Scan Media Library` to `Cancelling`, and once the runner holds `Cancelling` every
later `204` is accepted and then discarded — the status code stops meaning later `204` is accepted and then discarded — the status code stops meaning
anything. The fix is `systemctl --user restart jellyfin.service` (never anything. The fix is `systemctl --user restart jellyfin.service`, one POST after
`deploy.sh`: that runs the never-tested metadata-view cutover), one POST after
health answers, and then believe `ScheduledTasks` State at 130 % CPU, not the health answers, and then believe `ScheduledTasks` State at 130 % CPU, not the
response code. response code.
@@ -0,0 +1,143 @@
---
type: reference
title: Media Duplicate Prune
description: "Find and hold duplicate films on the entropy array: the hash ladder, the keep rules, the guards, the purge."
tags: [dankserver, media-server, storage, procedure]
timestamp: 2026-11-01T01:00:00Z
---
# Media Duplicate Prune
The movie library holds the same film twice in two different ways — the same file
downloaded again, or the same film grabbed a second time at another quality — and
[[Entropy Volume]] is permanently full, so both are worth finding. They need
different rules, and neither is decided by name similarity alone.
`/mnt/entropy/media-park` was a third kind: a parking lot that held whole shows
and films nothing served. It is gone; its contents went to `NeedsProcessing`.
## What counts
| Class | Test | Action |
|---|---|---|
| Byte-identical | equal size, equal content hash | keep one copy, hold the rest |
| Same work, worse encode | same title, same year, every runtime within 5 % | keep the largest — Frank's rule |
| Different cut | same title and year, runtimes more than 5 % apart | **never touched**: an extended and a theatrical version are different products |
| Same words, different film | title matches, year or duration does not | never grouped — this is the false-merge class that would destroy a unique file |
## The screen
The array cannot afford a full-content read, so identity is established in steps
and each step only narrows what the next one touches:
1. `scripts/media-census.py enumerate` — size is free, and identical files share it.
2. Head+tail `blake2b`, 4 MiB at each end, on the size collisions only.
3. **Before anything moves**, confirm with a sparse-window hash: 32 MiB at eight
evenly spaced offsets. Re-hashing a remux-heavy set in full read 150 GiB for
the better part of an hour; the sparse window cost 50 s, a file that merely
shares its head and tail cannot survive it, and the destination is a
recoverable hold rather than a deletion.
4. Durations come from `scripts/media-census.py probe` — the relayout's own
census. Do not hand-roll a second probe: it re-seeks the array for an answer
the repo tool already produces, and a probe is the one census step that costs
hours instead of seconds.
## Which copy survives
In order: not one inside an `extras`, `featurettes`, `samples` or `trailers`
folder (an extra is not a copy of the film, and extra names collide across films);
then the shallowest path; then the file whose name equals its `Title (year)`
folder; then the largest. For the same-work class the largest is the answer, and
the canonical-name rule only breaks a tie — without it a `Film - 1080p.mkv` can
outlive `Film.mkv` for no reason but a space sorting before a dot.
## Guards
Each of these is a file that would have been destroyed without it:
- **`.!qB` is qBittorrent state, not a duplicate.** It can be byte-identical to
the finished file while qB still counts the download incomplete; moving it
breaks the re-check and re-downloads the film. Report it, leave it. Both
leftovers in `Movies` are 13 months stale, so they are Frank's purge decision,
not a prune decision.
- **Require a year, and keep digits in the title.** Stripping digits merged
*Akira — Teaser 1* with *Teaser 2*; without a year, two different extras that
happened to share a name merged.
- Both members must be feature length, and a name carrying `disc`, `cd` or `part`
is excluded: half a multi-disc release is not a copy of the other half.
- Media containers only. Grouping every extension buries the report in subtitle
and cover pairs — and a duration guard that appears to fire on 720 groups is
counting subtitles, not protecting films.
## The hold
Nothing is deleted. Every doomed copy moves into a dated hold with the same
executor the relayout uses, so any file comes back:
```bash
scripts/media-apply.py apply --plan /mnt/entropy/dup-prune-similar-plan.tsv \
--root /mnt/entropy --journal /mnt/entropy/dup-prune-similar.jsonl # dry
scripts/media-apply.py apply --plan … --root /mnt/entropy --journal … --apply
scripts/media-apply.py verify --plan … --journal /mnt/entropy/dup-prune-similar.jsonl
scripts/media-apply.py sweep --journal /mnt/entropy/dup-prune-similar.jsonl --root /mnt/entropy --apply
scripts/media-apply.py rollback --journal /mnt/entropy/dup-prune-similar.jsonl --root /mnt/entropy --apply
```
The plan rows are the tool's own format (`action src dst kind`, `mkdir` rows for
every hold parent), and the planner re-checks the two things that go stale between
analysis and apply: a file that has moved since the census, and a group whose
keeper is missing — either one drops the whole group rather than half-applying it.
- **A hold on the same volume frees nothing.** The purge is a separate, approved
act and the only thing that moves the free-space number; `df` does not budge
when 1 TiB moves sideways.
- `sweep` removes the release folders the pass emptied. Empty folders dated a year
ago are torrent detritus (`Subtitle,info`, `Screens`, `Sample`) from a previous
era, not this pass — read the mtimes before blaming the prune.
- The 2026-11-01 pass held 226 files (~1.1 TiB) in
`/mnt/entropy/.AGENTTRASH/20261101-duplicate-prune`, journals
`dup-prune-exact.jsonl` and `dup-prune-similar.jsonl`. Purge pending Frank's word.
## After the pass
Both servers still hold rows for files that are now in the hold, and neither
notices: the media bind is the [[Media Metadata View]], and array changes do not
cross it as inotify events. Scan in the order the rescan law sets — Jellyfin to
`Idle`, then Plex's Movies section alone, one accepted refresh each; the requests
are owned by [[Jellyfin]] and [[Plex]].
As of 2026-11-01 the Plex leg is deliberately deferred: Plex is stopped and
**masked** (`systemctl --user stop plex && systemctl --user mask plex`) so its
metadata fetches cannot stack on Jellyfin's live scan, and a masked unit stays
stopped through a reboot — `unmask` before starting it. Its Movies section (key 1)
holds ~187 rows naming held paths until that one refresh is accepted.
The same evening's detritus pass is a second, separate hold: 423 files (12 MiB) in
`/mnt/entropy/.AGENTTRASH/20261101-detritus-sweep`, plan `detritus-sweep-plan.tsv`,
journal `detritus-sweep.jsonl`, 542 husks removed by `sweep`. It moved files only
from folders whose whole subtree held no media, so it creates no stale rows and
needs no scan. The guards that hold a folder back are the finding: external
subtitle folders (428 of them, 6 GiB) and ripped Blu-ray streams — `.sup` PGS,
`.thd`/`.eac3`/`.dts` audio, `.cci` captions, 25 GiB — are not trash, and an
extension the census does not recognise is a reason to stop, not to sweep.
`scripts/media-verify.py` reads differently for a prune than for a relayout:
`landed: 0` is the **correct** end state (no server may index the hold), so the
verdict is `still at src: 0` with `REAL: 0`, and the rows that simply vanished
from the database are the pass succeeding.
## Failure signatures
| Symptom | Meaning |
|---|---|
| A size group whose members split under the sparse hash | The head+tail screen collided. Those files stay exactly where they are and get reported. |
| The planner holds far fewer files than the analyzer counted | Two implementations of "largest wins". The analyzer must emit `keep`/`hold` marks and the planner only transcribes them — a planner that re-derives the winner from the hold list alone drops the keeper out of the group and prunes almost nothing. |
| `df` unchanged after a TiB of moves | Expected: the hold is on the same filesystem. Only the purge frees space. |
| A film that "disappeared" after a prune | It is in the hold, named by the journal: `media-apply.py rollback --journal … --apply`. |
## Links
- [[Media Relayout]] — the census tools, the executor, the journal format.
- [[Media Metadata View]] — why neither server sees the change without a scan.
- [[Jellyfin]] · [[Plex]] — the scan requests, one server at a time.
- [[Entropy Volume]] — the array that is always full.
@@ -3,7 +3,7 @@ type: reference
title: Media Metadata View title: Media Metadata View
description: "The SSD-backed overlay the two media servers read and write: shape, guards, commands, laws, rollback." description: "The SSD-backed overlay the two media servers read and write: shape, guards, commands, laws, rollback."
tags: [dankserver, media-server, jellyfin, plex, storage] tags: [dankserver, media-server, jellyfin, plex, storage]
timestamp: 2026-10-30T00:00:00Z timestamp: 2026-11-01T01:00:00Z
--- ---
# Media Metadata View # Media Metadata View
@@ -20,18 +20,40 @@ 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 merged door are one directory — and so the mount table grows no new `/mnt` entry
next to the array. next to the array.
**Not deployed on Dankserver — verified 2026-10-31.** `scripts/media-view.sh **Deployed on Dankserver — 2026-10-31, both servers.** `plex` binds
status` answers `view: /var/lib/media-meta/view NOT MOUNTED`, `/var/lib/media-meta/view:/mnt/entropy:ro` and `jellyfin` binds
`/var/lib/media-meta` does not exist, and the installed quadlets under `/var/lib/media-meta/view:/data:rw`; `findmnt` reports the overlay with
`~/.config/containers/systemd/` still carry the pre-view binds — `/mnt/entropy:/data:ro` `lowerdir=/mnt/entropy upperdir=/var/lib/media-meta/upper`, and each container
for [[Jellyfin]], `/mnt/entropy:/mnt/entropy:ro` for [[Plex]]. Everything below was proven to resolve a real media file through it — `podman exec <svc> ls -la
is the shape git describes, not the shape the servers run. The cutover is an <its data path>` is the whole proof, because both servers keep the paths their
ordinary deploy: `deploy.sh dankserver --service jellyfin|plex` runs databases recorded. Two service-scoped deploys, each gated by
`ensure_media_view` (`install` then `verify`) before it restarts the container, `media-view.sh verify` and the Playwright suite.
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 Re-verified 2026-11-01: `mount` shows the overlay on
`reap` has nothing to do — `media-apply.py` prints that follow-up only when the `/var/lib/media-meta/view` (`lowerdir=/mnt/entropy`,
view really is a mountpoint. `upperdir=/var/lib/media-meta/upper`), and the installed quadlets under
`~/.config/containers/systemd/` carry the view binds:
`Volume=/var/lib/media-meta/view:/data` for [[Jellyfin]],
`Volume=/var/lib/media-meta/view:/mnt/entropy:ro` for [[Plex]]. Prove it in that
order: `scripts/media-view.sh verify` (the deploy gate), then
`grep -n Volume ~/.config/containers/systemd/{jellyfin,plex}.container`.
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/<task-id>.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.
The cutover itself 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. Two consequences are now live rather than theoretical: inotify events for
array writes do not cross the merge, so any relayout or prune needs a manual
library refresh; and a move can leave ghost sidecars above, so `reap` runs after
a relayout — a pass that moves only media files with no sidecars next to them
leaves the upper layer untouched, and `reap` correctly finds nothing.
## Shape ## Shape
@@ -122,6 +144,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`. | | 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`. | | 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. | | `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. | | `systemctl status` finds no such unit | Look under the escaped name, `var-lib-media\x2dmeta-view.mount`, not the repo file name. |
## Rollback ## Rollback
@@ -3,7 +3,7 @@ type: reference
title: Media Relayout title: Media Relayout
description: "Census, plan and apply the movie, TV, music and audiobook libraries: the gates, the refusals, the rollback." description: "Census, plan and apply the movie, TV, music and audiobook libraries: the gates, the refusals, the rollback."
tags: [dankserver, media-server, jellyfin, plex, procedure] tags: [dankserver, media-server, jellyfin, plex, procedure]
timestamp: 2026-10-30T00:00:00Z timestamp: 2026-11-01T01:00:00Z
--- ---
# Media Relayout # Media Relayout
@@ -138,6 +138,8 @@ HTTPD close without a single byte; and the same HTTPD drops requests that carry
no `Accept` and client-identifier header (see the scan block above for the form no `Accept` and client-identifier header (see the scan block above for the form
that works). that works).
A duplicate prune is the same census and the same proof, with its own keep rules: [[Media Duplicate Prune]].
## TV in the movie library ## TV in the movie library
A box set that arrives in the movie share stays there until someone moves it: A box set that arrives in the movie share stays there until someone moves it:
+12 -2
View File
@@ -27,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) | | 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`) | | 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 | | Data root | `/var/lib/plexmediaserver` bind-mounted **in place** as `/config` (RW) — the migration moved zero bytes |
| Media | git: `/var/lib/media-meta/view` (the [[Media Metadata View]] overlay) bound at the same absolute path `/mnt/entropy` **read-only**; live today: `/mnt/entropy` itself, read-only — the view has never been installed. Either way every library path in the PMS database stays 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** — live since the 2026-10-31 cutover, so the PMS database's recorded paths keep resolving with 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 | | 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 | | Port | `32400/tcp` published on `0.0.0.0` — zero client reconfiguration |
| Network | `dankserver_appnet`, alias `plex`; Tautulli reaches it at `http://plex:32400` | | Network | `dankserver_appnet`, alias `plex`; Tautulli reaches it at `http://plex:32400` |
@@ -40,7 +40,7 @@ public traffic can be fronted by Caddy like every other vhost.
The migration moved zero bytes: the container bind-mounts the native data The migration moved zero bytes: the container bind-mounts the native data
root where it already lived, and the media archive re-mounts at root where it already lived, and the media archive re-mounts at
`/mnt/entropy` inside the container — only the HOST side of that bind has moved `/mnt/entropy` inside the container — only the HOST side of that bind has moved
(the array today; the overlay view in git has never been deployed), so every (the array until the 2026-10-31 view cutover, the overlay since), so every
absolute library path in the absolute library path in the
PMS database keeps resolving. The one metadata change: rootless podman PMS database keeps resolving. The one metadata change: rootless podman
cannot map the real host uid 997, so the fleet model applies — container cannot map the real host uid 997, so the fleet model applies — container
@@ -87,6 +87,16 @@ root runs as the `dank` user, and the deployer adopts the data root with
while the HTTP layer finishes loading; the first boot of a years-old while the HTTP layer finishes loading; the first boot of a years-old
library took ~10 minutes (2026-09 cutover). The deployer gate now allows library took ~10 minutes (2026-09 cutover). The deployer gate now allows
600 s before failing; later boots are much faster (warm page cache). 600 s before failing; later boots are much faster (warm page cache).
5. **`32400` answers TLS only to remote clients.** PMS requires secure
connections, so `curl http://192.168.0.235:32400/identity` from any other
machine closes without a single byte (curl exit 52) — that reads as a dead
server beside a healthy one, and it is how a post-cutover health check gets
misread. Probe with `curl -k https://192.168.0.235:32400/identity`. Two
consequences: `plex.franksdank.cloud` works **only** because its `transport
http` block carries `tls_insecure_skip_verify` (that option makes Caddy speak
TLS to PMS's self-signed cert — delete it and the vhost 502s), and the scan
block below rides `http://127.0.0.1` **inside** the container, where insecure
connections are allowed.
## Triggering a scan ## Triggering a scan
+1
View File
@@ -52,6 +52,7 @@ is a locator: one row, one clause, no numbers.
| [[Jellyfin]] | Jellyfin on Dankserver: quadlet unit, overlay metadata view, provider keys, ports, access, transcode status. | | [[Jellyfin]] | Jellyfin on Dankserver: quadlet unit, overlay metadata view, provider keys, ports, access, transcode status. |
| [[mariadb]] | The MariaDB backend for RomM: quadlet unit, network alias, env-file secret handling, and the backup path. | | [[mariadb]] | The MariaDB backend for RomM: quadlet unit, network alias, env-file secret handling, and the backup path. |
| [[Media Audiobook Relayout]] | Re-filing /mnt/entropy/Audiobooks by Plex's chapter rows: narrator shelves, no renumbering, the refusals. | | [[Media Audiobook Relayout]] | Re-filing /mnt/entropy/Audiobooks by Plex's chapter rows: narrator shelves, no renumbering, the refusals. |
| [[Media Duplicate Prune]] | Find and hold duplicate films on the entropy array: the hash ladder, the keep rules, the guards, the purge. |
| [[Media Metadata View]] | The SSD-backed overlay the two media servers read and write: shape, guards, commands, laws, rollback. | | [[Media Metadata View]] | The SSD-backed overlay the two media servers read and write: shape, guards, commands, laws, rollback. |
| [[Media Music Relayout]] | Re-filing /mnt/entropy/Music by Jellyfin's tag rows: the census, the album key, the refusals. | | [[Media Music Relayout]] | Re-filing /mnt/entropy/Music by Jellyfin's tag rows: the census, the album key, the refusals. |
| [[Media Relayout]] | Census, plan and apply the movie, TV, music and audiobook libraries: the gates, the refusals, the rollback. | | [[Media Relayout]] | Census, plan and apply the movie, TV, music and audiobook libraries: the gates, the refusals, the rollback. |
+6 -5
View File
@@ -28,15 +28,16 @@ REMOTE_DEPLOY="$REPO_ROOT/scripts/deploy-remote.sh"
! grep -q 'frank_npu\|frank-npu' "$REMOTE_DEPLOY" ! grep -q 'frank_npu\|frank-npu' "$REMOTE_DEPLOY"
} }
@test "the retired ik chat template plans nothing, and its archive is still here" { @test "the retired ik chat template plans nothing" {
# scripts/chat-template.jinja existed only for the ik_llama.cpp runtime # scripts/chat-template.jinja existed only for the ik_llama.cpp runtime
# (retired 2026-09-16 in favour of halogen-flash-server again). The # (retired 2026-09-16 in favour of halogen-flash-server again). The
# llama.cpp frank-zealot runtime serves the template the GGUF itself # llama.cpp frank-zealot runtime serves the template the GGUF itself
# carries, which is the one with reasoning_effort, preserve_thinking and # carries, which is the one with reasoning_effort, preserve_thinking and
# the tool-call grammar. Editing the archive must deploy nothing -- and the # the tool-call grammar. The planner's rule is a PATH rule, so an edit at
# archive must still be present, so this cannot pass by the file having # that path deploys nothing no matter where the bytes currently sit: the seam
# vanished. # deliberately does not assert the archive exists. Trash is a purgeable hold
[ -f "$REPO_ROOT/scripts/.AGENTTRASH/chat-template.jinja.ik-llama-qwen-sharp" ] # (untracked in #111, and the deletion policy says the purger deletes it), so
# pinning it fails every fresh checkout and proves nothing about planning.
run python3 "$PLANNER" --target dankclaw --paths scripts/chat-template.jinja run python3 "$PLANNER" --target dankclaw --paths scripts/chat-template.jinja
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[ -z "$output" ] [ -z "$output" ]
+26 -1
View File
@@ -10,7 +10,8 @@
# 4. both media units bind the VIEW and refuse to start without it; # 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 # 5. only those two units touch the view — the LLM and library tenants keep the
# raw volume, so the blast radius is two containers; # 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. # 7. the planner routes a change to these files to both media services.
load helpers load helpers
@@ -110,6 +111,30 @@ UNIT="$REPO_ROOT/dankserver/host/systemd/media-meta-view.mount"
done 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" { @test "the planner routes the view files to both media services" {
run python3 "$REPO_ROOT/scripts/deploy-plan.py" --target dankserver --paths \ run python3 "$REPO_ROOT/scripts/deploy-plan.py" --target dankserver --paths \
scripts/media-view.sh dankserver/host/systemd/media-meta-view.mount scripts/media-view.sh dankserver/host/systemd/media-meta-view.mount