A parked folder whose name parses to a registered show merges into the row
that owns the show. The proof is the episode rows standing under the owner
folder — never the id-less folder list, because one tvdb id can carry two
`Series` rows and an id-less folder that parses gets identified on its own
and keeps its own folder, offering the show twice.
No library file moves while `Scan Media Library` is Running: wait for a
Running sighting and then two consecutive Idle polls, because a gap between
sub-tasks reads as finished. Both sides speak different path dialects (the
server records the container coordinate, the journal speaks the array), so a
join that forgets the translation reads as an empty library.
Lands on [[Media TV Relayout]] — the page that owns the TV arm — so
`Media Relayout.md` stays under the OKF ceiling.
Media Relayout.md crossed the OKF 500-line ceiling with the 2026-11-02 TV
laws, and the ceiling was right: the TV arm reads a different authority and
refuses for different reasons, like the music and audiobook arms that already
have pages. The arm moves verbatim to Media TV Relayout.md and gains two laws
from the TVShows pass: a folder naming two series is not evidence of two
series (ffprobe's embedded title tag answers per file), and right numbering
with a wrong name means rename the folder only. The executor page keeps the
executor laws and gains the hold-outside---root/--force law; Jellyfin.md gains
the settle read (ScheduledTasks State, since /Library/ScanStatus is a 404 on
12.4). Index regenerated with gen-vault-index.py.
cwd: /home/blank/.omp/wt/wt-20261030-163847-6d1fc1f · session: 01a18f2e-18b1-7573-bcd5-ac99636f8bcb
The existing rule says don't `pkill -f` a pattern your own command line carries,
and to prefer `pkill -x` — but a remote one-shot often has to name the pattern,
and hitting it again today cost a silent `ssh` exit 255 with zero output, which
reads like a network fault. Record the escape (`"serve[-]script.sh"`), the zsh
glob-expansion trap that makes an unquoted `[s]` a no-op, and the exit-code
signature.
The `Shows` flip landed today (one field, proven in the server's own
`LibraryOptions` answer), so the posture table no longer calls it a silent file
index and `Music`/`Collections` are named as the remaining ones.
Two corrections the same day's measurements force:
- The per-library `POST /Items/{id}/Refresh` answered `204` and queued nothing
both before the flip and after it — zero journal lines for ten minutes, and
`Scan Media Library` kept `LastTaskResult=None, EndTimeUtc=None`, the never-run
signature. The provider switch was never the reason that door is dead; only
`POST /Library/Refresh` runs (204, then `Running` inside 15 s at 33 % CPU), and
it walks every library, so a library-scoped pass has no door on this build.
That paragraph also records the null-pair read that separates "never ran" from
"just finished", and that query+body on the same call answers `400` with an
empty body whatever the values.
- The key law: a plugin owns its own config file under its own
`<PluginConfiguration>` root and omits null settings, so TVDB's `<ApiKey>`
element never existed and `install` refused — which is what `MISSING key` had
been reporting since October. PR #121 fixes the writer.
The `Shows` art census replaces speculation with the split that matters: 2,908 of
3,558 series+season carry a primary, 624 have no provider id, 26 have an id and no
poster. A `Download` campaign over them took 20 s and downloaded nothing, which is
the measurement that says identification is the only lever left.
TheTVDB writes its own configuration file and its serializer omits a
null-valued setting entirely, so a fresh install has no <ApiKey> element to
fill, under a root element called <PluginConfiguration>. xml_secret's
fallback insertion was hardcoded to </Configuration>, matched nothing, and
install refused with "no place to write <ApiKey>" — measured on Dankserver
2026-11-02, where the TVDB key has been present in the secret file since
October and has never been installable.
The anchor now comes from the document's own root, so both shapes Jellyfin
actually produces work (the core plugins' <Configuration> and a plugin's
<PluginConfiguration>), and the refusal is kept for a file with no root at
all. Tests: the plugin-written shape is red before this commit and green
after; every other setting in the file is asserted preserved.
Four laws from finishing the un-shadow, correcting one:
- A book whose files ALL fail to probe never becomes an item, so a disk-vs-DB
folder gap beside `Invalid data` lines is corrupt media, not a lost book. A
format-only ffprobe clears files the scanner rejects — do not trust it.
- `Disc 01`/`CD 1` subfolders are parts of one item (110 of them here, under a
192-track book); the extension census counts them, ABS does not.
- `.cbr`/`.cbz` group like media but `parseComicMetadata` throws `ERAR_BAD_ARCHIVE`,
so those items land media-less: the file, not the scanner.
- Scan completion is a NEW scan uuid; `podman logs --since` is a sliding window
and ages its own evidence out.
- `?force=1` is now recorded as neither discovery nor repair: a forced full
re-scan left item count, path set and media-less count identical.
Verification names the five book folders that need re-acquiring.
Corrects the law pushed earlier today. The mechanism is
`groupFileItemsIntoLibraryItemDirs` (utils/scandir.js), called on a live
`recurseFiles` walk of the tree EVERY scan: each file attaches to the FIRST
directory along its path that already has a group, so one media file at a
collection root owns everything beneath it. Measured: one 949 MB `.cbr` hid
652 book folders behind a single 5,693-track item.
- "Media file" is audio OR ebook (`audiobooksOnly=false` -> SupportedAudioTypes
∪ SupportedEbookTypes, 17 audio + 6 ebook extensions). An audio-only census
cannot see the file that hides an Audiobooks library.
- Deleting the covering item does not fix a shadow: grouping is recomputed from
disk and the next scan rebuilds the item (measured: `1 Added`, same path).
- Un-shadow by MOVE: one file per folder is ABS's own single-book layout.
- `?force=1` is not a discovery arm (both arms walk the disk; force re-scans
every existing item, 1:33 against 0:04).
- Paging corrected: `offset` is ignored outright, `page` is the parameter.
- `DELETE /api/items/:id` is a DB row; `?hard=1` is `fs.remove()` of the whole
swallowed subtree. Never pass `hard`.
Shape table gains the layout row and the move journal; the stale "1,103
shelves" scan count is destroyed.
Measured on the live library: 6 stray files at the root of _unsorted made the
whole collection dump ONE item of 5,693 tracks, with 542 real books folded into
it; no scan subdivides an existing item. Also pinned: the items endpoint hands
back ceil(total/limit)*limit rows because an out-of-range offset repeats the
tail (that is where "exactly 1,500" and "exactly 4,000" came from), and
DELETE /api/items/:id?hard=1 runs fs.remove() on the item's folder - pointed at
a covering item that is the whole subtree, so un-swallowing is a DB-only delete
after the stray root files are filed.
Two censuses this session reported exactly 1,500 and exactly 4,000 books for one
library mid-scan. Cause: an out-of-range offset returns the last item instead of an
empty page, so a loop written "until the page is empty" manufactures its own
total, and total itself moves while a scan is running. Stop on total; count after
the pass settles.
A per-episode RemoteImages query is never cached, so the same campaign that
dressed 620 series in an afternoon costs ~11 s per episode for one hit in five.
Across 42k episodes that is days of provider traffic to dress a fifth of them, and
clients show series and season art regardless. Recorded so nobody runs the pass on
a hunch; the door and the resumable log are the same if Frank orders it.
The first full scan logged "Invalid data found when processing input" per file and
carried on. That line is about the file: the scanner skips it, so the book never
appears in the library at all. Census of the 43 refused files: 38 were 0 bytes, 3
held WAV bytes behind a .mp3 name, 2 were unreadable. The check to run first is a
byte count, not a server investigation.
Full suite: 818 tests, exit 0.
The page sent the next agent to POST /Items/{id}/Refresh?Recursive=true per
library to make a new folder visible. Measured against a Shows library that had
never been identified: that route answers 204 and queues nothing — item census
byte-identical, ScheduledTasks idle, journal silent, and still a silent 204 after
a service restart. The door that works is POST /Library/Refresh, and the progress
read is ScheduledTasks -> Scan Media Library -> CurrentProgressPercentage.
RefreshStatus on the VirtualFolders record stays Idle through a real scan, so it
is not a progress read at all; a 204 cannot distinguish accepted-and-discarded
from accepted-and-queued, so the acceptance read is the journal naming folders.
Also recorded the art door's wire shape, because guessing it reads as a server
bug: the list response carries the address in Url (there is no ImageUrl), the
Download POST takes camelCase type/imageUrl and answers 204, and PascalCase or an
empty imageUrl answers 500. RemoteImages is empty for items with no ProviderIds,
so art coverage is capped by identification, not by the image door.
The page claimed the account can only be created from a LAN address; measured,
the door is POST /init and it answers plain text. Replaced with the bootstrap the
deployer now runs, and with the doors a consumer actually needs: /status reports
isInit with no /api prefix, the item count rides /api/libraries/{id}/items ->
total (the library DTO has no totals), and the list DTO carries media.numTracks
while /api/items/{id} returns media.audioFiles.
New law: the folder watcher cannot cross the rootless podman bind — the same gap
as Jellyfin's auto-scan note — so files added on the host stay invisible until a
scan runs, and the deployer's re-run of the bootstrapper is the cheap trigger.
Testing Policy lists the new seam; index.md regenerated from frontmatter.
A freshly deployed Audiobookshelf answers /healthcheck and is otherwise dead: no
account, no library, no scan. The UI registration step is gone by design — the
doors are POST /init (200 with plain text "OK", not JSON) and /status (isInit),
and there is no /setup route. That is an API-only bootstrap, so it must be code.
scripts/audiobookshelf-init.sh is idempotent: /status -> /init when unregistered,
POST /login for the bearer, POST /api/libraries for the Audiobooks library on
/audiobooks, then POST /api/libraries/{id}/scan. The deployer runs it only after
the health gate has answered.
Credentials live in ~/.config/secrets/audiobookshelf (mode 600): the bearer rides
an Authorization header and the password only a POST body — never a URL or argv,
where any ps on the host would print them. The seam drives an inline stub server
that logs every request line, and asserts: fresh bootstrap creates account,
library and scan; a second run creates nothing new and still fires the
incremental scan; no secret appears in any logged request line; a server that
refuses is reported as "refused: <url>" with exit 1 rather than success; ABS_SCAN
outside 0|1 fails before a single request; the deployer orders init after health.
Measured on Dankserver: run against the already-bootstrapped host it is a no-op
that re-fires the scan; the library then scans (1,500+ books, every item with
audio tracks, none invalid).
The first deploy of the unit failed its own health gate in the worst possible shape:
systemd said active (running), the engine logged a clean database init, and the
server then listened on :80 under base path /audiobookshelf - so every probe of
:8000 was refused and wait_for_health timed out at 180s beside a container that
looked healthy.
Cause: ABS_PORT / ABS_CONFIG_DIR / ABS_METADATA_DIR / ABS_PUID are the npm
package's names, printed by the server README; this image never reads them. Its own
contract, read off the pinned digest with podman inspect, is PORT=80
CONFIG_PATH=/config METADATA_PATH=/metadata, and the server greps for exactly PORT,
CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH. The two path variables appeared to
work only because the image already exports the same values - the coincidence that
made the inert lines look correct.
The unit now speaks the image's names, adds ROUTER_BASE_PATH=/ so the service is
the plain host:port the docs and the homepage card promise, and the dead ABS_*
lines are deleted instead of kept as decoration. Proven against the same digest in
a throwaway container (temp state dirs, loopback-only mapping, removed after):
base path "/", listening on :8000, GET /healthcheck -> 200, and the old prefixed
path -> 404.
Guards: the quadlet seam now refuses an Environment=ABS_* line and requires
PORT/ROUTER_BASE_PATH; the vault page gains the law with its failure signature and
the two-second image-inspect check. Suite: ALL TESTS PASSED, 812 ok.
The page owns the shape, the first-run account step (ABS accepts the initial
account only from a local-network address) and the laws: why the array bind is
read-only and what the one-line rollback is if on-disk sidecars are ever wanted;
why the metadata view is not that rollback; why the mount gate, the wget health
check and the amd64 digest pin are each mandatory; and that LAN-only is a posture,
not an accident.
Media Audiobook Relayout gains the third reader: ABS stores absolute paths, so the
relayout procedure has to reach its library too or it keeps rows for folders that
are gone. The vault index is regenerated, not hand-carved.
Admin category, Media Library Apps section, LAN-direct url and probe like Tautulli:
the service is internal-only, with no vhost and no DNS name, so it earns a card and
no public link. The art is a mono-ab.svg monogram in the existing hexagon family.
Card counts in the e2e specs come from the live registry, so this entry is the test
update as well as the page change - and the admin walk will follow the link, which
is why the service deploys before the homepage ships.
Audiobookshelf serves /mnt/entropy/Audiobooks to browsers and phones with its own
library and per-listener positions. The design point of the unit: every byte this
server writes is its own state - SQLite library, accounts, progress, cached covers
- and all of it lands on the NVMe under /home/dank/Containers/audiobookshelf. The
audio bind is :ro, so a stray sidecar write onto the spindle is structurally
impossible rather than merely discouraged, and the array keeps exactly one
sanctioned write path (scripts/media-view.sh).
Three failure modes the unit is shaped around:
- Bind-source ordering. In a rootless unit RequiresMountsFor= is inert and
ConditionPathIsMountPoint= is fail-dead, so a missing array does not stop the
start: podman invents an empty directory and the first scan records an empty
library as truth. ExecStartPre=wait-for-mount.sh is the gate that works here,
the same one copyparty uses.
- The health binary. The image is Alpine: wget, no curl. A curl HealthCmd reads a
healthy server as unhealthy and Restart=always turns that into a loop.
- The image pin. podman records the linux/amd64 manifest for what it pulled, not
the manifest-list digest `latest` points at, so the amd64 digest is the pin that
names the build this host runs. Upstream version tags stop at 2.19.0 while
`latest` is newer, so latest@digest is the only form that is both current and
exact - the jellyfin/romm precedent.
The first-boot scan reads the whole tree once, so IOWeight=10 keeps it behind
playback; that weight is only honoured because the user.slice accounting drop-in
and the user@1000 delegation are installed by the deployer.
Deployer registration is the standard four touch-points (client gate, remote
allowlist + handler + dispatcher, planner order/set/path map, test routing). The
handler creates both state roots before the first start - letting podman invent
them leaves the SQLite file unwritable by the container user under a rootless
first boot - and gates on /healthcheck.
Measured 2026-11-02 against the live server. The per-library posture goes on the
page because two libraries are silent file indexes right now: Shows and Music
carry EnableInternetProviders=false, so a refresh on either answers 204 and
stores nothing — any metadata or artwork work there starts by flipping the
switch, not by scanning. Movies additionally has EnableRealtimeMonitor=false, so
a folder moved into /data/Movies stays invisible until a recursive refresh names
it; "Jellyfin does not see the file" is the designed answer, not a fault.
Recorded with it: the option door on 12.x is POST
/Library/VirtualFolders/LibraryOptions (GET answers 405 — that is how the route
is found) while the 10.x /Update and /Refresh routes answer 404 outright, and
the VirtualFolders DTO is PascalCase, so a census written against the old
camelCase keys reads None and misreports a healthy library as unconfigured.
A library record can also be a phantom: a 2026-11-01 capture carried Shows2,
Shows3 and Music2 with empty Locations while only four libraries existed, so the
live set is the entries with non-empty Locations and never the record count.
Measured 2026-11-02 while a TV dedupe pass moved folders on the raw path: the
merged tree still answered stat at the old coordinates, link count 0, while ls of
the same parent no longer listed the name. Overlayfs keeps a dentry for a lower
file moved underneath it, so a tool that tests existence with [ -e ] against the
view can believe a file its own readdir denies — the media servers walk by
readdir, so this bites a script and never a user.
Recorded with it: every pass that moves or measures media walks /mnt/entropy, and
the one reader allowed to point at the view is the tool that needs show identity.
media-tvdupes.py censuses a TV library by episode key and never by folder. 10 of
the 25 shows that looked duplicated in the 2026-11-02 census held complementary
halves (Game of Thrones Complete carries no S01 or S08, Modern Family S05 is the
only copy of season 5), so a folder-level verdict would have staged some 340 GB
of unique episodes to recover 54 GiB of true copies.
Show identity comes from the folder's [tvdbid-N] tag, from the tvshow.nfo the
metadata view exposes, or from an operator --ids map. A derived id that
contradicts a tag is dropped and reported, never unioned: Jellyfin's provider
table carries a 2026 special's id on the folder tagged [tvdbid-73838], and
unioning the two makes one show of a 2000 series and a 2026 special whose bigger
files then dominate the original's pilot. Files named by an id key only when the
server's own episode table (the --witness) names them, so a bare number is never
mistaken for an episode.
Seam: tests/media_tvdupes.bats — 12 assertions on which files land in the plan
and which the tool refuses to touch, on a synthetic library.
The Movies library had indexed 2,571 films and had never asked a provider
for a single one: no TMDb id, no plot, no rating, no poster. Nothing was
broken in the provider chain — the plugin is core in 12.4 and active, its
key is set, image.tmdb.org answers the container — and nothing was broken
in the write path: the overlay view has been read-write since the 2026-10-31
cutover, and movie.nfo lands on the NVMe upper layer today.
The state was two settings in the per-library option file,
config/root/default/<Library>/options.xml (Jellyfin 12 renamed it from
library.xml; the database stores none of these options, so a census that
greps for the old name and then queries the DB concludes nothing is
configured and goes hunting elsewhere):
<EnableInternetProviders>false</EnableInternetProviders>
<TypeOptions><ImageFetchers /> (empty, beside a populated
<MetadataFetchers> and a fully
populated <ImageFetcherOrder>)
With the internet switch off every refresh answers 204, stores nothing and
logs nothing, so the item count reads like a healthy scan — this passed the
deploy health gate, the Playwright suite and three agent audits. Order is
not enablement: the fetcher ORDER stays populated while the ENABLED set sits
empty, and the populated metadata list beside it makes the library look well.
verify now audits every library's options.xml and fails with `SILENT INDEX
<Library>: <what>` on providers-off or on a type whose image fetchers are
empty while its metadata fetchers are named; an options.xml that never
carried the switch (BoxSets, written before the property existed) is the
vendor default and stays accepted.
Vault: the page claimed artwork is a sidecar like movie.nfo. It is not — no
image saver is bound for any library, so art goes to the internal store and
Plex never reads it. The claim is corrected, and the RemoteImages diagnostic
that separates a provider fault from a library fault is written down with
the 12.x API shape traps (Items needs UserId; MetadataRefreshMode has no
Full/ValidationFailure, ReplaceAllMetadata=true is what re-runs providers).
A folder-rename pass ran on the media array with no journal anywhere: no
jsonl on the array, no bash history, no cron/timer/job artifact, no other
host ever authenticating. The door was GNOME Files mounting the array over
SFTP (gvfsd-sftp -> sftp-server as dank): rename() with no shell and no
journal, bumping only the renamed directory's ctime. Copyparty cannot do
this (its bind is ro at the mount) and the *arr stack has no route to the
array at all (no SMB, no NFS, mini asleep).
Two laws fall out for the prune pipeline: re-verify survivors by size
rather than by the path a stale artifact recorded, and Plex extras rows
naming a held path survive a second section pass.
The 2026-11-01 morning leg cleared every Plex row naming a held path, so the prune page records the outcome plus the residue that is normal: a section pass rewrites feature rows first and can leave rows for clips in a film's other/ or extras/ folder standing after the refreshing flag is back to 0. Beside REAL: 0 that is a dead disc-menu clip, not a false prune, and no reason to touch the cache or the database.
Plex gains the two operational laws the run taught: a start job issued after an unmask belongs to the manager, not the shell, and lands minutes later surviving the client that issued it; a refresh fired while PMS is warming answers 000, so retry it rather than restart the service.
The trap that made a finished job look hung belongs to the remote job runner: a script fed to bash -s IS the stdin, so any child that reads stdin (podman exec -i) swallows the rest of the script without a word.
`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.
`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.
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.
The Attack on Titan box set on the array names two files S03E2019 and
S04E47370355, and the provider rows the metadata server recorded for them carry
the same junk. EP_RANGE capped its first episode group at three digits without
guarding the run, so the pass-2 plan filed both under E201 and E473 — slots
that belong to real episodes — inside 進撃の巨人 (2013) [tvdbid-267440].
- guard the first episode group the way the second was already guarded, so a
longer digit run states no marker at all; a glued resolution stays legal,
because S01E01-1080p is still ONE episode
- cap a provider row's episode at EP_MAX: such a row is renumbered from tvdb's
title when the series' id answers, and parks with a reason that names the
junk when nothing answers
- split the operator-map exceptions into the two things they are: a folder that
IS on the array whose files never became a series (bonus material with no
owner: Fairy Tail, Kimetsu no Yaiba, Dorohedoro) is no longer reported as a
folder the array no longer holds, and the missing-folder hint says the review
pile counts as moved because the map reads top-level folders only
- the vault's evidence list carries the law; the four tests were read red
against the shipping planner before any of it changed
Replanning the two real files now parks them under their own names with the
reasons "a provider row of season 3 episode 2019" and
"… season 4 episode 47370355".
Tvdb titles a record in the show's own language and keeps the English name in
aliases the name search never reads, so the rule that accepts only tvdb's own
spelling of a query can never resolve them: ブラッククローバー (331753) holds no
alias at all, and the bare alias "One Piece" belongs to 392276, the 2023 live
action, not to the 1999 record (81797) that 1,072-file pack is. Seventeen pack
folders on the array — 3,507 files — are in that class.
media-shows.py now reads scripts/media-shows-tvdb-ids.tsv by default: one line
per pack folder, folder <TAB> tvdb id <TAB> note, every id read from tvdb's own
record. The map outranks a provider id, because a box set's provider id is the
server's own guess at a folder name, and it merges the release folders that are
one show. Everything it touched goes to the exceptions: every folder it named,
every row that matched no folder, every disagreement with a provider id, every id
tvdb never answered. The file is read fail-closed — an id that is not a number,
or one folder named twice, stops the plan, because a typo there renames a pack of
1,268 files to the wrong show.
scripts/media-shows-tvdb.py is the sheet the map was built from, promoted out of
/tmp so the next run is reproducible: it reads a plan's mkdir rows, asks tvdb
about every destination folder carrying no [tvdbid-], and grades the answer
EXACT / PUNCT / NEAR / NONE by how much was guessed to reach it. Acceptance is
unchanged from the passes it replaces — bare-form equality plus the year-span
guard — and that gate is what rejected three confident picks: Fairy Tail's 2024
sequel (410031), the 2026 Hokuto no Ken remake (465216) and Mugyutto! Black
Clover (378762) offered for Black Clover. The sheet is for the operator; the map
is what the planner reads, and only after he approves the rows.
Tests: seven for the map's behaviour — rename to the record it names, two release
folders becoming one series, precedence over a provider id and the line stating
the disagreement, an id tvdb never answered, a row whose folder is gone, a
malformed id refusing the run, a folder named twice refusing the run — plus one
that the shipped file parses and never stops a plan.
Tvdb's default episode query answers at most 500 rows and states no total.
One Piece (1999), Crayon Shin-chan (1992) and Detective Conan (1996) each
answer exactly 500 while holding over a thousand, so the contradiction rule
added in the previous commit read every long-running show as contradicted:
the pack books more episodes than "the authority holds", and the rule that
exists to stop a wrong anime title would have stripped the right one from
every file of the season.
A capped list now records that it is capped (`tvdb:capped:<id>`, so a warm
cache answers the same question a cold one did) and contradicts nothing;
titles for the episodes the page does hold still ride.
Test: a 500-row page with a 501-episode pack keeps ` - Episode 1` and lists
no contradiction. Red before the change, proved in a copy of the tree with
the cap raised past the fixture.
Three defects in the TVShows planner, all three visible in the applied plan:
- A series folder named a show by words alone. Jellyfin's Tvdb agent and
Plex's scanner both read `[tvdbid-NNNN]`, so the resolved id now rides the
series folder — and only the folder: a file name gets 180 characters and
the episode title needs the room.
- 1,412 files parked because their provider row said `season 0`. Season 0 is
a legal season; Jellyfin's contract is `Season 00` for specials.
- 1,033 files parked because Jellyfin's date matcher writes the AIRDATE YEAR
into the season column. Such a row is now renumbered by the title tvdb
states for the series' id, read backwards off its own episode list. A
title tvdb gives to two episodes names no episode, and that file parks
with the reason it always carried.
A season tvdb's own episode list CONTRADICTS — a release pack books more
episodes there than the authority holds, the anime numbering hazard — keeps
its rename and loses the tvdb title, because the slot its number points at
is a different episode. Those series are listed in the exceptions file,
which is where the operator reads them.
Tests: six through the fixture (five red before the change; the ambiguous
title is a guard that the recovery must never guess). Full bats suite green.
The crypttab entry named the md RAID array UUID (61bd6eaf-...) where a luks
entry takes the LUKS container UUID (cd86c728-...), so
systemd-cryptsetup@entropy.service could never produce /dev/mapper/entropy and
the fstab entry could not resolve either; the volume only appeared when a
desktop session let udisks2 unlock it, which kept the broken boot path hidden
for a year. The keyfile was wrong as well: 27 bytes, i.e. the 26-character
passphrase plus a trailing newline, and cryptsetup hashes the file whole.
Records the host config as dankserver/host/{crypttab,fstab} (the dankclaw/host
precedent), rewrites the Entropy Volume page to own the chain, the UUID law and
the keyfile contract, and comments out the now-unmountable /dev/md0 line.
Proven on the host: /etc/{crypttab,fstab} byte-identical to the repo copies,
reboot into 7.0.0-42-generic mounts /mnt/entropy from /dev/mapper/entropy, all
12 containers returned, frank-archon back to 141 t/s (was 6.7 t/s on a wedged
amdgpu queue). ./tests/run.sh: ALL TESTS PASSED (749).
The taxonomy was written as something an operator assembled by hand; the tool now
emits `on disk at dst` / `REAL` / `gone` lines, which is the difference between
reading `missing from the DB: 1851` as 1851 lost albums and reading it correctly.
cwd: /home/blank/.omp/wt/wt-20261030-163847-6d1fc1f · session: wt-20261030-163847-6d1fc1f
"No row at the destination" was one number, and it conflated three different
things: a sidecar the server never indexes, a file a later pass renamed again,
and a file that never left its source — the last one being actual damage. The
runbook already named the four classes; the tool never computed them, so an
operator reading `missing from the DB: 1851` had no way to tell a booklet.pdf
from a lost album and might have run a rollback over a false alarm.
`missing` rows are now stat'd on both paths and reported as
`on disk at dst, no row names it` / `REAL — file still at src` /
`gone — at neither path`, with offenders listed in that order. A row still
naming `src` stays a separate line: that is the server's own stale pointer, a
phantom, and it costs a re-scan rather than a restore.
Two new tests (REAL, and gone-vs-sidecar) plus two existing layout assertions
loosened from line positions to content. 14/14 in tests/media_verify.bats.
main split the music and audiobook arms out of Media Relayout.md and put a
locator in their place, so the branch's trims inside those sections now live on
the two child pages, and the parent keeps main's locator. The two Jellyfin laws
the branch moved to Jellyfin.md stay moved; the sweep law keeps the branch's
wording ("until the next sweep"), which is the one the next line depends on.
Full suite: 758 tests, all green.
The two Plex arms of media-census.py — `plex` (the movie planner's title
authority) and `audiobooks` (the chapter rows the audiobook plan files by) —
opened the library `mode=ro&immutable=1` while the Jellyfin arms already used
plain `mode=ro`. Same defect as media-verify.py: an uncheckpointed row of a
running scan is invisible, so a just-scanned item is simply absent from the
authority file a plan is built from, and the pass cannot say why.
Both now go through open_plex_ro(), which tries plain ro first and keeps
immutable only as the fallback for a database whose -shm cannot be read. The
new audiobooks test leaves a chapter row committed-but-uncheckpointed and
asserts the export still carries it; red before the change, green after.
Three things the 2026-10-31 five-library relayout taught, written where the
next operator reads them:
- A verify run must open the servers' databases plain `mode=ro`. Both keep
their libraries in WAL mode; `immutable=1` skips the write-ahead log, so a
scanner's own committed rows read as missing and the read can fail as
"database disk image is malformed". The census page claimed immutable — now
it says what the code does.
- "No row at the destination" splits into four classes, and only REAL (no row
at dst AND the file still at src) is the relayout's own damage. Phantom rows
naming a pre-move path are normal behaviour for both servers. And the owning
server decides whether a pass can convict: Jellyfin holds no audiobook
library, so that journal verifies against Plex or it proves nothing.
- A chain of passes rolls back newest journal first, or the tool refuses; the
refusals measured that day are the tool working, not failing.
The music and audiobook arms moved to their own pages (progressive disclosure;
the parent drops 175 lines and stays the owner of the shared gates, the
executor, the journal law and the rollback). Stale per-run counts destroyed:
episode counts, byte sizes, move tallies for a pass that already happened.
A verify run beside a running scan reported rows the scanner had just
written as MISSING, and could fail outright as "database disk image is
malformed". Both libraries live in WAL mode, and the verifier opened them
with immutable=1, which tells sqlite the file never changes: it skips the
write-ahead log, so every committed-but-uncheckpointed row is invisible.
134 MB of jellyfin.db-wal was riding past the verifier's eyes during the
2026-10-31 rescan.
Open plain mode=ro first — sqlite then reads the WAL through its -shm —
and keep immutable=1 only as the fallback for a database whose -shm
cannot be read. The bats fixture leaves a journaled move uncheckpointed
and proves the verifier still counts it landed.
The block said the token rides stdin and never argv, then handed it to curl as
`-H "X-Plex-Token: $T"` — world-readable in /proc/<pid>/cmdline. It now writes
a mode-600 curl config inside the container, which is also how the overnight
rescan was actually issued (five sections, all accepted). Two truths the old
block got wrong are stated: a section answers 200 with a Directory body when
Accept: application/json is sent, and a backslash-newline inside a quoted
printf format is literal, so the config builder writes two printfs.
The provider's first hit for a salvaged query is a near miss, and the page said
only that a provider must name the file. The bullet now states the two checks and
the two real misfiles they exist to stop.
The first tvdb-grade plan handed 12 files to the movie library, and two of them
were wrong in a way the exceptions file could not show: an episode of the
documentary series `Russia 1985-1999: TraumaZone` was renamed to the 1963 Bond
film `From Russia with Love`, and `Gundam 00 Special Edition III - Return The
World` became `Special Edition I - Celestial Being`.
Two guards, each alone insufficient:
- `title_of` salvages a short query from a long release name ("russia", "mobile
suit gundam 00"), and tmdb() took the FIRST search result. The call site then
compared the provider's words against that salvaged query, so any title merely
CONTAINING the query passed. The provider's title must now be a run of words the
FILE itself states: `Special Edition I: Celestial Being` is not in a filename
that says `Special Edition III - Return The World`.
- tmdb() read the year from `year`/`first_air_date`, which the MOVIE search does
not return — it returns `release_date`. Every film answer therefore arrived
year-less and the year guard at the call site could never fire. That is how a
1963 answer survived a filename stating 1991.
Tests: a provider answer the file does not name parks, and nothing lands in the
movie library; an answer from another year is refused. The year-from-the-wire half
is proven by the live re-plan, which now dates every movie answer.
The server drops the leading underscore from the hidden folder's display name, so
the park-echo rule that compares spelled names lets the review pile survive as a
visible series. The law now says which spelling is compared; the mechanism stays
with the guard's docstring.
Two destination defects the first tvdb-grade plan exposed in its own output —
both visible by reading the plan's folder names, neither caught by any gate.
1. Jellyfin sanitizes a hidden folder's display name: `_unsorted` comes back as
`unsorted`. The park-echo guard compared the row's series against the folder
with the underscore, so 1,091 pile rows survived as a real series and the plan
renamed 617 files out of the pile into a VISIBLE folder named `unsorted` —
inventing the junk show the guard exists to prevent. The comparison now strips
leading underscores from both sides.
2. TVDb hands back some series with the year already on the name (`Acapulco
(2021)`). label_of appended the year anyway, producing `Acapulco (2021)
(2021)` — a folder no matcher and no human reaches for twice. A name that
already ends in `(YYYY)` is left alone.
Both are covered by fixtures: a pile row whose series is the sanitized spelling
must park, never file under the pile's name; a provider name carrying its year
produces one year in the path and the plan contains no `(YYYY) (YYYY)` anywhere.
The first tvdb-grade TV plan built 49,771 moves with not ONE TMDb answer: every
search and every external_ids call came back 401. The credential on this box is
the v3 key (~/.config/secrets/tmdb, `api_key=`), and TMDb takes a v3 key only as
the `api_key` query parameter — a v4 request token is the only thing the Bearer
header accepts. Measured on the same credential in one pass: the query form
resolves `New Girl (2011)`, the Bearer form raises HTTP 401.
tmdb_request() now picks the transport from the shape it was handed (three
dot-separated segments = a JWT → Bearer, anything else → query parameter), and
the old header comment's reasoning is corrected: a token in argv is
world-readable in /proc/<pid>/cmdline, but this URL is built inside the process
and never reaches argv, so the query form leaks nothing.
The failure mode that let it look like a finished plan is fixed with it. The
401s were stderr-only, so the summary printed a confident move count while every
series folder still carried the metadata provider's folder-echo name — the exact
junk this contract exists to undo. Provider health is now part of the summary
line and of the exceptions file, and a test covers it offline with
`env -u TMDB_API_KEY`: a plan that asked and could not ask says NOT
provider-complete in both places, and still writes the moves it can justify,
because the answers that did arrive are worth their rename.
Measured while commenting on PR 102: POST /pulls/102/comments answers 405 to a
plain body (it is the review-comment store); the conversation thread is
POST /issues/<PR-number>/comments, which answers 201 and returns an html_url on
the PR. Gitea maintains one issue index, so the PR number is the same number.
The trap that hid it: building the body with jq --rawfile b /dev/stdin inside a
command substitution feeds jq nothing (the substitution does not inherit the
heredoc), Gitea answers 422, and jq -r .id on the error prints nothing — a
silent no-op that looked like a posted comment. The body goes in a file.
Media Relayout.md hit the vault's 500-line gate the moment the tvdb contract
landed, and the fat in it was not the new text: the Plex and Jellyfin scan
blocks (35 lines) sat inside the relayout procedure even though they are the
way you talk to those servers after ANY change, and two Laws bullets repeated
the same facts a third time. Both blocks now live on [[Plex]] and
[[Jellyfin]]; the relayout page keeps the two laws an operator needs at that
moment — 204 is acceptance and anything else queued nothing, and one accepted
refresh is the whole procedure, because a second moves `Scan Media Library` to
`Cancelling` and every later 204 is discarded.
Same pass, the dead numbers the vault's own law says to destroy: an applied
music pass's date, an applied audiobook pass's move/park counts, and the 146
sweep stragglers of one specific first sweep. The laws they taught stay.
490 lines, `bats tests/docs_completeness.bats` 6/6 (length violation gone,
link integrity clean).
The TV relayout that landed copied Jellyfin's Series names verbatim, and the
naming census showed what that launders in: one Series item per release folder
(1,107 items for 1,107 top-level dirs), the same show spelled six ways, and
box sets living in 346 separate folders for 821 identities. Jellyfin names a
Series after the folder it scanned, so `24 (2001) Season 1` is a folder echo,
not a show name.
The name authority is now tvdb, reached through the id the server matched:
* media-census.py jellyfin emits two more columns — `title` (the episode row's
own Name) and `tvdb` (the series id, read from BaseItemProviders where
Jellyfin 12 keeps provider ids and from the row's Data blob where older
builds do, so the census does not care which shape the live DB has);
* media-shows.py resolves each series to tvdb — the row's tvdb id, else its
TMDb id through TMDb's external ids, else an exact tvdb name search — and
writes tvdb's name and year. That also collapses the provider's spellings
into ONE identity, which is what turns a box set split over five release
folders into one folder and what makes two files claiming one episode MEET
in the duplicate rule;
* the episode file becomes `<Series> (<year>) - SxxEyy - <Title>`: the title
comes from tvdb's episode list, else the row's own title, else nowhere — no
title means no separator, never an invented one.
Two refusals come with it. A row whose series is the hidden top-level folder the
file itself sits in is NOT evidence: the review pile parked inside the library
was indexed as one show named `_unsorted`, and its rows would have renamed real
shows to it. And inside such a folder a file's own words may MATCH a registered
show but never INVENT one — invention is what filled the pile. `--park`
therefore belongs outside the library, which is also what makes the next plan
re-file the pile instead of skipping it.
Both providers answer offline-reproducibly: every answer is cached in one JSON
file, so a plan can be re-planned without a key. Lookups stay behind --tmdb and
--tvdb, and the tvdb token is read from a mode-600 file, never argv.
tests/media_shows.bats is 23 tests, none of which touch the network — the
fixture Jellyfin DB carries the providers table and the name cache is an input
file. ./tests/run.sh: 753 green.
Tonight's sequence, measured: the post-sweep refresh answered 204 while
`Scan Media Library` sat in `Cancelling`, and Jellyfin's stale-path counts froze
for an hour at 0 % CPU — the request was accepted and discarded. Restarting the
user unit cleared the runner; one POST after that reported State `Running` at
130 % CPU and the counts began to move. So the status code is not the evidence,
and the restart is `systemctl --user restart jellyfin.service`, never
`deploy.sh`, which would run the never-tested metadata-view cutover.
Corrects the lesson written an hour ago, which said a non-204 was transient and
should simply be re-issued. It is reproducible and mechanical: `printf` with
`\"` inside the single-quoted format string drops the backslash, curl then ends
the quoted value at the first bare quote, and Jellyfin receives
`Authorization: MediaBrowser Token=` with an empty key. Both forms were run
against the host in one pass — `\\"` answers 204, `\"` answers 401 — so the
block's double backslashes are load-bearing, and the handoff script that deviated
left a 58k-move library unscanned for 40 minutes.
Also the trap that diagnosis walked into: a second accepted refresh while a scan
is running moves `Scan Media Library` to `Cancelling` and the pass starts over.
One accepted request is the procedure; wait it out.
The vault described the overlay as the live shape. It is not: `media-view.sh
status` answers 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) while git binds the view with ConditionPathIsMountPoint. The deploy gate
(deploy-remote.sh:ensure_media_view) has therefore never run: the first
`deploy.sh dankserver --service jellyfin|plex` is an untested cutover of both
media servers, and Jellyfin's read-only sidecar error is today's expected state,
not a fault.
Same commit, the scan-acceptance law from tonight's TV handoff: it logged
`jellyfin scan: 401`, so Jellyfin never rescanned a 58k-move library until the
request was re-issued with the identical key and header form (204). Anything
that is not 204 means no scan was queued — re-issue, do not diagnose.
Every apply and rollback printed "next: scripts/media-view.sh reap", but on
Dankserver there is nothing to reap: the metadata view has never been installed
(`media-view.sh status` -> NOT MOUNTED, `/var/lib/media-meta` absent) and the
live quadlets still bind the array read-only. All three relayout arms tonight
issued that order — 242,084 moves — and it pointed at a directory that does not
exist. Where no overlay is mounted the servers bind the array itself, so the
sidecars moved with the media.
`--metadata-view` (default /var/lib/media-meta/view) names the overlay and the
note now rides only when that path is a mountpoint. Red first: two new cases in
tests/media_relayout.bats (apply and rollback); the shared helper names a real
mountpoint so the pre-existing presence assertion keeps its meaning. 37 ok.
The music library is rips and discography packs whose filenames say nothing the
matcher needs. The authority is Jellyfin's own audio rows: one per track, with
the album and album-artist ids it carries in BaseItemProviders (the item JSON
holds an empty ProviderIds for albums in this build).
Grouping is what the tags assert, not where files sit: (album-artist tag, album,
year), case-folded. A compilation is one album whose tracks name many performers,
so it files once under Various Artists; two album-artist tags on one album name
are two albums. The folder spelling is the dominant one, because a name taken
from whichever row sorted first depends on row order and re-names albums that are
already filed.
An occupied path is never a destination: the executor refuses occupied targets,
so a plan that aims at one fails after the renames meant to clear it. A file
standing on its own target, or already carrying a collision suffix, is a keep -
otherwise the plan shuffles instead of being a fixed point.
One page owns relayout: the census commands, the order the planner believes
things in, the two refusals a dry run on the real library proved necessary, the
apply and rollback path, and the standing wart that a provider name can itself
be scene junk ("Black Clover 001", 92 episodes) — renaming that is Jellyfin's
job, not the mover's.
A dry run of the TV planner against the real 58k-file library produced a root
series called "01 NCOP" out of a bonus subfolder, copied Jellyfin's stale
numbering (every file under SNL 16-20/Season 16..19 carries season 16 episode
20; one Gundam row says season 2004), and lost subtitles a scene pack had
buried in Subs/<release-name>/.
- Folders now RESOLVE a series and never invent one: a folder's words match a
registered name as a token run ("(CBB) Tengen Toppa Gurren Lagann - The
Complete Series (BD 1080p)" names Tengen Toppa Gurren Lagann), and folder
words alone name nothing, so an unknown pack's bonus folder parks instead of
opening a new show at the library root.
- Numbering priority: the marker the file's own name states, then the Season NN
folder plus the number the name leads with inside it, then the provider row.
A row that still insists on season 1989 parks its file rather than creating
Season 1989.
- The provider row keeps identity: the registered name and its year survive a
file whose own marker numbers it, and the pack index a release leads with
("4a. Seed DESTINY") is folded out of the folder name.
- A sidecar searches every ancestor folder for the video it names, not just the
parent, so Subs/<release-name>/<release>.jpn.srt follows its episode.
- The shared bonus-folder test no longer reads an index as a title: name_words
drops "extras" as release junk, so "01. Extras" reduced to "01" and matched
the "01" of "NCOP 01", cancelling the bonus evidence. Only words can name a
film.
The first run against the real library died in the anchor lookup: a subtitle
whose own name carries the media extension matches BOTH prefix forms of its
video, and the "keep the longest" comparison read the destination slot of the
tuple instead of the length slot. The fixture only ever matched one prefix, so
the seam was green over a crash.
The tail the sidecar keeps is now stripped of that container word: Jellyfin and
Plex both read "Show S01E01.forced.srt", not "Show S01E01.mkv.forced.srt", and
an nfo still carries the media extension because that one is constructed, not
parsed.
The Movies relayout planned from Plex; the TV library has no Plex rows and
58k files whose names lie about which episode they hold. Jellyfin has already
matched and numbered 47,676 of them, so the planner treats that numbering as
the authority and the file name as the fallback: an episode number the name
states, or a provider row, or nothing - and "nothing" parks the file under
_unsorted with a reason instead of inventing a number.
- media-census.py jellyfin: one TSV row per matched episode, its container
path mapped back to the host, series name/year/TMDb id from the Series row
(the episode row carries none of them). Both TSV writers now end lines with
\n so a shell consumer sees the last field clean.
- media-shows.py: the plan. Series folders take the provider's own spelling;
one file claiming two episodes keeps both numbers; two files claiming one
episode - the bigger keeps the standard name, the other parks; bonus
material files under the series' extras folder, never as an episode; a long
file inside the TV library leaves for Movies only when TMDb answers with a
matching title, otherwise it parks. Re-running the plan on a library it
already filed moves nothing.
- tests/media_shows.bats: 15 behaviors, including the executor preflight and
the re-plan stability law.
The page carries the deploy-time shape: which four providers are core plugins
in Jellyfin 12, which one is not, the exact XML each key lives in, the host
file the keys come from and its 600 rule, the three-way pin on the TVDB
artifact with its rollback, and what the deployer does when the keys file is
absent. OMDb is recorded as deliberately unconfigured -- keyed by email,
1000 req/day, and a ToS that forbids storing what it returns. index.md's row
carries the same locator.
Jellyfin 12 ships TMDB, TVDB, StudioImages and MusicBrainz as core plugins:
no DLL, only a configuration file under the data root, so the keys have to
reach that XML from somewhere that is not git. They live in the fleet's
per-service env-file convention -- /home/dank/Containers/jellyfin/provider-keys
(TMDB_API_KEY=, TVDB_API_KEY=, optional TVDB_PIN=, mode 600) -- and
scripts/jellyfin-providers.sh plan|install|verify reads only that file, prints
no key byte, and refuses a keys file that is not mode 600 or that has no line
for the provider about to be written. install is idempotent: it never touches
a key the UI already saved, backs up every XML it edits into
~/.AGENTTRASH/<date>-jellyfin-providers/ first, and only installs the TVDB
plugin when the pinned official zip carries the plugin guid.
The deployer runs install BEFORE it restarts jellyfin, because the plugin and
its keys are read at start-up and a restart-first deploy boots the old state.
A missing keys file warns instead of failing: a host without the keys still
deploys, degraded rather than broken. No plugin configuration XML is tracked.
The container has had one owner since the Quadlet cutover
(dankserver/jellyfin/quadlet/jellyfin.container). The block was a second owner,
and wrong on every path: jellyfin/jellyfin:latest instead of the pinned ghcr
image, /opt/jellyfin/* data roots that no longer exist, and a read-only
/mnt/entropy:/data bind that the metadata view has since replaced.
The planner no longer counts jellyfin as a compose service, so editing the
inventory cannot restart a media server; tests/deployment_scoping.bats pins that
contract for both jellyfin and plex.
The merged door was /mnt/media, which read like a third volume and split the host
state across two trees. It is now /var/lib/media-meta/view, so the array layer,
the SSD upper layer, the kernel workdir, the reap trash and the merged tree are
one directory — and the mount table gains no new /mnt entry beside the array.
systemd names a mount unit after its ESCAPED path, so the repo copy keeps the
readable name media-meta-view.mount while `install` writes the host file as
var-lib-media\x2dmeta-view.mount; `plan` prints both and the page records them, so
nobody hunts for a unit that "does not exist". install also creates the mount
point itself, because systemd will not.
The seam now additionally pins that the unit's Where= is exactly the path both
containers bind — guard and bind cannot drift apart. The Quadlet Lifecycle
phantom-path example is corrected too: it named an invented /mnt/media/*, while
the real phantom is the legacy /opt/jellyfin/* that dankserver/compose.yaml still
carries for a service whose roots moved to /home/dank/Containers/jellyfin.
New owner page Dankserver/Media Metadata View.md: the shape, the two binds and
why Plex keeps the /mnt/entropy container path, the commands, the laws (SSD-only
upper layer, never mount over an unmounted array, never delete through the view
because a whiteout only hides the array's copy, reap after any relayout, inotify
does not cross the merge), the failure signatures, and the rollback.
Jellyfin.md: /data is the view, so Local metadata works; the
"Read-only file system : '/data/…/movie.nfo'" line that used to be expected noise
is now the signature of a missing view. Plex.md: same bind, one fact changed.
Media Relayout.md: two new laws for the metadata that does not follow a rename.
AGENTS.md: runbook-index row plus a summary row. Root index regenerated.
A relayout renames on the raw array; the sidecars the media servers wrote live in
the NVMe upper layer of the view and stay at the old coordinates, which is how a
ghost folder (artwork and an .nfo, no video) is born. apply and rollback now
print the follow-up — scripts/media-view.sh reap — at the moment the operator
needs it, and the existing apply seam asserts the hint is in the output.
Both media servers read a Kodi-format sidecar (movie.nfo / tvshow.nfo /
episode.nfo) only when it sits next to the media file, and the library lives on
the RAID6 array, deliberately mounted read-only for every container and slow to
seek for a thousand tiny XML reads. So neither server binds the array any more:
both bind /mnt/media, an OverlayFS merge of the array (lowerdir, never written)
with /var/lib/media-meta on the NVMe (upperdir, where every write lands).
Jellyfin binds the view rw at /data and is the fleet's only metadata writer;
Plex binds it ro at /mnt/entropy, the container path its own database already
recorded, so only the host side of that bind moved and its library paths keep
resolving.
scripts/media-view.sh plans, installs, verifies, reports and reaps the view. It
refuses to mount over an unmounted array, refuses an upper layer on rotational
storage, and refuses a non-root install. deploy-remote.sh raises the view before
it restarts either media server, both units carry
ConditionPathIsMountPoint=/mnt/media so a missing view skips the start instead of
letting a scan clear the library database, and the planner routes the installer
and dankserver/host/** to the jellyfin+plex pair.
tests/media_overlay.bats (11 seams) pins the resolved shape, both refusals, the
two binds and their guard, the deployer's ordering, the planner mapping, and the
reap behaviour against a fixture (ghosts listed, then moved to the SSD trash --
files that exist only above are sidecars, not ghosts, so only directories are
candidates).
Six single-episode files stayed in the movie library when the threshold refused
them; the hand path is the same executor on a hand-written plan and the same
journal. The trap recorded here is the series year -- never inherit it from the
title whose bonus folder an episode happens to be parked in.
The seam ran through `grep -c`, which returned success on a non-zero count and
let a red suite through: description over the 120-char cap, and a timestamp
that check-okf reads against the local date, so a UTC stamp a day ahead of
Seattle is 'in the future'. index.md regenerated for the new description.
docs_completeness 6/6, exit status checked this time.
X-Emby-Token is refused for every key on 12.4 -- which is why this page
carried the false claim that no credential on the box could trigger a scan and
that the two Seerr keys were dead. The documented snippet now authenticates
with Authorization: MediaBrowser Token=..., reads 204 from /Library/Refresh,
and points at ~/.config/secrets/jellyfin (mirrored as JELLYFIN_API_KEY in the
ignored dankserver/.env). Law: prove a 401 against the ApiKeys table before
blaming the key.
Trashing 31 GiB of duplicate Monk to ~/.AGENTTRASH would have been a
cross-device copy of the thing being deleted, into 187 GiB of free space, on a
volume at 100% -- and rename(2) refuses it regardless. The stage belongs beside
the item.
The destination-hold refusal's evidence, stated exactly: 124 episodes both
sides, same episode list, every byte count identical, 31.5 GiB each way,
head+tail blake2b identical on a 6-episode sample.
Plus the law this came out of: a path-substring filter for a series name also
returns other shows (monk matches Monkey Dust), and whichever file a walk
reaches first standing in for an episode turns two libraries into a fake
duplicate — the same sloppiness aimed the other way deletes a unique file.
ep_of on both sides, and a hash before anything is trashed.
Two fixes found by one live sweep, shipped together because the second is
what makes the first finishable.
1. scripts/media-tv.py — the other half of media-plan.py's refusal to file
TV. Groups episode-shaped files by the series name before the marker
(media-titles.ep_of, imported not restated), writes a plan in the
executor's own format: <library>/<Series> (<year>)/Season <NN>/<own
name>.<ext>. Refuses a group below --min, an episode naming no season,
and an episode the destination library already holds at the same byte
count — the movie share carries a second copy of all 124 Monk episodes
the TV library has had for years; a duplicate is Frank's call, never a
tool's copy. Applied by media-apply.py with its own journal and
--root /mnt/entropy.
2. scripts/media-apply.py — gone meant "rmdir intended", so a folder whose
rmdir failed was blacklisted forever and no later sweep could finish it
(live movie sweep: 4517 targeted, 146 permanently stranded). gone now
means "actually gone": a failed intent is retried by the next sweep.
tests: 9 new tests in media_relayout.bats, each red against the previous
revision — 8 TV + 1 sweep retry behind a chmod 500 parent. ./tests/run.sh
633 green. No live move run yet.
The live sweep removed 2271 husks and left 146 standing, every one a release
folder blocked by a junk subfolder (Screens/, Sample/, a BDMV leaf) that no
file ever moved out of. Eligibility was per-folder, so the blocker was never a
target and its parent could never be removed.
Proof now carries downward: inside a folder the journal shows emptied, every
empty directory qualifies. Nothing under such a folder can hold data, and every
removal is still journalled as rmdir and undone by rollback.
The new test is red against the previous revision, green now. The live sweep
touched no file: 8193 before, 8193 after.
Keyed on the whole episode title, every episode of a box set became a group of
one and never reached the five-episode size that calls something a series, so
the live run filed 124 Monk episodes as 124 movie folders, each named after its
own episode. ep_of now returns the series — the name before SxxEyy / 1x04 /
Part N — and the planner groups and names the quarantine pile by that series,
so one show is one pile: 'Monk - S05E03 - Mr. Monk and the Big Game' is series
monk. The threshold keeps its job: a lone film named Part 2 stays a film.
Re-planning the live census with this moves nothing differently except the 124
Monk files, which now join the TV pile: 168 episode files across 3 series.
An apply leaves every release folder it emptied standing where it was: the
live library run left 1675 husks at the root. sweep takes the same journal
and deletes only folders it can prove this run emptied - a journalled move
out of the folder or something under it, nothing but empty directories left,
no journalled destination beneath it, not a folder the run created. A folder
nobody moved cannot be selected at all, so a hand-made placeholder survives,
and a symlink anywhere inside disqualifies the subtree. Every rmdir is
journalled, so rollback recreates what a sweep removed.
A by-digest pull leaves the image carrying every digest it was pulled under, so
reading RepoDigests[0] reported a healthy pinned image as wrong and refused a
window that was ready to run. Membership, not position. Also records the measured
step optimum: 106 tok/s at an 8,192-token step against 76 at 16,384 on a 32,768
calibration window, so the wide step has a ceiling, not a plateau.
Each gate the window used was a proxy, and each lied at least once:
- /health answered 200 from the dying production process (window dead at second zero)
- podman inspect named a rig container that had bound nothing
- the space check asked a directory that merely looked related
- the identity check compared podman's CONTAINER-internal pids (1, 7) to an ss host pid
scripts/qwen38-preflight.sh now checks cwd, the digest a tag actually resolves to,
free space on the tap's own filesystem, stale grams, every /dev/kfd holder's host pid
(podman top <ctr> hpid) and the :7770 listener's host pid against the service's, the
corpus and the reference recording; it prints PASS/FAIL per fact and exits 2. The
window calls it before a card is touched, and its duplicated checks are gone.
The runner stops frank-archon first, and the dying production process keeps its
listener for a moment while the freshly created rig container is still loading.
'podman inspect says running' plus '/health answers' therefore BOTH hold for the
wrong process: measured 2026-10-30, the window logged 'health ok waited=0s',
the pusher refused on the container name, and a 12M window died at second zero.
The gate now asks the kernel who owns :7770 (ss -ltnp) and requires that pid to
be one of the rig container's processes (podman top). Two tests, written red
first against the old rig_up: a foreign listener must fail the window without a
push, and an in-container listener must proceed and bank.
Also renames the new test fixture to tap_window_fixture: it had shadowed the
existing window_fixture helper, which broke the window dry-run test when the
whole file ran.
The law today's authentik bump paid for: start on an active unit is a no-op, so
the new digest installs and the old container keeps serving behind three green
gates. Restart applies a pin.
The handler called `systemctl --user start` on all three units. Start is a
no-op for an already-active unit, so installing a new digest under an active
authentik left the old container serving: today's 2026.8.3 -> 2026.8.5 bump
moved only the OUTPOST (whose handler restarts) and left server + worker on
2026.8.3 while the deploy, the health gate and the e2e suite were all green.
The generator re-renders from the .container, so a restart is the step that
applies a pin.
Frank's rule: the served iq4r this arm competes with was calibrated on a
10M-token set, so the arm is calibrated at least as deeply. The floor was
lowered to 1.5M on the vendor's '10M beats 1M by under 1%' note; that
measures the vendor's own plate, not ours — the arm is judged against a
10M-calibrated reference, so cutting the calibration 6x to save wall clock
buys a worse position on the only comparison that matters.
One round of the re-chunked corpus is ~9.31M tokens, so a 12M floor seals
in round 1 plus part of round 2. A round that seals nothing is normal (the
floor is pooled) and round_verdict treats it as continue, never failure.
Also fixes tests/qwen38_campaign.bats' window dry run: its fixture lands on
/tmp (a 16 GiB tmpfs) where the 60 GiB seal preflight can never pass, so the
test now uses the documented Q38_CALIB_MIN_FREE_MIB override instead of
asserting environment luck.
frank-dragoon: engine 0.18.20 (image e35133b2…), the 18-model NPU catalog, the
re-cut qwen3-embedding-0.6b device programs, and the wire surface the new engine
actually answers (GET /tokenize is 405, /props is 200, /completion and /ping
stay 404). Eras gains the 2026-10-30 row.
frank-zealot: fork build 12118 (0.7.0-dev, 024474448) with the payload-load
evidence (202 pq2_0 tensors, 202 Hadamard-folded weights), plus the law a
rebuild pays for: cmake --install strips the rpath and installs bin/+lib/, so
the configure needs CMAKE_INSTALL_RPATH and LLAMA_BIN names …/bin/llama-server.
Rebuilt the fork from origin/prism at 024474448 with the same configure the
10777 cutover used (Release, GGML_CUDA=ON, sm_86, gcc-13 host compiler, Ninja),
installed to /home/dank/GitPackages/llama-prism-0244744. The payload gate holds:
libllama carries 42 prism.hadamard strings (b10777 had 29) and libggml-base
still registers PQ2_0, so type 142 and the hadamard metadata still load.
LLAMA_BIN inherits a bin/lib install layout now, so the install needs an RPATH
($ORIGIN;$ORIGIN/../lib) — cmake's install step strips the build rpath and the
binary otherwise dies on libllama-server-impl.so.
The unit test that pinned the 10777 path now asserts the invariant its own
comment described (LLAMA_BIN names a llama-prism-* build) instead of a
directory one rebuild happens to install; the suite runs on a box with no
/home/dank, so existence is not checkable there.
Rollback: repoint LLAMA_BIN at llama-prism-b10777/llama-server (still on disk).
A version bump re-cuts the device programs, so a staged file of a different
size is stale rather than partial. `curl -C -` against it appended the new
tail to the old bytes and produced the right BYTE COUNT with the wrong
content — the sha256 gate caught it (consts.hnpb, 2026-10-30), and the
failure reads as a corrupt program rather than a fetch fault. Resume only
when the staged file is smaller than the manifest expects.
The engine pin moves to the newest vendor release (0.18.20, image built
2026-10-30). The payload lock is the gate this deploy has to pass: the v2
.hgn loads only in the flash-server line, so /health plus the boot ledger
decide, and the rollback is the 0c61bf84 digest on the same env line.
0.18.20's NPU catalog is NOT the 0.16.2 one: it names 14 device programs
(u0..u13) and an 8,495,512 B consts.hnpb where 0.16.2 had 10 and 5,086,496 B,
and the entrypoint verifies size+sha256 for every staged file before it
starts. HALOGEN_NPU_MODELS names qwen3-embedding-0.6b, so the staging set is
re-pinned to revision 8fd44c9f with the new block — bumping the engine alone
would refuse the boot on the NPU lane.
The tap did not arm on the production pin: a 17-hour calib run on 1.8.1
wrote zero bytes while /health, /stats and the push all read healthy.
On :latest (v1.14.0, revision 4a61d7296bc5) the same boot prints
'recorded passes: off, the program accumulates calibration Grams
(gram_accum)' and a 9,129-token push sealed 24,768 gram files (42.9 GB)
in the fitting shape - 4 slots at a 2,048-token step, production's own.
The vendor's calibration shape (--max-num-batched-tokens 65536,
--max-num-seqs 64, ~930 tok/s) cannot boot two 32 GiB R9700s: the
activation arena alone is 12.83 GiB of the 27.95 GiB claimable, and
every variant stops in the VRAM resolver (short 3.19 GiB .. 86 MiB,
then a starved cache).
Floor moves 12M -> 1.5M tokens: the vendor's own measurement is 'a
pooled Gram from 10M tokens beat one from 1M by under 1%', and 1.5M is
hours at this rig's rate instead of the 29 h a 6M floor cost.
The converter moves to the same digest: rad-convert's gptq:calib=<dir>
input is version-stable, so a latest-written tap converts on latest.