feat(media): per-episode TV duplicate census, and the Jellyfin library that indexed but fetched nothing #116

Merged
dank merged 4 commits from feat/jellyfin-provider-keys into main 2026-11-02 02:03:03 -08:00
Owner

Two streams, four commits, one branch (Frank asked for the branch as a single PR).

Per-episode TV duplicate census, and the tag that outranks a borrowed id

scripts/media-tvdupes.py censuses a TV library by episode key (season, episode) and never by folder, and writes a staging plan in media-apply.py's own format — it moves nothing itself.

  • A folder-level verdict destroys content. 10 of the 25 shows that looked duplicated in the 2026-11-02 census held complementary halves (Game of Thrones: The Complete Series carries no S01 and no S08; Modern Family S05 is the only copy of season 5; Loiter Squad Uncensored the only copy of season 1). Judged per folder the pass would have staged some 340 GB of unique episodes; judged per episode it staged 220 files / 54 GiB of true copies.
  • Identity before geometry: the [tvdbid-N] name tag, the tvshow.nfo the metadata view exposes (--meta), or an operator folder<TAB>id map (--ids). Disjoint id sets are two shows however alike they read, and an unprovable pair is reported UNRESOLVED and left alone.
  • A bare number is never an episode. Key-less names are never staged and are counted in the report; --witness (Jellyfin's own BaseItems episode rows) keys the files a packager named by id.
  • A pack states a range and competes on all of it, and the audit re-stats every survivor before --out is written — a failed audit leaves no plan artifact to hand the executor.

The bug the run caught. Jellyfin's provider table carries a 2026 special's tvdb id (457779) on the folder tagged [tvdbid-73838]. The old rule unioned tag and derived ids, and an id in common outranks name and year — so Malcolm in the Middle (2000) and the 2026 special became one show, whose bigger files dominate the original's S01E01–S01E04: 3.2 GiB of unique episodes bound for the hold as "worse copies". The tag now outranks every derived id; a contradiction is dropped and printed:

DISAGREE (the tag wins): Malcolm in the Middle (2000) [tvdbid-73838] — tag tvdb [73838], derived tvdb [457779]

Red test first (it staged the file), then green, then the same result on the live library.

State of the library after the pass, re-verified read-only on the host: a census over all 753 top-level folders with all three identity layers staged 0 files — duplicate-free to the limit of provable identity. The two pairs the census still refuses to merge share 0 episode keys (measured), so they are complementary rather than duplicated, and tagging them would have freed nothing at the cost of a rename with library consequences. Both media servers read landed: 0 / still at src: 0 / REAL: 0 against the pass journal.

Records live at the array root, not /tmp: tv-dupes.jsonl (220 move / 16 mkdir / 7 rmdir), tv-dupes-plan.tsv, tvshows-detritus.jsonl (109 / 14 / 14), tvshows-detritus-plan.tsv, tv-tvdb-ids.tsv (616 folders, from BaseItemProviders where ProviderId = 'Tvdb').

Jellyfin: a library that indexed 2,571 films and fetched nothing

52600b2 and a25f139. The cause was two per-library settings in config/root/default/<Library>/options.xml — EnableInternetProviders=false plus an empty <ImageFetchers/> sitting beside a fully populated <MetadataFetchers> — so every refresh answered 204, stored nothing and logged nothing. That state passed the deploy health gate, the Playwright suite and three agent audits. jellyfin-providers.sh verify now fails with SILENT INDEX <Library> on providers-off or on an empty image-fetcher set beside a named metadata set. The page's claim that artwork is a sidecar like movie.nfo is corrected: no image saver is bound for any library, so art lands in the internal store and Plex never reads it.

Overlay law recorded

51ed5c0: stat through the view can outlive the file. After folders were moved 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. Existence through the view is proved by the parent listing, and every pass that moves or measures media walks /mnt/entropy; only a reader of show identity points at the view.

Verification

  • ./tests/run.sh: 806 assertions, exit 0 (new seam tests/media_tvdupes.bats: 12 assertions, synthetic library, no server or array).
  • No deploy in this PR: no service path changed — a new repo script, a test seam, docs. Nothing on either host was restarted, and no library scan was needed because the census staged nothing further.
  • The purge is deliberately not done: 20261101-duplicate-prune (~1.1 TiB), 20261102-tv-duplicates (54 GiB / 220 files), 20261102-tvshows-detritus (552 KiB). Frank's word, per repo law.

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

Two streams, four commits, one branch (Frank asked for the branch as a single PR). ## Per-episode TV duplicate census, and the tag that outranks a borrowed id `scripts/media-tvdupes.py` censuses a TV library by episode key `(season, episode)` and never by folder, and writes a staging plan in `media-apply.py`'s own format — it moves nothing itself. - **A folder-level verdict destroys content.** 10 of the 25 shows that looked duplicated in the 2026-11-02 census held complementary halves (`Game of Thrones: The Complete Series` carries no S01 and no S08; `Modern Family S05` is the only copy of season 5; `Loiter Squad Uncensored` the only copy of season 1). Judged per folder the pass would have staged some 340 GB of unique episodes; judged per episode it staged 220 files / 54 GiB of true copies. - **Identity before geometry**: the `[tvdbid-N]` name tag, the `tvshow.nfo` the metadata view exposes (`--meta`), or an operator `folder<TAB>id` map (`--ids`). Disjoint id sets are two shows however alike they read, and an unprovable pair is reported `UNRESOLVED` and left alone. - **A bare number is never an episode.** Key-less names are never staged and are counted in the report; `--witness` (Jellyfin's own `BaseItems` episode rows) keys the files a packager named by id. - **A pack states a range and competes on all of it**, and the audit re-stats every survivor before `--out` is written — a failed audit leaves no plan artifact to hand the executor. **The bug the run caught.** Jellyfin's provider table carries a 2026 special's tvdb id (457779) on the folder tagged `[tvdbid-73838]`. The old rule unioned tag and derived ids, and an id in common outranks name and year — so `Malcolm in the Middle` (2000) and the 2026 special became one show, whose bigger files dominate the original's `S01E01`–`S01E04`: 3.2 GiB of unique episodes bound for the hold as "worse copies". The tag now outranks every derived id; a contradiction is dropped and printed: DISAGREE (the tag wins): Malcolm in the Middle (2000) [tvdbid-73838] — tag tvdb [73838], derived tvdb [457779] Red test first (it staged the file), then green, then the same result on the live library. **State of the library after the pass**, re-verified read-only on the host: a census over all 753 top-level folders with all three identity layers staged **0 files** — duplicate-free to the limit of provable identity. The two pairs the census still refuses to merge share **0 episode keys** (measured), so they are complementary rather than duplicated, and tagging them would have freed nothing at the cost of a rename with library consequences. Both media servers read `landed: 0 / still at src: 0 / REAL: 0` against the pass journal. Records live at the array root, not `/tmp`: `tv-dupes.jsonl` (220 move / 16 mkdir / 7 rmdir), `tv-dupes-plan.tsv`, `tvshows-detritus.jsonl` (109 / 14 / 14), `tvshows-detritus-plan.tsv`, `tv-tvdb-ids.tsv` (616 folders, from `BaseItemProviders` where `ProviderId = 'Tvdb'`). ## Jellyfin: a library that indexed 2,571 films and fetched nothing `52600b2` and `a25f139`. The cause was two per-library settings in `config/root/default/<Library>/options.xml` — `EnableInternetProviders=false` plus an empty `<ImageFetchers/>` sitting beside a fully populated `<MetadataFetchers>` — so every refresh answered 204, stored nothing and logged nothing. That state passed the deploy health gate, the Playwright suite and three agent audits. `jellyfin-providers.sh verify` now fails with `SILENT INDEX <Library>` on providers-off or on an empty image-fetcher set beside a named metadata set. The page's claim that artwork is a sidecar like `movie.nfo` is corrected: no image saver is bound for any library, so art lands in the internal store and Plex never reads it. ## Overlay law recorded `51ed5c0`: `stat` through the view can outlive the file. After folders were moved 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. Existence through the view is proved by the parent listing, and every pass that moves or measures media walks `/mnt/entropy`; only a reader of show identity points at the view. ## Verification - `./tests/run.sh`: **806 assertions, exit 0** (new seam `tests/media_tvdupes.bats`: 12 assertions, synthetic library, no server or array). - **No deploy in this PR**: no service path changed — a new repo script, a test seam, docs. Nothing on either host was restarted, and no library scan was needed because the census staged nothing further. - The purge is deliberately **not** done: `20261101-duplicate-prune` (~1.1 TiB), `20261102-tv-duplicates` (54 GiB / 220 files), `20261102-tvshows-detritus` (552 KiB). Frank's word, per repo law. --- cwd: /home/blank/.omp/wt/wt-20261030-163847-6d1fc1f · session: 01a18f2e-18b1-7573-bcd5-ac99636f8bcb
dank added 4 commits 2026-11-02 02:02:07 -08:00
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).
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.
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.
dank merged commit b93e8481f9 into main 2026-11-02 02:03:03 -08: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#116