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

+26 -75
View File
@@ -1903,44 +1903,18 @@ impl Engine {
};
let provider = service.provider();
let (stored, listed) = {
let (stored, enriched) = {
let catalog = self.catalog.lock();
match catalog
.all_metadata()
.and_then(|stored| Ok((stored, catalog.episode_metadata_ages()?)))
.and_then(|stored| Ok((stored, catalog.enrichment_ages()?)))
{
Ok(pair) => pair,
Err(error) => return self.fail("read stored metadata", error),
}
};
// 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 — searching for it again would ask the provider
// something it has already answered.
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);
if pending.is_empty() {
return self.emit(Event::Status("everything is already matched".into()));
}
@@ -1959,25 +1933,7 @@ impl Engine {
let Ok(_permit) = permits.acquire().await else {
return None;
};
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),
},
Work::Episodes(title, found) => Ok((
title.key.clone(),
None,
service.title_episodes(title, found).await,
)),
})
Some(service.work(work).await)
});
}
@@ -1985,8 +1941,8 @@ impl Engine {
let mut episodes_found = 0usize;
while let Some(result) = lookups.next().await {
match result {
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() {
matched += 1;
} else {
@@ -1998,16 +1954,16 @@ impl Engine {
tracing::warn!("store metadata for {}: {error}", record.title_key);
}
}
if !episodes.is_empty() {
episodes_found += episodes.len();
let stored = self.catalog.lock().set_episode_metadata(
&title_key,
if let Some(enrichment) = &outcome.enrichment {
episodes_found += enrichment.episodes.len();
let stored = self.catalog.lock().set_enrichment(
&outcome.title_key,
provider,
now(),
&episodes,
enrichment,
);
if let Err(error) = stored {
tracing::warn!("store episodes for {title_key}: {error}");
tracing::warn!("store enrichment for {}: {error}", outcome.title_key);
}
}
}
@@ -2078,17 +2034,21 @@ impl Engine {
return engine.fail("store the match", error);
}
let episodes = service.title_episodes(&title, &found).await;
if !episodes.is_empty() {
let stored = engine.catalog.lock().set_episode_metadata(
&title.key,
service.provider(),
now(),
&episodes,
);
if let Err(error) = stored {
tracing::warn!("store episodes for {}: {error}", title.key);
// A failure leaves the title un-enriched, and the next match run
// asks again.
match service.enrich(&title, &found).await {
Ok(enrichment) => {
let stored = engine.catalog.lock().set_enrichment(
&title.key,
service.provider(),
now(),
&enrichment,
);
if let Err(error) = stored {
tracing::warn!("store enrichment for {}: {error}", title.key);
}
}
Err(error) => tracing::warn!("enrich {}: {error}", title.key),
}
engine.emit(Event::Status(format!("{} is now {name}", title.name)));
@@ -2202,15 +2162,6 @@ fn digest(url: &str) -> String {
.collect()
}
/// The cache and texture key for a file's poster.
/// One title's worth of work in a match run.
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<pstr_core::metadata::TitleMetadata>),
}
/// Unix seconds, for the "when was this asked" columns.
fn now() -> i64 {
std::time::SystemTime::now()