Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cf388367e | ||
|
|
13830865f6 | ||
|
|
010b9affc7 | ||
|
|
ed5eed6d54 | ||
|
|
14f340370c | ||
|
|
50e0c5a786 | ||
|
|
649bbafa17 | ||
|
|
b7c19592e5 | ||
|
|
6c115c1394 | ||
|
|
6d79709cc0 | ||
|
|
0336f42e2a | ||
|
|
0f4a527337 | ||
|
|
1d92627935 |
No files matched your search
@@ -818,6 +818,7 @@ per-attempt page and no per-swap ledger anywhere else.
|
||||
| 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` |
|
||||
| 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
|
||||
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. |
|
||||
| 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, 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. |
|
||||
| Real secrets | **Never commit** — host `.env` files; `.env.example` holds placeholders only. |
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+18
-1
@@ -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 ;;
|
||||
|
||||
@@ -66,3 +66,24 @@ exactly describes the year this bug survived.
|
||||
volume absent they start `active (healthy)` against empty directories, which is
|
||||
how a missing 91 TiB volume hides. See [[Dankserver Stack]] for the stack and
|
||||
[[Storage Policy]] for what may be written here.
|
||||
|
||||
## Write doors
|
||||
|
||||
Something renamed movie folders on this array with no journal in any of the
|
||||
`/mnt/entropy/*.jsonl` plans, no bash history, no cron, no timer, and no job
|
||||
artifact. The census of things that can mutate it:
|
||||
|
||||
- **A local process** as `dank` or root. `scripts/media-apply.py` is the only
|
||||
one that journals; anything else (a hand `mv`, another session's script) is
|
||||
silent.
|
||||
- **SFTP by a human.** GNOME Files on a workstation mounts the array through
|
||||
`gvfsd-sftp`, which runs `sftp-server` on this host as `dank`. Renames and
|
||||
moves are direct `rename()` calls: no shell, no history, no journal. A folder
|
||||
rename bumps **only that directory's ctime** — its mtime and every child's
|
||||
ctime stay at their old values, so the files look untouched while their paths
|
||||
are gone. Diagnose it with `pgrep -af gvfsd-sftp` on the workstation and
|
||||
`ss -tnp` for the peer.
|
||||
- **Copyparty is not a door**: the bind is `ro` at the mount level, so no HTTP
|
||||
or WebDAV write reaches the array regardless of account flags ([[Copyparty]]).
|
||||
- **No SMB or NFS server exists here** — the `*arr` stack on FrankBook-Pro
|
||||
cannot reach the array at all, so it cannot rename, import, or rescan it.
|
||||
@@ -18,7 +18,7 @@ clients.
|
||||
public vhost `jelly.franksdank.cloud` behind the [[Anubis]] guard chain →
|
||||
[[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 |
|
||||
|------|-------|
|
||||
@@ -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=/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) |
|
||||
| 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 |
|
||||
@@ -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/cache -> /cache (JELLYFIN_CACHE_DIR, XDG_CACHE_HOME)
|
||||
/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
|
||||
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).
|
||||
sees 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.
|
||||
|
||||
**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 :
|
||||
'/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
|
||||
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 +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
|
||||
`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.
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
---
|
||||
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.
|
||||
- **Re-verify each survivor by size, not by the recorded path, before purging.**
|
||||
Detection artifacts and plans key on paths, and a folder renamed by an
|
||||
interactive session ([[Entropy Volume]], *Write doors*) makes its keeper look
|
||||
deleted while it sits untouched under the new name. The 2026-11-01 audit hit
|
||||
three such groups: every keeper was present, same bytes, different folder. A
|
||||
purge decided from the plan's paths alone would have reported survivors gone.
|
||||
|
||||
## 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]].
|
||||
|
||||
The Plex leg may be deferred on purpose: stop **and mask** Plex (`systemctl --user
|
||||
stop plex && systemctl --user mask plex`) so its metadata fetches cannot stack on a
|
||||
live Jellyfin scan, and because a masked unit stays stopped through a reboot —
|
||||
`unmask` before starting it. The 2026-11-01 morning ran it once Jellyfin reached
|
||||
`Idle`: one accepted `refresh` on the Movies section cleared every row that named a
|
||||
held path.
|
||||
|
||||
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.
|
||||
|
||||
One residue is normal, so do not chase it: a section pass rewrites the feature rows
|
||||
first, and rows for clips in a film's `other/` or `extras/` subfolder can still name
|
||||
a held path after the section's `refreshing` flag is back to `0` — and a second
|
||||
section pass does not clear them either, so do not run one hoping for `0`. Beside
|
||||
`REAL: 0` that is a dead disc-menu clip — not a false prune, and no reason to touch
|
||||
the cache, the database, or the hold.
|
||||
|
||||
## 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
|
||||
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
|
||||
timestamp: 2026-11-01T01:00:00Z
|
||||
---
|
||||
|
||||
# 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
|
||||
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 <svc> ls -la
|
||||
<its data path>` 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.
|
||||
|
||||
Re-verified 2026-11-01: `mount` shows the overlay on
|
||||
`/var/lib/media-meta/view` (`lowerdir=/mnt/entropy`,
|
||||
`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
|
||||
|
||||
@@ -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`. |
|
||||
| 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
|
||||
|
||||
@@ -3,7 +3,7 @@ type: reference
|
||||
title: Media Relayout
|
||||
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]
|
||||
timestamp: 2026-10-30T00:00:00Z
|
||||
timestamp: 2026-11-01T01:00:00Z
|
||||
---
|
||||
|
||||
# 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
|
||||
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
|
||||
|
||||
A box set that arrives in the movie share stays there until someone moves it:
|
||||
|
||||
@@ -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) |
|
||||
| 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 | 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 |
|
||||
| Port | `32400/tcp` published on `0.0.0.0` — zero client reconfiguration |
|
||||
| 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
|
||||
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
|
||||
(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
|
||||
PMS database keeps resolving. The one metadata change: rootless podman
|
||||
cannot map the real host uid 997, so the fleet model applies — container
|
||||
@@ -86,7 +86,22 @@ root runs as the `dank` user, and the deployer adopts the data root with
|
||||
4. **Cold container boot is slow.** PMS pre-binds 32400 and empty-replies
|
||||
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
|
||||
600 s before failing; later boots are much faster (warm page cache).
|
||||
600 s before failing; later boots are much faster (warm page cache). A
|
||||
`systemctl --user start` issued after an `unmask` is a job in the manager,
|
||||
not in the shell that issued it: it lands minutes later and survives that
|
||||
client dying, so a wrapper script that looks hung is usually only waiting —
|
||||
read `is-active` and `podman ps` before declaring a fault, and give every
|
||||
step of such a script its own `timeout`.
|
||||
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
|
||||
|
||||
@@ -121,10 +136,13 @@ shred -u /tmp/px.tok
|
||||
```
|
||||
|
||||
Every section answers `204` — or `200` with a Directory body when
|
||||
`Accept: application/json` is sent. Inside the quoted `printf` format a
|
||||
backslash-newline is LITERAL, not a continuation, so `mk` writes two `printf`s:
|
||||
one glued line and one stray backslash is a config file curl rejects, and the
|
||||
failure prints as `option -K: is unknown`, never as a bad config line.
|
||||
`Accept: application/json` is sent. A refresh fired while PMS is still warming
|
||||
answers `000` instead (the empty reply of gotcha 4, arriving mid-request): retry
|
||||
until it answers and never restart the service over it. Inside the quoted
|
||||
`printf` format a backslash-newline is LITERAL, not a continuation, so `mk`
|
||||
writes two `printf`s: one glued line and one stray backslash is a config file
|
||||
curl rejects, and the failure prints as `option -K: is unknown`, never as a bad
|
||||
config line.
|
||||
|
||||
Proof that a rename landed is not a scan response: `scripts/media-verify.py`
|
||||
replays the run's journal against the database — the only read that
|
||||
|
||||
@@ -107,6 +107,12 @@ job.sh watch # refreshing status table
|
||||
`ssh 'bash -c "python3 -c \"…\""'` quoting broke repeatedly (unterminated
|
||||
quotes; `grep -c` returning `"0\n0"` because `|| echo 0` appended a line)
|
||||
every failure disappeared once the script was a file.
|
||||
- **A script fed over stdin owns that stdin.** `ssh host 'bash -s' <<'EOS'` hands
|
||||
the script itself to stdin, so any child that reads stdin — `podman exec -i`,
|
||||
`ssh -t`, a bare `read` — swallows everything after it and the remainder
|
||||
vanishes without a word. It reads exactly like a hang: the commands before the
|
||||
child print, the ones after never existed. Drop `-i`, or give every child
|
||||
`< /dev/null`.
|
||||
|
||||
## Verify before trusting it (3am law)
|
||||
|
||||
|
||||
@@ -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. |
|
||||
| [[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 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 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. |
|
||||
|
||||
@@ -28,15 +28,16 @@ REMOTE_DEPLOY="$REPO_ROOT/scripts/deploy-remote.sh"
|
||||
! 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
|
||||
# (retired 2026-09-16 in favour of halogen-flash-server again). The
|
||||
# llama.cpp frank-zealot runtime serves the template the GGUF itself
|
||||
# carries, which is the one with reasoning_effort, preserve_thinking and
|
||||
# the tool-call grammar. Editing the archive must deploy nothing -- and the
|
||||
# archive must still be present, so this cannot pass by the file having
|
||||
# vanished.
|
||||
[ -f "$REPO_ROOT/scripts/.AGENTTRASH/chat-template.jinja.ik-llama-qwen-sharp" ]
|
||||
# the tool-call grammar. The planner's rule is a PATH rule, so an edit at
|
||||
# that path deploys nothing no matter where the bytes currently sit: the seam
|
||||
# deliberately does not assert the archive exists. Trash is a purgeable hold
|
||||
# (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
|
||||
[ "$status" -eq 0 ]
|
||||
[ -z "$output" ]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user