fix(core,meta,app,android,cli): number episodes the way releases do

AniList's per-season lookup searched for "<title> 2nd Season" and the
scorer picked the base entry again, so Attack on Titan seasons 2-4 and
Jigokuraku season 2 were captioned with season one's names. Episode
names came from streamingEpisodes, which is partial and numbered however
a streaming site counted. Bleach S17 and Mushishi S02/S03 had none.

AniList now walks SEQUEL relations from the match and takes each
entry's episodes from ani.zip, which carries TVDB's season, number and
absolute number. An episode row keeps all three plus its place in the
AniList entry. EpisodeGuide::get matches whichever numbering a file
uses and never answers from another season.

Episodes and fanart are stored as one enrichment. The episode list is
replaced even when empty, and the enrichment is recorded as asked, so a
film is not re-asked every run. Storing a different match drops the old
entry's episode rows. AniList's 4.75:1 bannerImage is no longer stored
as the 16:9 backdrop; ani.zip's 1920x1080 fanart is used instead.

Schema v9 adds the columns and deletes the AniList episode rows and
banners. Matches are kept. Desktop, Android and the CLI now share
service::plan and MetadataService::work. The settings text names
ani.zip. Audit and roadmap in docs/METADATA.md; bug entry B61.
This commit is contained in:
nvrl committed 2026-10-04 00:47:41 +02:00
1 parent 52d27ec996
commit 4c80076762
17 files changed
+1680 -618

No files matched your search

+45
View File
@@ -146,6 +146,51 @@ and [B44](#b44--r8-broke-the-app-under-instrumentation-in-five-places).
## Fixed
### B61 — later seasons were captioned with season one's episode names
**Symptom.** On a 42-title AniList library, Attack on Titan seasons 2–4 and
Jigokuraku season 2 showed season one's episode names. Dan Da Dan, Kaiju No. 8
and The Apothecary Diaries had a handful of names numbered past the end of the
season. Bleach's had 20 rows called `Untitled`. Bleach `S17` (the Thousand-Year
Blood War) and Mushishi `S02`/`S03` had none at all. Jujutsu Kaisen 0 kept the
TV series' names after it was re-pinned to the film. Every desktop tile drew a
blurred strip of AniList banner.
**Cause.** Four, independent.
- `season_episodes` searched for `<title> 2nd Season`, and the scorer, seeing
the base entry's alias inside the query, picked the base entry again. That
search was [B11](#b11--seasons-past-the-first-had-no-episode-names-and-were-one-query-from-having-the-wrong-ones)'s
fix, and it only worked where the sequel's name scored higher than the base's.
- `streamingEpisodes` is what a streaming site published: partial, and
numbered however that site counted.
- A release's season is not an AniList entry. `S17` is four entries, and
Mushoku Tensei's `S01` is two.
- An empty episode list was never written, so a re-match kept the old one.
`bannerImage`, a 4.75:1 strip, was stored as the 16:9 backdrop.
**Fix.** AniList walks `SEQUEL` relations from the match. Each entry's episodes
come from ani.zip with TVDB's season, number and absolute number, plus the
entry's own numbering. `EpisodeGuide::get` meets whichever of those a filename
used without ever crossing seasons. Enrichment is stored as one unit,
replaced even when empty and recorded as asked. A changed match drops the old
entry's rows at once. The backdrop is ani.zip's 1920×1080 fanart, and the
banner is no longer stored. Schema v9 deletes the bad rows. See
[METADATA.md](METADATA.md).
**Verified.** `EpisodeGuide` tests in `pstr_core::metadata` (absolute numbers
in a season folder, by-entry seasons, no season crossing), and the catalog's
`an_empty_enrichment_clears_the_episodes_the_last_match_left`,
`pinning_a_different_entry_drops_the_old_entrys_episodes_at_once` and
`upgrading_drops_anilists_episode_rows_and_banners_but_keeps_its_matches`.
Live, against a copy of the same library through `pstr metadata match`:
- 0 lookups failed.
- Every numbered file is answered except 2 Attack on Titan files, 2 Bleach
files and one Mushoku Tensei special, which TVDB does not list.
- 29 of 44 matched titles have fanart, and none has a banner.
- A second run asks nothing.
### B60 — a failed "Add and crawl" left the crawl spinner running forever
**Symptom.** Pasting a link without its `#` fragment, or one already added,
+20 -24
View File
@@ -106,26 +106,22 @@ card (`App::tick_up_next`, `ui::player::up_next_card`) with *Play now* and
chapters, autoplay on, a next episode, and playback actually running — a paused
episode is one somebody walked away from.
Done: **per-episode metadata.** `Provider::episodes` fetches an episode list for
a title that already matched (AniList `streamingEpisodes`, which is titles and
thumbnails only and no synopsis; TMDB `/tv/{id}` plus every season appended onto
one more request), stored in `episode_metadata` — schema **v4**. The reconciling
between a release's numbering and a provider's lives in one tested place,
`EpisodeGuide::get`: the season and number the filename states, then the
provider's absolute numbering, then season one for a file that states no season
at all. That last fallback is what makes `#057` line up with TMDB filing a
64-episode anime under one season. Episode names show on the title page's rows,
in the player's title strip and in the "next:" line autoplay prints.
Done: **per-episode metadata.** `Provider::enrich` fetches the episode list —
and, for AniList, the series' fanart — of a title that already matched, and
`Catalog::set_enrichment` stores both and records that it asked (schema **v9**),
so an empty answer is not re-asked every run. TMDB is `/tv/{id}` plus every
season appended onto one more request. AniList walks the match's `SEQUEL`
relations and takes each entry's episodes from ani.zip, which carries TVDB's
season, number and absolute number — the numbering release groups use. The
reconciling between a release's numbering and a provider's lives in one tested
place, `EpisodeGuide::get`. [METADATA.md](METADATA.md) has the audit that drove
this, the order `get` tries things in, and what comes next.
**A season is a separate lookup on AniList.** `Provider::seasons_are_separate_entries`
is what splits the two providers: TMDB files every season under one show and
returns them numbered, while AniList makes each sequel its own entry numbering
from one (`[Oshi no Ko] 2nd Season`, id 166531, is not `[Oshi no Ko]`, id
150672). So for AniList each season past the first is searched for by name —
`<title> 2nd Season`, then `<title> Season 2` — and its episodes are stored
tagged with that season. The absolute fallback in `EpisodeGuide::get` stops at
season one for the same reason: without that it answered `S02E01` with season
one's episode one, which is not a missing caption but a wrong one.
The first version searched AniList for `<title> 2nd Season` per season and took
episode names from `streamingEpisodes`; both were wrong often enough to show —
the search found the base entry again and captioned Attack on Titan's later
seasons with season one's names, and `streamingEpisodes` is whatever a streaming
site published. v9 deletes everything they stored.
Done: **matching a title by hand.** `MATCH_FLOOR` is set where a wrong poster
costs more than none, and the price of that is a few titles it refuses to decide
@@ -145,11 +141,11 @@ rather than storing a miss, so the next run treats the title as one it has never
asked about. Switching provider still clears everything, hand-picked rows
included: a `remote_id` means nothing to the other provider.
Worth knowing before chasing a missing name: AniList's episode titles come from
`streamingEpisodes`, which is what streaming sites published, and for some shows
it is simply **empty** — all three Oshi no Ko entries have none, while Fullmetal
Alchemist Brotherhood has 64. There is nothing to fix on our side for those;
TMDB is the provider that has them.
Worth knowing before chasing a missing name: ani.zip only lists what TVDB has
placed. A file it cannot answer is usually a special (`S02E00`), an episode TVDB
skipped, or one too new to be listed — Attack on Titan's last two files and two
of Bleach's forty-four are the standing examples. `pstr metadata match` prints
how many episodes each title got.
Note the glyph trap: egui's bundled fonts cover the transport icons
(`⏮ ⏭ ⏸ ▶ 🔊 🔇 ⛶`) and *not* `← 💬 ▣`, which draw as an empty box. Anything new
+137
View File
@@ -0,0 +1,137 @@
# Metadata
How a library of filenames becomes posters, synopses and episode names, what
was wrong with the first version of it, and where it is going. The privacy
stance from `pstr_core::metadata` holds throughout: nothing here is requested
until the viewer turns enrichment on, and every new source named below sits
behind that same switch.
## What a real library showed
Audited against a 42-title, 758-file anime library matched on AniList
(schema v8, October 2026). The match itself — title to AniList entry — was
good. What hung off it was not:
1. **Later seasons captioned with season one's names.** Attack on Titan
seasons 2–4 and Jigokuraku season 2 all stored season one's episode list.
The per-season lookup searched for `Attack on Titan 2nd Season`, and the
similarity scorer, seeing the base entry's alias inside the query, picked the
base entry again. That is the failure `EpisodeGuide::get` already warned
about: not a missing caption but a wrong one.
2. **`streamingEpisodes` is not an episode list.** It is what a streaming site
published, partial and numbered however that site numbered it. Dan Da Dan
had 5 rows numbered 13–17, Kaiju No. 8 had 4–15, The Apothecary Diaries had
25–48 under two seasons at once, Bleach had 20 rows called `Untitled`, Akira
had a trailer. No row anywhere had a synopsis — AniList keeps none.
3. **A release's season is not an AniList entry.** Bleach `S17` is the
Thousand-Year Blood War, four separate AniList entries. Mushoku Tensei `S01`
is two (11 + 12 episodes). Attack on Titan's season folders hold
absolutely-numbered files — `S2` is episodes 26–37. Mushishi's `S03` is
*Zoku Shou 2*, which TVDB files as the second half of season two.
4. **Answers outlived their match.** `jujutsu kaisen 0 movie` kept the TV
series' 24 episode names after it was re-pinned to the film: an empty episode
list was never written, so the old one stayed.
5. **Desktop art was the wrong shape.** The 16:9 tile and the library's hero
prefer `backdrop_url`, which on AniList is `bannerImage` — a 1900×400 strip,
4.75:1, cached at 640×135. Cropped to 16:9 and stretched, that is a blurred
sliver from the middle of a banner. Android looks right because it draws the
2:3 cover.
6. **"Newest" sorts on nothing.** It reads the year from the filename, which 2
of 42 titles state.
## Sources
| Source | Key | What it is good for |
|---|---|---|
| **AniList** GraphQL | none | Identity. Search, every alias, cover art, relations (`SEQUEL`, `PREQUEL`, `SIDE_STORY`…), studios, staff, tags, format, season, popularity, next airing episode, the MAL id. |
| **ani.zip** `api.ani.zip/mappings?anilist_id=` | none | Everything AniList lacks, per AniList id: TVDB season, episode and absolute numbers; episode titles in several languages; TVDB synopses; 16:9 stills; air dates; runtime. Series art: TVDB **fanart at 1920×1080**, poster, banner, logo. Cross-ids for MAL, AniDB, TVDB, TMDB, Kitsu, IMDb. A community service — cache what it says. |
| **TMDB** | free key | Film backdrops and logos; live-action TV. Already implemented. |
| **TVmaze** | none | Live-action TV episodes, keyless. |
| **AniSkip** | none | Opening and ending timestamps by MAL id and episode. |
| **Jikan** (MAL) | none | Filler and recap flags. Timed out when tried; optional. |
| **manami-project/anime-offline-database** | none | Every anime with its synonyms, ids and relations, as one 6 MB `.zst`. Matching against it locally sends no title anywhere. |
| AniDB, TVDB directly | registration / paid | Covered by ani.zip, which republishes their data. |
The fact the whole design turns on: **release groups number files the way TVDB
does.** `Bleach - S17E01` is TVDB season 17; Attack on Titan's 26–96 are TVDB
absolute numbers. ani.zip gives both for every AniList entry, so the join
between a filename and an episode is a lookup, not a guess.
## Phase 1 — correct data (done)
**Seasons by relation, not by name.** `AniList::enrich` starts at the matched
entry and follows its `SEQUEL` edges — TV formats first, a special or film only
when it is the sole link onward (Mushishi's chain runs through an OVA). Each
entry's episodes come from ani.zip. The walk stops as soon as every numbered file
in the title has an answer, at the end of the chain, or after
`MAX_CHAIN_ENTRIES`, so a one-season show costs one ani.zip request and no
relations query at all.
**One episode, three numberings.** `EpisodeMetadata` carries TVDB's
`(season, number)`, TVDB's `absolute` number, and its `(entry_season,
entry_number)` — the position of its entry among the franchise's TV entries,
and its number within that entry, which is how a release that follows AniList
rather than TVDB numbers its seasons. `EpisodeGuide::get` tries, in order:
1. the season and number the file states;
2. for a file in no season or season one, the provider's own absolute list,
then season one;
3. the absolute number — but only into the season the file states, so an
absolutely-numbered season folder (`Attack on Titan/S2/… - 26`) finds
S2E1, and `S03E13` can never be answered with season one's 13th;
4. the entry numbering, only for a season TVDB does not have at all — which is
what makes Mushishi `S03E01` *Zoku Shou 2* episode 1 while Mushoku Tensei
`S02E01`, a season TVDB does have, stays TVDB's.
`streamingEpisodes` is gone.
**Enrichment is recorded, empty or not.** A title's episode list and its
fanart are one *enrichment*, stored together by `Catalog::set_enrichment` in one
transaction that always replaces the old episode rows — so an empty answer
clears what a previous match left behind. `title_metadata.enriched_at` records
that it was asked, which is what keeps a film with no episodes from being
re-asked on every match run. A new match clears it; a failed enrichment leaves it
clear, so the title is asked again.
**No banner on a 16:9 tile.** AniList's `bannerImage` is no longer stored as a
backdrop. The backdrop is ani.zip's fanart where there is one; otherwise the tile
letterboxes the poster.
**Schema v9** adds the three numbering columns and `enriched_at`, deletes every
stored AniList episode row and every AniList banner-as-backdrop — all of it
`streamingEpisodes` data and wrong-shaped art — and leaves every title
un-enriched, so the next match run (which the app starts on its own) refills
them. Matches, hand-picked ones included, are kept.
## Phase 2 — art and order
- **Art slots** instead of one backdrop: poster (2:3), fanart (16:9), banner
(ultra-wide), logo. The hero and the 16:9 tiles take fanart; the banner only
ever fills an ultra-wide strip.
- **A larger size class for the hero.** `THUMBNAIL_MAX_EDGE` is 640 for every
picture; the hero is drawn up to ~1600 px wide.
- **A poster grid on desktop**, as an option or the default — AniList's covers
are the best art this library has, and Android already proves the layout.
- **More of the AniList answer kept**: format, season and year, studios,
director, tags, popularity, status, next airing episode — all in the search
response already, so no extra request.
- **Sorts**: by the displayed name (English / romaji / folder, as a setting),
release season, rating, popularity, recently added (needs a `first_seen`
column), in-progress first.
- **Franchise grouping** from relations: the Evangelion TV series, its two films
and the Rebuild folder; Made in Abyss and its three films; Mushishi and its
specials; the Heaven's Feel trilogy. One tile each, with the parts in release
order on the title page.
- **Shelves**: genre, studio, director — this library has a Satoshi Kon shelf
waiting in it — format, decade, airing now.
## Phase 3 — reach
- **Local matching** against the offline database, so only ids leave the
machine.
- **Routing by title**: AniList first, then TMDB (with a key) or TVmaze for
whatever is not anime — Game of Thrones in a mostly-anime share.
- **Folders of films** matched per file: four Rebuild films in one folder are
four entries, not one.
- **Skip intro** from AniSkip for files without chapters, and filler flags from
Jikan.