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.
A scan answers 204 whether or not it found anything, so the fleet's documented
proof — every move row has a row at its dst and zero rows still name its src —
was a hand-written sqlite query every time anyone needed it. That is the step a
half-finished relayout is judged on, so it is now a command with an exit code.
It encodes the two doors that bite: Jellyfin stores the container-side path
(/data stands for the share) while Plex stores the host path verbatim and hides
deleted scans behind deleted_at on two tables. The report breaks the missing rows
down by extension, because a .cue or .log has no row to land in and must not read
as a lost album; --ignore-ext keeps those out of the verdict.
Two relayouts writing one journal cannot be rolled back apart: the mover
(Movies -> TVShows) and the library re-filer both pointed at
media-relayout-tv.jsonl. The re-filer now writes media-relayout-shows.jsonl,
and its plan/exceptions move off /tmp, which a reboot eats and which is not a
record.
The page said the music plan "was applied" while 62k of its 131k journal rows
were still being written; a runbook line that gets believed is worse than none.
Also lands the single-writer law the audiobook handoff exists to honour, and the
durable plan path (/tmp is not a record).
Plex's artist row for 272 chapters is a chapter number the ripper put in the
artist tag (0.4, 31, 05), which would have filed them under a shelf called 0.4.
A narrator is a person, so it must contain a letter; the same test is deliberately
not applied to a book title, which may honestly be 1984.
The first real plan filed 1,106 chapters from 14 unrelated top-level folders
onto one shelf, because all 1,106 carry the album tag [Unknown Album] and the
same artist tag. Plex's album title is the authority for the shelf only when it
names a book; a placeholder names nothing, and unrelated books share it. Those
chapters now park with the path they have today, timestamp-glued variants
included; Various Artists stays a legitimate multi-cast narrator.
The linter caps a page at 500 lines and a description at 120 chars: the section
lost four lines of prose (no law went with them) and the description lost its
restatement of the format names. gen-vault-index.py regenerated the locator row.
One page owns the relayout facts: the new section carries the Plex-only reader,
the shape, the four commands, and the two laws a future operator would otherwise
re-litigate (never renumber a chapter; the shelf is the narrator). The music
section said its plan was NOT applied — it was applied on 2026-10-31, and a stale
line in a runbook gets believed, so it now names the journal and the rollback.
The audiobook tree is the one library Jellyfin does not read at all and Plex
reads completely: 58,975 chapters matched into Artist/Album/Track, where the
album is the BOOK and the book's single artist row is the NARRATOR. That is the
authority the new planner files by, never the collection folder a pack made.
Audiobooks/<Narrator>/<Book> (<year>)/<the file's own name>
Two laws are the opposite of the music tool's, both forced by the rows: files
are never renumbered (72% of the books carry chapters with no usable index, and
playback order comes from the embedded track tag either way, so an invented
number can only lie), and the shelf is the narrator, not the author (every book
has exactly one artist row; the author survives in 10% of album titles and in no
column Plex stores, so one rule beats a shelf half authors and half narrators).
media-census.py gains the audiobooks export: the chapters Plex matched under a
root, host paths straight through, nothing outside the root and nothing Plex
deleted. 22 tests in tests/media_audiobooks.bats pin the export's join and the
plan's output.
A scan answers 204 whether or not it found anything, so proof is the journal
replayed against Jellyfin's BaseItems.Path and Plex's media_parts.file: every
move has a row at its dst, no row names its src. Records the four wrong doors:
no sqlite3 binary on the hosts or in either container, Jellyfin's single
BaseItems table (no MediaItems), its container-side /data path prefix, and
Plex's HTTPD closing on ?path= / on /search matching titles not paths.
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.