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

+71 -31
View File
@@ -483,18 +483,18 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
)
.context("build the metadata provider")?;
let catalog = Catalog::open(&dirs.catalog_db()).context("open catalog")?;
let mut catalog = Catalog::open(&dirs.catalog_db()).context("open catalog")?;
let library = Library::build(catalog.all_files()?, &catalog.all_watch_states()?);
let stored = catalog.all_metadata()?;
let enriched = catalog.enrichment_ages()?;
let mut pending: Vec<_> = library
.titles
.iter()
.filter(|title| {
force
|| !pstr_meta::service::is_usable(stored.get(&title.key), settings.provider)
})
.collect();
let mut pending = pstr_meta::service::plan(
library.titles,
&stored,
&enriched,
settings.provider,
force,
);
if let Some(limit) = limit {
pending.truncate(limit);
}
@@ -504,7 +504,7 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
return Ok(());
}
println!(
"matching {} titles against {}…",
"looking up {} titles against {}…",
pending.len(),
settings.provider.label()
);
@@ -513,32 +513,65 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
// and the GUI already has the parallel path. What this command is
// for is reading the output.
let (mut matched, mut failed) = (0usize, 0usize);
for title in pending {
match service.record(title).await {
Ok(record) => {
match &record.metadata {
Some(found) => {
matched += 1;
println!(
" {:<40} → {} ({})",
title.name,
found.name,
found
.year
.map(|year| year.to_string())
.unwrap_or_else(|| "-".into())
);
}
None => println!(" {:<40} → no match", title.name),
}
catalog.set_metadata(&record)?;
}
for work in pending {
let name = &work.title().name;
let outcome = match service.work(&work).await {
Ok(outcome) => outcome,
Err(error) => {
failed += 1;
// Not stored: a failure to ask is not an answer, and the
// title has to stay askable. See `pstr_meta::service`.
println!(" {:<40} ! {error}", title.name);
println!(" {name:<40} ! {error}");
continue;
}
};
if let Some(record) = &outcome.record {
match &record.metadata {
Some(found) => {
matched += 1;
println!(
" {name:<40} → {} ({})",
found.name,
found
.year
.map(|year| year.to_string())
.unwrap_or_else(|| "-".into())
);
}
None => println!(" {name:<40} → no match"),
}
catalog.set_metadata(record)?;
}
match &outcome.enrichment {
Some(enrichment) => {
println!(
" {:<40} {} episodes{}",
"",
enrichment.episodes.len(),
if enrichment.backdrop_url.is_some() {
", fanart"
} else {
""
}
);
catalog.set_enrichment(
&outcome.title_key,
settings.provider,
unix_now(),
enrichment,
)?;
}
// Matched, but the episodes could not be asked for; the
// next run asks again.
None if outcome
.record
.as_ref()
.is_some_and(|r| r.metadata.is_some()) =>
{
println!(" {:<40} ! episodes not fetched", "");
}
None => {}
}
}
println!("{matched} matched, {failed} could not be looked up");
@@ -926,3 +959,10 @@ async fn play(
drop(opened.library);
Ok(())
}
/// Unix seconds, for the "when was this asked" columns.
fn unix_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |since| since.as_secs() as i64)
}