TMDb has no TV side: a series, a season and an episode get nothing from it #106

Closed
opened 2026-08-13 22:59:41 +02:00 by Claude · 1 comment
Collaborator

crates/jellyfin-app/src/metadata/tmdb.rs has one entry point — movie() — plus images(). Upstream has six more in-box providers against the same API: TmdbSeriesProvider, TmdbSeasonProvider, TmdbEpisodeProvider and the three image providers beside them, all under MediaBrowser.Providers/Plugins/Tmdb/TV/.

So a TV library here fills from filename and NFO only: no overview on a series, no episode titles or air dates beyond what the file names, no season posters, no series backdrop, no cast.

The parts already built that this rides on: the MetadataHttp port and its cassette (D27), the merge rule with its lock set, the download-and-file path for the pictures a film won, and the person rows a cast writes. What is new is the request shapes, the season/episode identification (an episode is looked up by series id plus season and episode number, not by an id of its own), and the fixture — the enriched library will need a series named by [tmdbid-…] the way the films are.

TmdbMissingEpisodeProvider and its TmdbRefreshUpcomingEpisodes task hang off the same client and belong here or immediately after: they are what fills /Shows/Upcoming, which ENDPOINTS.md currently answers with what the fixture's missing premiere dates allow.


Where to start

crates/jellyfin-app/src/metadata/tmdb.rs has movie() and images() and the
client machinery around them — the append-to-response list, the crew filter, the
cast ceiling, the id-then-external-id lookup order. Six more providers hang off
the same client:
MediaBrowser.Providers/Plugins/Tmdb/TV/{TmdbSeriesProvider,TmdbSeasonProvider,TmdbEpisodeProvider}.cs
and the three image providers beside them.

What is genuinely new, and worth reading upstream for rather than inferring:

  • An episode is not looked up by an id of its own. It is series id plus
    season and episode number, which means the series has to have resolved first
    and the ordering matters.
  • A season's images and a series' images come from different endpoints with
    different language fallbacks.
  • TmdbMissingEpisodeProvider and its TmdbRefreshUpcomingEpisodes task are
    the same client again and are what fills /Shows/Upcoming, which today
    answers what the fixture's absent premiere dates allow.

The fixture has to grow. M10's films are named [tmdbid-…] so that a lookup
is by id and the recording is stable; a series needs the same treatment before
any of this can be measured. reference/README.md says how the enriched
snapshot is made and re-provisioned, and the cassette is regenerated beside the
answer key rather than hand-written.

How to know it is done

A series, a season and an episode scanned here carry the same fields as the rows
upstream fetched into the enriched snapshot — every column, the Data blob
included — and the artwork matches on bytes. That is the same bar M10's films
met, applied to the TV side.

House rules

CLAUDE.md is the short version and these are the parts that bite: tests run
under cargo nextest; upstream's source is the checkout in upstream/ at the
pin
(v12.0-rc5, 6d501ba4) and not the clone beside the repository;
generated files are regenerated rather than edited; a comment says what the code
does, not how it got there; and Dn references stay out of code and comments.
ARCHITECTURE.md decides which crate a thing goes in and which dependency edges
are illegal, DECISIONS.md says why a rule exists.

The reference pair is cargo xtask reference up (and reference provision,
which is idempotent); a run is
cargo run -p jellyfin-harness -- replay --upstream URL --ours URL --scenario FILE --out DIR,
with diff-db for the two databases. crates/jellyfin-harness/README.md says
what a diff means and what an ignore entry has to carry — every entry needs a
reason and an expiry milestone, and an adds-only history is the smell the risk
table names.

`crates/jellyfin-app/src/metadata/tmdb.rs` has one entry point — `movie()` — plus `images()`. Upstream has six more in-box providers against the same API: `TmdbSeriesProvider`, `TmdbSeasonProvider`, `TmdbEpisodeProvider` and the three image providers beside them, all under `MediaBrowser.Providers/Plugins/Tmdb/TV/`. So a TV library here fills from filename and NFO only: no overview on a series, no episode titles or air dates beyond what the file names, no season posters, no series backdrop, no cast. The parts already built that this rides on: the `MetadataHttp` port and its cassette (D27), the merge rule with its lock set, the download-and-file path for the pictures a film won, and the person rows a cast writes. What is new is the request shapes, the season/episode identification (an episode is looked up by series id plus season and episode number, not by an id of its own), and the fixture — the enriched library will need a series named by `[tmdbid-…]` the way the films are. `TmdbMissingEpisodeProvider` and its `TmdbRefreshUpcomingEpisodes` task hang off the same client and belong here or immediately after: they are what fills `/Shows/Upcoming`, which `ENDPOINTS.md` currently answers with what the fixture's missing premiere dates allow. --- ## Where to start `crates/jellyfin-app/src/metadata/tmdb.rs` has `movie()` and `images()` and the client machinery around them — the append-to-response list, the crew filter, the cast ceiling, the id-then-external-id lookup order. Six more providers hang off the same client: `MediaBrowser.Providers/Plugins/Tmdb/TV/{TmdbSeriesProvider,TmdbSeasonProvider,TmdbEpisodeProvider}.cs` and the three image providers beside them. What is genuinely new, and worth reading upstream for rather than inferring: - **An episode is not looked up by an id of its own.** It is series id plus season and episode number, which means the series has to have resolved first and the ordering matters. - A season's images and a series' images come from different endpoints with different language fallbacks. - `TmdbMissingEpisodeProvider` and its `TmdbRefreshUpcomingEpisodes` task are the same client again and are what fills `/Shows/Upcoming`, which today answers what the fixture's absent premiere dates allow. **The fixture has to grow.** M10's films are named `[tmdbid-…]` so that a lookup is by id and the recording is stable; a series needs the same treatment before any of this can be measured. `reference/README.md` says how the enriched snapshot is made and re-provisioned, and the cassette is regenerated beside the answer key rather than hand-written. ## How to know it is done A series, a season and an episode scanned here carry the same fields as the rows upstream fetched into the enriched snapshot — every column, the `Data` blob included — and the artwork matches on bytes. That is the same bar M10's films met, applied to the TV side. ### House rules `CLAUDE.md` is the short version and these are the parts that bite: tests run under `cargo nextest`; upstream's source is the checkout in `upstream/` **at the pin** (`v12.0-rc5`, `6d501ba4`) and not the clone beside the repository; generated files are regenerated rather than edited; a comment says what the code does, not how it got there; and `Dn` references stay out of code and comments. `ARCHITECTURE.md` decides which crate a thing goes in and which dependency edges are illegal, `DECISIONS.md` says why a rule exists. The reference pair is `cargo xtask reference up` (and `reference provision`, which is idempotent); a run is `cargo run -p jellyfin-harness -- replay --upstream URL --ours URL --scenario FILE --out DIR`, with `diff-db` for the two databases. `crates/jellyfin-harness/README.md` says what a diff means and what an ignore entry has to carry — every entry needs a reason and an expiry milestone, and an adds-only history is the smell the risk table names.
Author
Collaborator

Done, on main. The six providers, the scan wiring, the fixture, and the answer key that measures them.

What it answers now

A series, a season and an episode take from TMDb what upstream takes: name, original title, overview, official rating, original language, production year, premiere date, end date, run time, genres, studios, tags, community rating, Status in the blob, and all four provider ids at series level — measured against rows the pinned upstream image wrote for Firefly (2002) [tmdbid-1437] and Twin Peaks (1990) [imdbid-tt0098936], the second resolved through /find so both halves of how a lookup starts are covered.

The fixture grew to carry them: three clips under enriched/shows, a second tvshows library with fetchers on, and a regenerated enriched snapshot. The answer key is 11 items, 99 persons, 104 images, 156 credits, 118 values, 125 recorded answers.

JELLYFIN_RS_REQUIRE_FIXTURES=1 cargo nextest run --workspace: 2219 passed, 0 skipped.

The cassette stopped being a liability

tmdb.json was 13 MB of base64 rewritten wholesale on every regeneration, and adding TV would have made it ~40 MB. Bodies now live in content-addressed files beside it and the JSON is 1.1 MiB. Cassette owns the layout so the generator and the replay cannot drift — they are in different crates, which is exactly how they would have.

The conversion was proven rather than asserted: 46 keys before and after, zero semantic differences among the 42 unchanged, exactly four re-spelled for the append_to_response fix with byte-identical answers, and all 40 base64 bodies decoding to the file that now holds them. Three guards keep it that way — the recording is byte-for-byte what the writer emits, every named body is on disk and every body on disk is named, and each is filed under the digest of what it holds.

Recorded::answer now fails on a missing key and on a named-but-absent file, where it used to answer an empty body. deny_unknown_fields closes the same hole one format-version away: an old-format recording is refused rather than answering 200 with nothing.

Two things this got wrong before it got right, and both are method

A self-made answer key is not a check. The first measurement reported 13 of 13 pictures byte-identical — against a cassette the port had recorded itself, so it compared the port's choices to a recording of the port's own choices. The committed corpus was the first independent check, and it disagreed on seven pictures. That is M10's "a check on a projection is not a check" one level up: not a projection, a self-made key.

And then byte equality turned out to be unsound anyway. Fetching each disputed URL showed the port's chosen address serving exactly upstream's bytes, seven times out of seven — so the selection was right and the two halves of the key disagreed. image.tmdb.org serves two JPEG encodings of one picture: Tom Skerritt's portrait is baseline at 1,590,787 bytes and progressive at 1,121,471, same 2000x3000 pixels, and Accept: image/webp proves negotiation is live on that CDN. Upstream downloaded during provisioning and the generator recorded minutes later; they got one each.

D30 is the rule that came out of it: the answer key records which picture upstream chose, discovered by walking the ranked candidates until one is the file the snapshot holds, and the test asserts the port's chosen path against it. The pairing is discovered from upstream's bytes rather than asserted from our ranking, so a ranking both sides share and both get wrong is still caught. A candidate list where nothing matches stops the cut and names the row. Bytes are still compared where that is sound — a picture we filed equals the picture we were handed — and never across two downloads.

Worth recording: progressive and baseline do not decode to the same blur hash. They differ in two characters, which is why the recording takes upstream's own file once the address is established.

What the review changed

  • Seasons, episodes and all three image providers resolved through /find, where upstream reads only the Tmdb id it was handed and answers nothing without it. A series carrying only a TVDb id got nothing from upstream and a full set from us. The series is now identified once per library, before any row is read.
  • The season key never converged. It was derived from path ids on every scan while the series key moved to fetched ids, so the port landed on (78874-en, series-firefly-en-001) and stayed — reproducing a transient upstream state as a permanent one. Known carries the persisted key now and a test asserts both passes.
  • Two key tests pinned the port's own output annotated as upstream's, and the values appeared in no committed fixture. They are corpus-backed now: upstream fetched Tvdb 78874 and 70533.
  • A whitespace-only provider id is a knowing divergence with upstream's actual behaviour (" -en") written down, rather than a test asserting our rule as theirs.
  • crew_of capped before the name check where the film path caps after; studios were not deduped; Status was read out of the blob and hardcoded None on the way in; episode trailers carried a name upstream does not write; episode premiere_date is upstream's one local-time date. All fixed.

Left open, named

  • The Firefly season crew order. Upstream's row had its three producers reversed against the season endpoint's order while the series and both episodes preserved it — reproduced four times, with no mechanism found in TmdbSeasonProvider, AddPerson, UpdatePeople or MergePeople, and the only .Reverse() in four assemblies being on episodes. Against the regenerated corpus the two now agree, which suggests the earlier reversal was TMDb drift in a scout container rather than a rule. scan_enriched compares crew elementwise by ListOrder, so if it is a rule it will fail there and say so.
  • TmdbMissingEpisodeProvider stays out: 663 lines plus a scheduled task, sharing only the client, and it is what fills /Shows/Upcoming.
  • EnableAutomaticSeriesGrouping is unmodelled — default true, and when off upstream keys a series Id.ToString("N").

#105 stays open: OMDb, MusicBrainz and AudioDb, the person provider and the generated images are still unwritten.

One thing this turned up that belongs to the whole workspace rather than here, filed as #137: thirty-five tests skip themselves by printing a note and returning, which nextest reports as passed. Fifteen were vacuous in the worktree this was developed in, including the one test that checks the presentation-key rule against real rows.

Done, on `main`. The six providers, the scan wiring, the fixture, and the answer key that measures them. ## What it answers now A series, a season and an episode take from TMDb what upstream takes: name, original title, overview, official rating, original language, production year, premiere date, end date, run time, genres, studios, tags, community rating, `Status` in the blob, and all four provider ids at series level — measured against rows the pinned upstream image wrote for `Firefly (2002) [tmdbid-1437]` and `Twin Peaks (1990) [imdbid-tt0098936]`, the second resolved through `/find` so both halves of how a lookup starts are covered. The fixture grew to carry them: three clips under `enriched/shows`, a second `tvshows` library with fetchers on, and a regenerated `enriched` snapshot. The answer key is **11 items, 99 persons, 104 images, 156 credits, 118 values, 125 recorded answers**. `JELLYFIN_RS_REQUIRE_FIXTURES=1 cargo nextest run --workspace`: **2219 passed, 0 skipped.** ## The cassette stopped being a liability `tmdb.json` was 13 MB of base64 rewritten wholesale on every regeneration, and adding TV would have made it ~40 MB. Bodies now live in content-addressed files beside it and the JSON is 1.1 MiB. `Cassette` owns the layout so the generator and the replay cannot drift — they are in different crates, which is exactly how they would have. The conversion was proven rather than asserted: 46 keys before and after, zero semantic differences among the 42 unchanged, exactly four re-spelled for the `append_to_response` fix with byte-identical answers, and all 40 base64 bodies decoding to the file that now holds them. Three guards keep it that way — the recording is byte-for-byte what the writer emits, every named body is on disk and every body on disk is named, and each is filed under the digest of what it holds. `Recorded::answer` now fails on a missing key and on a named-but-absent file, where it used to answer an empty body. `deny_unknown_fields` closes the same hole one format-version away: an old-format recording is refused rather than answering `200` with nothing. ## Two things this got wrong before it got right, and both are method **A self-made answer key is not a check.** The first measurement reported 13 of 13 pictures byte-identical — against a cassette the port had recorded itself, so it compared the port's choices to a recording of the port's own choices. The committed corpus was the first independent check, and it disagreed on seven pictures. That is M10's "a check on a projection is not a check" one level up: not a projection, a self-made key. **And then byte equality turned out to be unsound anyway.** Fetching each disputed URL showed the port's chosen address serving *exactly* upstream's bytes, seven times out of seven — so the selection was right and the two halves of the key disagreed. `image.tmdb.org` serves two JPEG encodings of one picture: Tom Skerritt's portrait is baseline at 1,590,787 bytes and progressive at 1,121,471, same 2000x3000 pixels, and `Accept: image/webp` proves negotiation is live on that CDN. Upstream downloaded during provisioning and the generator recorded minutes later; they got one each. **D30** is the rule that came out of it: the answer key records **which picture upstream chose**, discovered by walking the ranked candidates until one *is* the file the snapshot holds, and the test asserts the port's chosen path against it. The pairing is discovered from upstream's bytes rather than asserted from our ranking, so a ranking both sides share and both get wrong is still caught. A candidate list where nothing matches stops the cut and names the row. Bytes are still compared where that is sound — a picture we filed equals the picture we were handed — and never across two downloads. Worth recording: progressive and baseline do **not** decode to the same blur hash. They differ in two characters, which is why the recording takes upstream's own file once the address is established. ## What the review changed - **Seasons, episodes and all three image providers resolved through `/find`**, where upstream reads only the Tmdb id it was handed and answers nothing without it. A series carrying only a TVDb id got nothing from upstream and a full set from us. The series is now identified once per library, before any row is read. - **The season key never converged.** It was derived from path ids on every scan while the series key moved to fetched ids, so the port landed on (`78874-en`, `series-firefly-en-001`) and stayed — reproducing a transient upstream state as a permanent one. `Known` carries the persisted key now and a test asserts both passes. - **Two key tests pinned the port's own output annotated as upstream's**, and the values appeared in no committed fixture. They are corpus-backed now: upstream fetched `Tvdb 78874` and `70533`. - **A whitespace-only provider id** is a knowing divergence with upstream's actual behaviour (`" -en"`) written down, rather than a test asserting our rule as theirs. - `crew_of` capped before the name check where the film path caps after; studios were not deduped; `Status` was read out of the blob and hardcoded `None` on the way in; episode trailers carried a name upstream does not write; episode `premiere_date` is upstream's one local-time date. All fixed. ## Left open, named - **The Firefly season crew order.** Upstream's row had its three producers reversed against the season endpoint's order while the series and both episodes preserved it — reproduced four times, with no mechanism found in `TmdbSeasonProvider`, `AddPerson`, `UpdatePeople` or `MergePeople`, and the only `.Reverse()` in four assemblies being on episodes. Against the regenerated corpus the two now **agree**, which suggests the earlier reversal was TMDb drift in a scout container rather than a rule. `scan_enriched` compares crew elementwise by `ListOrder`, so if it is a rule it will fail there and say so. - **`TmdbMissingEpisodeProvider`** stays out: 663 lines plus a scheduled task, sharing only the client, and it is what fills `/Shows/Upcoming`. - **`EnableAutomaticSeriesGrouping`** is unmodelled — default true, and when off upstream keys a series `Id.ToString("N")`. #105 stays open: OMDb, MusicBrainz and AudioDb, the person provider and the generated images are still unwritten. One thing this turned up that belongs to the whole workspace rather than here, filed as **#137**: thirty-five tests skip themselves by printing a note and returning, which nextest reports as *passed*. Fifteen were vacuous in the worktree this was developed in, including the one test that checks the presentation-key rule against real rows.
Sign in to join this conversation.
No description provided.