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

+18 -65
View File
@@ -1420,44 +1420,22 @@ impl AndroidEngine {
impl AndroidEngine {
/// One enrichment pass over the whole library.
///
/// Two kinds of work, and the difference is a request saved: a title that
/// already has a good match but no episode list needs the episode request
/// only. Both run fanned out under a semaphore, and a provider error counts
/// against the run rather than ending it — one rate-limited title must not
/// leave the other thirty unenriched. This mirrors the desktop engine's
/// `run_match`, which is the reference for the behaviour.
/// What to do per title is decided by `pstr_meta::service::plan`, the same
/// as on the desktop. The work runs fanned out under a semaphore, and a
/// provider error counts against the run rather than ending it — one
/// rate-limited title must not leave the other thirty unenriched. This
/// mirrors the desktop engine's `run_match`, which is the reference for the
/// behaviour.
async fn match_titles_inner(&self, force: bool) -> Result<MatchSummary, BridgeError> {
let service = Arc::new(self.metadata_service()?);
let provider = service.provider();
let (titles, stored) = self.titles_and_metadata()?;
let listed = self
let enriched = self
.catalog
.lock()
.episode_metadata_ages()
.enrichment_ages()
.map_err(BridgeError::from_display)?;
let mut pending: Vec<Work> = Vec::new();
for title in titles {
let record = stored.get(&title.key);
// A hand-picked entry is never searched for again, not even by
// "match again" — that button means "the automatic answers are
// wrong", and re-deciding the one title the viewer already fixed by
// hand is the opposite of what they asked for. Its episode list is
// still fetched below if it is missing.
let pinned = record.is_some_and(|record| record.manual && record.provider == provider);
if !pinned && (force || !pstr_meta::service::is_usable(record, provider)) {
pending.push(Work::Match(title));
continue;
}
let has_episodes = listed
.get(&title.key)
.is_some_and(|(asked, _)| *asked == provider);
if let Some(found) = record.and_then(|record| record.metadata.clone())
&& !has_episodes
{
pending.push(Work::Episodes(title, Box::new(found)));
}
}
let pending = pstr_meta::service::plan(titles, &stored, &enriched, provider, force);
let permits = Arc::new(tokio::sync::Semaphore::new(LOOKUP_CONCURRENCY));
let mut lookups = tokio::task::JoinSet::new();
@@ -1466,32 +1444,15 @@ impl AndroidEngine {
let permits = Arc::clone(&permits);
lookups.spawn(async move {
let _permit = permits.acquire().await.ok()?;
Some(match work {
Work::Match(title) => match service.record(&title).await {
Ok(record) => {
// Only for a title that matched: there is no id to
// ask about otherwise.
let episodes = match &record.metadata {
Some(found) => service.title_episodes(&title, found).await,
None => Vec::new(),
};
Ok((record.title_key.clone(), Some(record), episodes))
}
Err(error) => Err(error.to_string()),
},
Work::Episodes(title, found) => {
let episodes = service.title_episodes(&title, &found).await;
Ok((title.key, None, episodes))
}
})
Some(service.work(&work).await.map_err(|error| error.to_string()))
});
}
let mut summary = MatchSummary::default();
while let Some(joined) = lookups.join_next().await {
match joined.map_err(BridgeError::from_display)? {
Some(Ok((title_key, record, episodes))) => {
if let Some(record) = &record {
Some(Ok(outcome)) => {
if let Some(record) = &outcome.record {
if record.metadata.is_some() {
summary.matched += 1;
} else {
@@ -1502,16 +1463,16 @@ impl AndroidEngine {
log::warn!("store metadata for {}: {error}", record.title_key);
}
}
if !episodes.is_empty() {
summary.episodes += episodes.len() as u32;
let stored = self.catalog.lock().set_episode_metadata(
&title_key,
if let Some(enrichment) = &outcome.enrichment {
summary.episodes += enrichment.episodes.len() as u32;
let stored = self.catalog.lock().set_enrichment(
&outcome.title_key,
provider,
now(),
&episodes,
enrichment,
);
if let Err(error) = stored {
log::warn!("store episodes for {title_key}: {error}");
log::warn!("store enrichment for {}: {error}", outcome.title_key);
}
}
}
@@ -2340,14 +2301,6 @@ struct CachedLibrary {
titles: Vec<TitleRecord>,
}
/// One title's share of an enrichment pass.
enum Work {
/// Search for it, and take its episodes if it matches.
Match(Title),
/// It is already matched; only the episode list is missing.
Episodes(Title, Box<TitleMetadata>),
}
/// How many provider lookups may be in flight at once.
///
/// Deliberately small. Both providers rate-limit by client, and a phone