feat(media): tvdb names the show — titled episode files, id-backed series folders, park outside #102

Merged
dank merged 10 commits from feat/tv-tvdb-naming into main 2026-10-31 12:12:15 -07:00
Owner

What this changes

The TV naming contract goes tvdb-grade. Every file in /mnt/entropy/TVShows is
renamed to

<Series> (<year>)/Season NN/<Series> (<year>) - SxxEyy - <Episode Title>.ext

and — the part that matters — the series name and the episode title come from
tvdb
, reached through the id Jellyfin matched, not from the words a release
folder happens to carry.

The census that prompted it found 1,107 Jellyfin Series items for 1,107
top-level folders: the server names a Series after the folder it scanned, so a
library filed by release folders ends up with 24 (2001) Season 1 as a show
name, one show spelled six ways, and 821 real identities scattered over 346
extra folders.

The chain

Piece Change
scripts/media-census.py jellyfin two more columns: title (the episode row's own Name) and tvdb (the series id). Provider ids are read from BaseItemProviders (Jellyfin 12) and from the row's Data blob (older builds), so the census does not care which shape the live DB has
scripts/media-shows.py resolves each series to tvdb — row's tvdb id → TMDb external_ids → exact tvdb name search — then writes tvdb's name and year, merging the provider's spellings into one identity
file name <label> - SxxEyy[-Ezz] - <Title> (+ Part N); title from tvdb's episode list, else the row's title, else nothing (no dangling -)
name cache one JSON file holds both providers' answers (tvdb:series:, tvdb:episodes:, tmdb:external:, tvdb:search: keys), so a plan is reproducible with no key at all
credentials tvdb's api_key= is read from a mode-600 file (--tvdb-credentials); the JWT and both keys stay out of argv, the cache and the plan

Merging on the identity is what makes a box set split across five release
folders one folder, and what makes two files claiming one episode meet
in the duplicate rule (before: two polluted spellings never met, so both kept
the standard name in different folders).

Two refusals

  • A row whose series is the park folder is not evidence. The review pile
    parked inside the library got indexed as one show named _unsorted; its
    rows would have renamed real shows to that name. row_names_the_park drops
    any row whose series is the hidden top-level folder the file itself sits in.
  • Inside a hidden top-level folder a file's own words may match a registered
    show, 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 (the parked files now have provider
    rows, so the ones with evidence get filed and the rest stay parked).

Verification

  • bats tests/media_shows.bats → 23/23, none of which touch the network: the
    fixture Jellyfin DB carries the BaseItemProviders table and the name cache
    is an input file.
  • ./tests/run.sh → 753 green.
  • The census change was run against the live Jellyfin DB: series rows carry real
    TMDb/Tvdb ids and episode titles (9,566 provider rows; the polluted-name
    buckets are counted in the census output, not restated here).

Deliberately not in this PR

No plan is applied. The next step is a fresh census + --tvdb --tmdb plan build
whose exceptions file Frank reads before anything moves; applying stays a
separate, explicitly-approved action.


cwd: /home/blank/.omp/wt/wt-20261030-163847-6d1fc1f · session: 01a18f2e-18b1-7573-bcd5-ac99636f8bcb

## What this changes The TV naming contract goes tvdb-grade. Every file in `/mnt/entropy/TVShows` is renamed to ``` <Series> (<year>)/Season NN/<Series> (<year>) - SxxEyy - <Episode Title>.ext ``` and — the part that matters — the **series name and the episode title come from tvdb**, reached through the id Jellyfin matched, not from the words a release folder happens to carry. The census that prompted it found 1,107 Jellyfin `Series` items for 1,107 top-level folders: the server names a Series after the folder it scanned, so a library filed by release folders ends up with `24 (2001) Season 1` as a show name, one show spelled six ways, and 821 real identities scattered over 346 extra folders. ## The chain | Piece | Change | |---|---| | `scripts/media-census.py jellyfin` | two more columns: `title` (the episode row's own `Name`) and `tvdb` (the series id). Provider ids are read from `BaseItemProviders` (Jellyfin 12) **and** from the row's `Data` blob (older builds), so the census does not care which shape the live DB has | | `scripts/media-shows.py` | resolves each series to tvdb — row's tvdb id → TMDb `external_ids` → exact tvdb name search — then writes tvdb's name and year, merging the provider's spellings into one identity | | file name | `<label> - SxxEyy[-Ezz] - <Title>` (+ `Part N`); title from tvdb's episode list, else the row's title, else nothing (no dangling ` - `) | | name cache | one JSON file holds both providers' answers (`tvdb:series:`, `tvdb:episodes:`, `tmdb:external:`, `tvdb:search:` keys), so a plan is reproducible with no key at all | | credentials | tvdb's `api_key=` is read from a mode-600 file (`--tvdb-credentials`); the JWT and both keys stay out of argv, the cache and the plan | Merging on the identity is what makes a box set split across five release folders **one** folder, and what makes two files claiming one episode **meet** in the duplicate rule (before: two polluted spellings never met, so both kept the standard name in different folders). ## Two refusals * **A row whose series is the park folder is not evidence.** The review pile parked *inside* the library got indexed as one show named `_unsorted`; its rows would have renamed real shows to that name. `row_names_the_park` drops any row whose series is the hidden top-level folder the file itself sits in. * **Inside a hidden top-level folder a file's own words may match a registered show, 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 (the parked files now have provider rows, so the ones with evidence get filed and the rest stay parked). ## Verification * `bats tests/media_shows.bats` → 23/23, none of which touch the network: the fixture Jellyfin DB carries the `BaseItemProviders` table and the name cache is an input file. * `./tests/run.sh` → 753 green. * The census change was run against the live Jellyfin DB: series rows carry real TMDb/Tvdb ids and episode titles (9,566 provider rows; the polluted-name buckets are counted in the census output, not restated here). ## Deliberately not in this PR No plan is applied. The next step is a fresh census + `--tvdb --tmdb` plan build whose exceptions file Frank reads **before** anything moves; applying stays a separate, explicitly-approved action. --- cwd: /home/blank/.omp/wt/wt-20261030-163847-6d1fc1f · session: 01a18f2e-18b1-7573-bcd5-ac99636f8bcb
dank added 1 commit 2026-10-31 07:02:16 -07:00
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.
dank added 1 commit 2026-10-31 07:10:29 -07:00
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).
Author
Owner

Second commit (e531e90) is docs-only and it is in this PR because the TV section is what pushed Media Relayout.md past the vault's 500-line gate. The fat was older: the Plex and Jellyfin scan blocks lived inside the relayout procedure although they are how anyone talks to those servers after any change, so they moved to Plex and Jellyfin, which own them; the relayout page keeps the two laws needed at that moment (204 is acceptance, one accepted refresh is the whole procedure). Applied-pass bookkeeping (a date, two move/park counts, the 146 sweep stragglers) is destroyed per the measurements law; the laws they taught stay.

bats tests/docs_completeness.bats 6/6 — length violation gone, link integrity clean.

cwd: /home/blank/.omp/wt/wt-20261030-163847-6d1fc1f · session: 01a18f2e-18b1-7573-bcd5-ac99636f8bcb

Second commit (`e531e90`) is docs-only and it is in this PR because the TV section is what pushed `Media Relayout.md` past the vault's 500-line gate. The fat was older: the Plex and Jellyfin **scan** blocks lived inside the relayout procedure although they are how anyone talks to those servers after any change, so they moved to [[Plex]] and [[Jellyfin]], which own them; the relayout page keeps the two laws needed at that moment (204 is acceptance, one accepted refresh is the whole procedure). Applied-pass bookkeeping (a date, two move/park counts, the 146 sweep stragglers) is destroyed per the measurements law; the laws they taught stay. `bats tests/docs_completeness.bats` 6/6 — length violation gone, link integrity clean. --- cwd: /home/blank/.omp/wt/wt-20261030-163847-6d1fc1f · session: 01a18f2e-18b1-7573-bcd5-ac99636f8bcb
dank added 1 commit 2026-10-31 07:11:14 -07:00
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.
dank added 1 commit 2026-10-31 07:16:46 -07:00
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.
dank added 1 commit 2026-10-31 07:27:48 -07:00
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.
dank added 1 commit 2026-10-31 07:28:22 -07:00
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.
dank added 1 commit 2026-10-31 07:38:54 -07:00
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.
dank added 1 commit 2026-10-31 07:39:45 -07:00
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.
dank added 1 commit 2026-10-31 09:50:23 -07:00
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.
dank added 1 commit 2026-10-31 10:22:34 -07:00
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.
dank merged commit 44e55ad172 into main 2026-10-31 12:12:15 -07:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: dank/serverAppConfigs#102