AniList's fragment now asks for the first ten characters, main roles first, with their Japanese voice actor, and for the trailer's site and id. Both are stored in the existing details JSON as TitleDetails.cast and .trailer with serde defaults, so no migration is needed and details stored before them still read. The details REVISION goes to 2, so a stored match is refetched by its id on the next metadata run and the cast fills in without anyone matching the title again. The title page shows a Cast row of round portraits with the character's name over the actor's. Clicking a tile opens the Person browse page for that actor, which now also admits voice actors, and search counts them under People and studios. A Trailer button opens the YouTube or Dailymotion page in the browser; the app itself makes no request to either, and an unknown site or an id with odd characters gives no link.
17 KiB
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:
- 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 failureEpisodeGuide::getalready warned about: not a missing caption but a wrong one. streamingEpisodesis 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 calledUntitled, Akira had a trailer. No row anywhere had a synopsis — AniList keeps none.- A release's season is not an AniList entry. Bleach
S17is the Thousand-Year Blood War, four separate AniList entries. Mushoku TenseiS01is two (11 + 12 episodes). Attack on Titan's season folders hold absolutely-numbered files —S2is episodes 26–37. Mushishi'sS03is Zoku Shou 2, which TVDB files as the second half of season two. - Answers outlived their match.
jujutsu kaisen 0 moviekept 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. - Desktop art was the wrong shape. The 16:9 tile and the library's hero
prefer
backdrop_url, which on AniList isbannerImage— 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. - "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. Chosen as the provider, or with AniList chosen, asked by id for art alone. |
| TVmaze | none | Live-action TV: episodes with 16:9 stills and runtimes, network, status, full-HD backgrounds. No films; its "typography" art is a JPEG, so no logos. In use after AniList. |
| 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:
- the season and number the file states;
- for a file in no season or season one, the provider's own absolute list, then season one;
- the absolute number — but only into the season the file states, so an
absolutely-numbered season folder (
Attack on Titan/S2/… - 26) finds S2E1, andS03E13can never be answered with season one's 13th; - the entry numbering, only for a season TVDB does not have at all — which is
what makes Mushishi
S03E01Zoku Shou 2 episode 1 while Mushoku TenseiS02E01, 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 (done)
More of the answer kept. The AniList query now also asks for the full
start date, broadcast season, banner, popularity, status, the next airing
episode, spoiler-free tags ranked 60 or more, main studios, the Director
among the most relevant staff, and the relations that keep to one story
(PREQUEL, SEQUEL, PARENT, SIDE_STORY, SUMMARY, ALTERNATIVE,
COMPILATION, CONTAINS — not SPIN_OFF, CHARACTER or OTHER, which reach
into other franchises). All of it lives in TitleDetails, stored as one JSON
column (schema v10). A match stored before that is fetched again by id
(Work::Refresh) — never searched for, so hand-picked matches stay — and a
refresh of the same entry keeps its episodes, fanart and enrichment record.
Enrichment also stores the chain of entries it walked.
Art. Three textures per title where there was one: the tile's (Art::of),
the 2:3 cover (Art::poster), and wide art (Art::wide — fanart, else the
AniList banner) decoded at up to 1920 px for the library's hero and the title
page's band. The banner, wrong in a 16:9 tile, fits those strips as drawn.
Poster grid. Settings → Appearance → Library tiles: covers (the default) or 16:9 stills. Covers fill their tile; a cover in a still-shaped tile is still fitted whole.
Names. Settings → Appearance → Title names: English (the default, and what Android always showed), romaji, or the share's own. Search matches all of them whichever is shown.
Sorts. pstr_core::browse, shared by both clients. A–Z by the shown name, recently watched, recently added (from
node_first_seen, which survives recrawls; files present before v10 count as
added at 0), newest by air date, highest rated, most popular.
Franchises. pstr_core::franchise::group joins titles that share a
provider id — their own, a related one, or one their episode walk passed
through. The grid folds each franchise into its lead (the first series to air,
else the first film) with a +N badge; the stack button beside the sort turns
that off. The title page shows the whole franchise in release order. On the
library this was built against: Evangelion ×4, Made in Abyss ×4, Heaven's Feel
×3, Mushishi with its OVA, Jujutsu Kaisen with 0, Chainsaw Man with Reze.
Shelves. pstr_core::shelves::pick: airing now, then directors, studios
and genres, each only when it spans three franchises — Made in Abyss's four
titles are one director's, and that is not a shelf — and a genre only under 40%
of the library. Four at most, over the unfiltered library. Here: Directed by
Satoshi Kon, From MADHOUSE, From MAPPA, From Production I.G.
The title page adds the season and next airing episode to its line of
facts, the tags beside the genres, and the studios and director as chips of
their own. Each genre, studio and director chip opens a page of every title
carrying it (pstr_core::discover::Facet).
More like this. pstr_core::discover::more_like scores every other title
by what it shares with this one — a director 4, a studio 3, a genre 2, one of
the first eight tags 1, the format 1 on top of anything else — and offers those
scoring 4 or more: a director, or two genres, but never one genre alone. The
title's own franchise is left out and each other franchise offers only its best
match. The same scoring, seeded by the title most recently watched to the end
and limited to titles not started, is the library's Because you watched row.
No request: it reads only what the match already stored.
Cast and trailer. The AniList query also asks for the first ten
characters by role, main ones first, each with their Japanese voice actor and
both portraits, and for the trailer's site and id. They are
TitleDetails::cast and TitleDetails::trailer, in the same JSON column — no
migration. TitleDetails::REVISION is 2, so a match stored before them is
fetched again by id on the next match run, as runtime was. A voice actor's
chip opens the same page as a director's (Facet::Person). The trailer is
only ever a link: Trailer::url builds the YouTube or Dailymotion address,
keeping only an id's own characters, and the browser opens it. Portraits are
fetched like posters, from AniList's image host.
Android. The same, through the bridge: AndroidEngine::arrangement
returns the grid's tiles and the shelves as keys into library's titles, and
each TitleRecord carries its display name, franchise and details. The
library's bar has a sort menu with the franchise toggle in it; the grid shows
+N on a folded tile and up to three shelves above it; the title page has the
same facts, tags and franchise row as the desktop's; Title names is in
Settings → Appearance. Android's grid is always covers, so it has no
Library tiles choice.
Folders of films. pstr_core::catalog::build_rows gives each file in a
folder of films its own title, so the four Rebuild films in one folder are
four entries, matched one by one, and the franchise folds them back into one
tile. Only when no file is numbered, none sits under Movies/OVA, and each
names itself differently from the others and from the folder; anything less
is a series with its specials. A film's version number (1.11) is not part of its name on
AniList, so a search that finds nothing is asked again without it.
Not done: TMDB fills no details.
Logos and running times. ani.zip sent both from the start and neither was
kept. Enrichment now keeps the series' TVDB Clearlogo — a transparent PNG —
in title_metadata.logo_url, kept across a refresh of the same entry like the
fanart, and each episode's running time (TVDB's, else AniDB's length) in
episode_metadata.runtime. AniList's duration lands in
TitleDetails::runtime: minutes per episode for a series, the whole running
time for a film. TMDB's episode runtimes are kept the same way.
A clear logo is padded to a square, so every client crops it to its visible
pixels before drawing it (trim_alpha on the desktop, opaqueBounds on
Android and iOS). It replaces the name over the library's banner and at the
top of the title page — except on a light theme's title page, where lettering
made for dark art could vanish. A film's running time joins its line of facts;
an episode row shows its own (on Android and iOS in place of the file size).
Schema v13 adds the two columns and marks every AniList title un-enriched,
deleting nothing. TitleDetails::revision makes details written before
runtime existed refetch by id on the next run; they stay on screen until the
answer lands, rather than being blanked by the migration as schema v10 did.
Phase 3 — reach
Routing by title (done). One provider for the whole library left Game of
Thrones unmatched in a share of anime, and switching the library to TMDB would
have thrown away every AniList match, chain and shelf. So the providers are now
asked in a chain (pstr_meta::service::chain_of): with AniList chosen, a title
AniList has no match for is searched on TVmaze, keyless, before it is given up
on. TMDB, which has television itself, is still asked alone.
- A match is stored under the provider that gave it, and everything after it — enrichment, refreshing details by id — goes to that provider.
- A miss is stored under the last provider asked.
MetadataRecord::is_freshtakes the chain, and a miss only stands when the chain ends with the provider that stored it. So an AniList miss from before TVmaze joined is not trusted, and the next run asks TVmaze at once rather than after the miss expires. - A provider that fails (a timeout, a 429) ends the lookup with an error rather than falling through. The next provider's answer for a title AniList could not be asked about is a guess about an anime, and a guess stored as a match would stay.
- TVmaze is not asked about a title the library knows to be a film. It has none, and a show sharing a film's name is far more often wrong than an adaptation.
- A hand search asks every provider in the chain and labels each result with the provider it came from; picking one pins the title to that provider.
TVmaze answers search with whole show records — genres, network, status,
premiere date, runtime, rating — so a match carries its details with no second
request. Enrichment is one more: /shows/{id} with the episodes and images
embedded. Specials, which TVmaze numbers nowhere a file would, are left out.
Episode names from scene releases (done). Game.of.Thrones.S01E01.Winter.Is.Coming.2160p…
names its episode in dotted words, and naming::episode_title now reads them,
stopping at the first release tag. A dotted run that no tag ends is not taken.
This is the fallback for a title no provider has.
Film art from TMDB by id (done). TVDB has no films, so ani.zip has no
fanart or logo for one, and seventeen of the library's titles — nearly all
films — had nothing wide to draw. ani.zip does give the TMDB id
(mappings.themoviedb_id, with mappings.type saying whether it is a film's
or a show's). With a TMDB key stored and AniList chosen, an entry left without a
backdrop or a logo is asked for /{movie,tv}/{id}/images: one request, by id,
so no title name goes to TMDB. A backdrop without lettering is preferred, and a
PNG logo in the viewer's language, then English. An OVA's or a special's id is
left alone, since TMDB files those either way. A failure there is logged and
the enrichment stored without the art, rather than lost over decoration.
With TMDB itself chosen, enrichment now makes the same request for the logo,
and backdrops are fetched at w1280 rather than w780, since they fill the
width of the title page.
A band that never looks broken (done). A title with no wide art drew
its poster cropped to the band's shape: a blown-up slice of the middle of it.
Now the poster is squeezed to 64×36 and blurred once, when it is fetched
(Fit::Ambient; the cache keeps the wash, not the poster), and that is
stretched across the band — the poster's colours and light rather than a
crop. The library's banner then stands the poster itself at its right. Android
and iOS blur the poster the same way (Android 12 and later; older versions
decode it tiny, which softens it less).
Quality from file names (done). naming::quality reads 2160p, HDR,
10-bit and Dual-Audio out of a release name, and a title's page shows what
most of its files say. No release group is read.
Still to do:
- Local matching against the offline database, so only ids leave the machine.
- Skip intro from AniSkip for files without chapters, and filler flags from Jikan.