diff --git a/android/app/src/main/java/io/narl/protonstream/ui/SettingsScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/SettingsScreen.kt index b5429cb..0756459 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/SettingsScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/SettingsScreen.kt @@ -438,16 +438,16 @@ private fun MetadataSettingsDialog( title = { Text("Metadata enrichment") }, text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("Off by default: enabling sends the titles in your library to a third party, associated with your IP address and subject to their privacy policy. With AniList, the matched ids also go to ani.zip for episode names and wide artwork.") + Text("Off by default: enabling sends the titles in your library to a third party, associated with your IP address and subject to their privacy policy. With AniList, a title it has nothing for is then sent to TVmaze, and the matched ids go to ani.zip for episode names and wide artwork.") SettingToggle("Enable enrichment", enabled) { enabled = it } Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - MetadataProvider.entries.forEach { option -> + listOf(MetadataProvider.ANI_LIST, MetadataProvider.TMDB).forEach { option -> if (option == provider) AccentButton(onClick = { provider = option }) { Text(option.displayName()) } else EdgedButton(onClick = { provider = option }) { Text(option.displayName()) } } } Text( - if (provider == MetadataProvider.ANI_LIST) "Anime; no account or API key required." + if (provider == MetadataProvider.ANI_LIST) "Anime, and television from TVmaze; no account or API key required." else "Film and television; requires a free TMDB API key.", style = MaterialTheme.typography.bodySmall, ) @@ -497,6 +497,7 @@ private fun MetadataSettingsDialog( internal fun MetadataProvider.displayName(): String = when (this) { MetadataProvider.ANI_LIST -> "AniList" MetadataProvider.TMDB -> "TMDB" + MetadataProvider.TV_MAZE -> "TVmaze" } private data class LegalDocument(val title: String, val assetPath: String) diff --git a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt index 92e212a..a3273dc 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt @@ -759,7 +759,7 @@ internal fun ChangeMatchDialog( title = { Text("Change match") }, text = { Column { - Text("Search the configured metadata provider. Nothing is stored until you choose an entry.") + Text("Search the configured metadata providers. Nothing is stored until you choose an entry.") OutlinedTextField(term, { term = it }, Modifier.fillMaxWidth().padding(vertical = 8.dp), singleLine = true) AccentButton(enabled = term.isNotBlank() && !searching, onClick = { searching = true @@ -794,7 +794,10 @@ internal fun ChangeMatchDialog( ) { Column(Modifier.fillMaxWidth()) { Text(option.name, fontWeight = FontWeight.SemiBold) - Text(listOfNotNull(option.year?.toString(), option.originalName).joinToString(" · ")) + Text( + listOfNotNull(option.year?.toString(), option.originalName, option.provider.displayName()) + .joinToString(" · "), + ) } } } diff --git a/crates/pstr-android/src/lib.rs b/crates/pstr-android/src/lib.rs index cc54ef3..10a2e21 100644 --- a/crates/pstr-android/src/lib.rs +++ b/crates/pstr-android/src/lib.rs @@ -304,6 +304,9 @@ pub enum TitleType { pub enum MetadataProvider { AniList, Tmdb, + /// Never chosen in the settings: what AniList hands a show it does not + /// have. Seen on a [`MatchRecord`] from a hand search. + TvMaze, } /// A palette family. The shared `pstr_core::appearance::Flavor`. @@ -1746,7 +1749,7 @@ impl AndroidEngine { spawned(&runtime, async move { let service = self.metadata_service()?; let metadata = title_metadata(found); - if metadata.provider != service.provider() { + if !service.chain().contains(&metadata.provider) { return Err(BridgeError::Failure { reason: "match came from a different metadata provider".to_owned(), }); @@ -1762,7 +1765,7 @@ impl AndroidEngine { if let Some(enrichment) = &outcome.enrichment { let stored = self.catalog.lock().set_enrichment( &title_key, - service.provider(), + outcome.provider, now(), enrichment, ); @@ -2363,21 +2366,13 @@ impl AndroidEngine { /// behaviour. async fn match_titles_inner(&self, force: bool) -> Result { let service = Arc::new(self.metadata_service()?); - let provider = service.provider(); let (titles, stored) = self.titles_and_metadata()?; let enriched = self .catalog .lock() .enrichment_ages() .map_err(BridgeError::from_display)?; - let pending = pstr_meta::service::plan( - titles, - &stored, - &enriched, - provider, - service.has_details(), - force, - ); + let pending = pstr_meta::service::plan(titles, &stored, &enriched, &service.chain(), force); let permits = Arc::new(tokio::sync::Semaphore::new(LOOKUP_CONCURRENCY)); let mut lookups = tokio::task::JoinSet::new(); @@ -2409,7 +2404,7 @@ impl AndroidEngine { summary.episodes += enrichment.episodes.len() as u32; let stored = self.catalog.lock().set_enrichment( &outcome.title_key, - provider, + outcome.provider, now(), enrichment, ); @@ -3685,6 +3680,7 @@ fn metadata_provider(provider: ProviderId) -> MetadataProvider { match provider { ProviderId::AniList => MetadataProvider::AniList, ProviderId::Tmdb => MetadataProvider::Tmdb, + ProviderId::TvMaze => MetadataProvider::TvMaze, } } @@ -3692,6 +3688,7 @@ fn provider_id(provider: MetadataProvider) -> ProviderId { match provider { MetadataProvider::AniList => ProviderId::AniList, MetadataProvider::Tmdb => ProviderId::Tmdb, + MetadataProvider::TvMaze => ProviderId::TvMaze, } } diff --git a/crates/pstr-app/src/engine.rs b/crates/pstr-app/src/engine.rs index 43ba7ea..37a51d5 100644 --- a/crates/pstr-app/src/engine.rs +++ b/crates/pstr-app/src/engine.rs @@ -2605,8 +2605,6 @@ impl Engine { } return self.emit(Event::Error(self.no_service_reason())); }; - let provider = service.provider(); - let (stored, enriched) = { let catalog = self.catalog.lock(); match catalog @@ -2618,14 +2616,7 @@ impl Engine { } }; - let pending = pstr_meta::service::plan( - titles, - &stored, - &enriched, - provider, - service.has_details(), - force, - ); + let pending = pstr_meta::service::plan(titles, &stored, &enriched, &service.chain(), force); if pending.is_empty() { if automatic { return; @@ -2636,7 +2627,7 @@ impl Engine { self.emit(Event::Status(format!( "matching {} titles against {}…", pending.len(), - provider.label() + service.label() ))); let (mut matched, mut unmatched, mut failed) = (0usize, 0usize, 0usize); @@ -2673,7 +2664,7 @@ impl Engine { episodes_found += enrichment.episodes.len(); let stored = self.catalog.lock().set_enrichment( &outcome.title_key, - provider, + outcome.provider, now(), enrichment, ); @@ -2755,7 +2746,7 @@ impl Engine { if let Some(enrichment) = &outcome.enrichment { let stored = engine.catalog.lock().set_enrichment( &title.key, - service.provider(), + outcome.provider, now(), enrichment, ); diff --git a/crates/pstr-app/src/ui/matcher.rs b/crates/pstr-app/src/ui/matcher.rs index 6aa5c27..6923154 100644 --- a/crates/pstr-app/src/ui/matcher.rs +++ b/crates/pstr-app/src/ui/matcher.rs @@ -55,8 +55,8 @@ pub fn show( .color(theme::text()), ); ui.label(ui::muted(format!( - "Everything {} answers with, in its own order — nothing is scored here.", - provider.label() + "Everything {} answer with, in their own order — nothing is scored here.", + pstr_meta::service::chain_label(provider) ))); ui.add_space(theme::space::L); @@ -279,8 +279,9 @@ fn poster(art: &mut Art<'_>, found: &TitleMetadata) -> Option String { let mut parts: Vec = vec![ match found.kind { @@ -298,5 +299,6 @@ fn facts(found: &TitleMetadata) -> String { if let Some(rating) = found.rating { parts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR)); } + parts.push(found.provider.label().to_string()); parts.join(" · ") } diff --git a/crates/pstr-app/src/ui/settings.rs b/crates/pstr-app/src/ui/settings.rs index e7e5ca3..e2f02d7 100644 --- a/crates/pstr-app/src/ui/settings.rs +++ b/crates/pstr-app/src/ui/settings.rs @@ -874,8 +874,8 @@ fn metadata_form( without this every tile is a pair of initials. Turning it on sends the titles \ in your library — not your files, and nothing about what you have watched — to \ the provider you choose, over HTTPS, each time a new one appears. With AniList, \ - the ids it matched — not the titles — then go to ani.zip for episode names and \ - wide artwork.", + a title it has nothing for is then sent to TVmaze, and the ids AniList matched — \ + not the titles — go to ani.zip for episode names and wide artwork.", )); ui.add_space(theme::space::L); @@ -889,7 +889,7 @@ fn metadata_form( if settings.enabled { ui.add_space(theme::space::L); ui.label(ui::muted("Provider")); - for provider in ProviderId::ALL { + for provider in ProviderId::CHOOSABLE { if ui .radio(settings.provider == provider, provider.label()) .on_hover_text(provider.description()) diff --git a/crates/pstr-cli/src/main.rs b/crates/pstr-cli/src/main.rs index 99b7dd9..076f1c7 100644 --- a/crates/pstr-cli/src/main.rs +++ b/crates/pstr-cli/src/main.rs @@ -575,12 +575,16 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> { api_key, } => { if let Some(name) = provider { - settings.provider = ProviderId::parse(&name).ok_or_else(|| { - anyhow::anyhow!( - "unknown provider {name:?}; try one of: {}", - ProviderId::ALL.map(|provider| provider.as_str()).join(", ") - ) - })?; + settings.provider = ProviderId::parse(&name) + .filter(|provider| ProviderId::CHOOSABLE.contains(provider)) + .ok_or_else(|| { + anyhow::anyhow!( + "unknown provider {name:?}; try one of: {}", + ProviderId::CHOOSABLE + .map(|provider| provider.as_str()) + .join(", ") + ) + })?; } if on { settings.enabled = true; @@ -606,10 +610,11 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> { // `Engine::set_metadata_config`. if off || settings.enabled { let catalog = Catalog::open(&dirs.catalog_db()).context("open catalog")?; + let chain = pstr_meta::service::chain_of(settings.provider); let stale = catalog .all_metadata()? .values() - .any(|record| record.provider != settings.provider); + .any(|record| !chain.contains(&record.provider)); if off || stale { catalog.clear_metadata()?; println!("cleared the stored answers"); @@ -627,7 +632,7 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> { anyhow::bail!( "lookups are off — run `pstr metadata set --on` first.\n\ Matching sends the titles in your catalog to {}.", - settings.provider.label() + pstr_meta::service::chain_label(settings.provider) ); } @@ -646,8 +651,7 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> { library.titles, &stored, &enriched, - settings.provider, - service.has_details(), + &service.chain(), force, ); if let Some(limit) = limit { @@ -661,7 +665,7 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> { println!( "looking up {} titles against {}…", pending.len(), - settings.provider.label() + service.label() ); // Serial, not concurrent: both providers rate-limit by the minute, @@ -691,12 +695,17 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> { Some(found) => { matched += 1; println!( - " {name:<40} → {} ({})", + " {name:<40} → {} ({}){}", found.name, found .year .map(|year| year.to_string()) - .unwrap_or_else(|| "-".into()) + .unwrap_or_else(|| "-".into()), + if found.provider == service.provider() { + String::new() + } else { + format!(" on {}", found.provider.label()) + } ); } None => println!(" {name:<40} → no match"), @@ -717,7 +726,7 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> { ); catalog.set_enrichment( &outcome.title_key, - settings.provider, + outcome.provider, unix_now(), enrichment, )?; diff --git a/crates/pstr-core/src/metadata.rs b/crates/pstr-core/src/metadata.rs index 48cbe77..454f9db 100644 --- a/crates/pstr-core/src/metadata.rs +++ b/crates/pstr-core/src/metadata.rs @@ -39,15 +39,23 @@ pub enum ProviderId { AniList, /// The Movie Database. Film and television, needs a free API key. Tmdb, + /// TVmaze. Television, no API key. Never chosen on its own: it answers + /// for what AniList has not got — see `pstr_meta::service`. + TvMaze, } impl ProviderId { - pub const ALL: [Self; 2] = [Self::AniList, Self::Tmdb]; + /// Every provider a stored row can name. + pub const ALL: [Self; 3] = [Self::AniList, Self::Tmdb, Self::TvMaze]; + + /// The ones a viewer picks between in the settings. + pub const CHOOSABLE: [Self; 2] = [Self::AniList, Self::Tmdb]; pub fn as_str(self) -> &'static str { match self { Self::AniList => "anilist", Self::Tmdb => "tmdb", + Self::TvMaze => "tvmaze", } } @@ -56,6 +64,7 @@ impl ProviderId { match self { Self::AniList => "AniList", Self::Tmdb => "TMDB", + Self::TvMaze => "TVmaze", } } @@ -64,9 +73,11 @@ impl ProviderId { match self { Self::AniList => { "Anime, with no API key and no account. Episode names and wide art come from \ - ani.zip, which is sent the matched ids." + ani.zip, which is sent the matched ids. Shows AniList does not have are \ + looked up on TVmaze." } Self::Tmdb => "Film and television. Needs a free API key from themoviedb.org.", + Self::TvMaze => "Television, with no API key and no account.", } } @@ -528,8 +539,13 @@ pub const MISS_TTL_SECS: i64 = 60 * 60 * 24 * 3; impl MetadataRecord { /// Whether this answer is still worth using rather than asking again. - pub fn is_fresh(&self, now: i64, provider: ProviderId) -> bool { - if self.provider != provider { + /// + /// `chain` is the providers asked, in order. A match from any of them + /// stands. A miss is stored under the last one asked, and only stands for + /// a chain that ends there: one an earlier provider stored was never + /// followed by the providers after it. + pub fn is_fresh(&self, now: i64, chain: &[ProviderId]) -> bool { + if !chain.contains(&self.provider) { return false; } // A hand-picked match does not go stale: nothing a re-ask could learn @@ -539,8 +555,10 @@ impl MetadataRecord { } let ttl = if self.metadata.is_some() { MATCH_TTL_SECS - } else { + } else if chain.last() == Some(&self.provider) { MISS_TTL_SECS + } else { + return false; }; now.saturating_sub(self.fetched_at) < ttl } @@ -786,13 +804,13 @@ mod tests { fetched_at: now - MISS_TTL_SECS - 1, manual: false, }; - assert!(!miss.is_fresh(now, ProviderId::AniList)); + assert!(!miss.is_fresh(now, &[ProviderId::AniList])); let matched = MetadataRecord { metadata: Some(metadata()), ..miss.clone() }; - assert!(matched.is_fresh(now, ProviderId::AniList)); + assert!(matched.is_fresh(now, &[ProviderId::AniList])); } /// What the viewer picked by hand outlives every TTL: it is not an answer @@ -806,9 +824,9 @@ mod tests { fetched_at: 0, manual: true, }; - assert!(record.is_fresh(MATCH_TTL_SECS * 100, ProviderId::AniList)); + assert!(record.is_fresh(MATCH_TTL_SECS * 100, &[ProviderId::AniList])); // But it is still one provider's id, and it means nothing to the other. - assert!(!record.is_fresh(MATCH_TTL_SECS * 100, ProviderId::Tmdb)); + assert!(!record.is_fresh(MATCH_TTL_SECS * 100, &[ProviderId::Tmdb])); } /// Switching provider must re-ask rather than trust the other one's answer — @@ -822,6 +840,32 @@ mod tests { fetched_at: 1_000_000_000, manual: false, }; - assert!(!record.is_fresh(1_000_000_000, ProviderId::Tmdb)); + assert!(!record.is_fresh(1_000_000_000, &[ProviderId::Tmdb])); + } + + /// AniList's miss was stored before TVmaze followed it, and so says + /// nothing about what TVmaze has; TVmaze's miss, last in the chain, does. + /// A match from either stands. + #[test] + fn a_miss_only_stands_when_the_last_provider_asked_stored_it() { + let chain = [ProviderId::AniList, ProviderId::TvMaze]; + let now = 1_000_000_000; + let miss = |provider| MetadataRecord { + title_key: "game of thrones".into(), + provider, + metadata: None, + fetched_at: now, + manual: false, + }; + assert!(!miss(ProviderId::AniList).is_fresh(now, &chain)); + assert!(miss(ProviderId::TvMaze).is_fresh(now, &chain)); + assert!(miss(ProviderId::AniList).is_fresh(now, &[ProviderId::AniList])); + + let found = |provider| MetadataRecord { + metadata: Some(metadata()), + ..miss(provider) + }; + assert!(found(ProviderId::AniList).is_fresh(now, &chain)); + assert!(found(ProviderId::TvMaze).is_fresh(now, &chain)); } } diff --git a/crates/pstr-meta/src/anilist.rs b/crates/pstr-meta/src/anilist.rs index 15f224d..76e1ce6 100644 --- a/crates/pstr-meta/src/anilist.rs +++ b/crates/pstr-meta/src/anilist.rs @@ -812,7 +812,7 @@ fn kind_of(format: Option<&str>) -> TitleKind { /// AniList descriptions carry a little HTML even with `asHtml: false` — `
` /// mostly, and the occasional ``. egui draws no markup, so tags become /// nothing and `
` becomes the line break it stands for. -fn strip_markup(text: &str) -> String { +pub(crate) fn strip_markup(text: &str) -> String { let text = text .replace("
", "\n") .replace("
", "\n") diff --git a/crates/pstr-meta/src/lib.rs b/crates/pstr-meta/src/lib.rs index 5f4fc0e..1bd7fe3 100644 --- a/crates/pstr-meta/src/lib.rs +++ b/crates/pstr-meta/src/lib.rs @@ -8,10 +8,10 @@ //! //! ```text //! Title ──▶ Query ──▶ Provider::search ──▶ [Candidate] ──▶ matching::best -//! (anilist | tmdb) │ +//! (anilist, then tvmaze | tmdb) │ //! ▼ //! Enrichment ◀── Provider::enrich ◀──────────────────── TitleMetadata -//! (episodes, fanart) (anilist relations + ani.zip | tmdb) +//! (episodes, fanart) (anilist relations + ani.zip | tvmaze | tmdb) //! ``` //! //! Three things are worth knowing before touching any of it: @@ -37,6 +37,7 @@ pub mod provider; pub mod service; pub mod settings; pub mod tmdb; +pub mod tvmaze; pub use error::{Error, Result}; pub use matching::{Candidate, Query}; diff --git a/crates/pstr-meta/src/service.rs b/crates/pstr-meta/src/service.rs index ffbdbdc..a8e0c96 100644 --- a/crates/pstr-meta/src/service.rs +++ b/crates/pstr-meta/src/service.rs @@ -5,8 +5,17 @@ //! │ miss / stale //! ▼ //! provider.search ──▶ matching::best ──▶ store ──▶ MetadataRecord +//! │ no match +//! ▼ +//! next provider in the chain, the same way //! ``` //! +//! The providers are asked in a chain: AniList, then TVmaze for what AniList +//! has not got. The first match wins and is stored under the provider that +//! gave it, so a library of anime with one live-action show in it is matched +//! against both without anyone choosing per title — and everything after a +//! match is asked of that match's own provider. +//! //! Two rules govern what gets written back, and they are the difference between //! a library that settles down and one that hammers a third party forever: //! @@ -34,6 +43,7 @@ use crate::error::{Error, Result}; use crate::matching::{self, Query}; use crate::provider::Provider; use crate::tmdb::Tmdb; +use crate::tvmaze::TvMaze; /// How long to wait on a provider. /// @@ -44,10 +54,11 @@ const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); /// Identifies this client to the providers, which both ask for it. const USER_AGENT: &str = concat!("proton-stream/", env!("CARGO_PKG_VERSION")); -/// The configured provider, resolved. +/// One provider in the chain, resolved. enum Source { AniList(AniList), Tmdb(Tmdb), + TvMaze(TvMaze), } impl Source { @@ -55,6 +66,7 @@ impl Source { match self { Self::AniList(provider) => provider.id(), Self::Tmdb(provider) => provider.id(), + Self::TvMaze(provider) => provider.id(), } } @@ -62,78 +74,137 @@ impl Source { match self { Self::AniList(provider) => provider.search(query).await, Self::Tmdb(provider) => provider.search(query).await, + Self::TvMaze(provider) => provider.search(query).await, } } /// The entry a stored match names, fetched again by its id. `None` from a - /// provider that has no details to add. + /// provider that has no details to add — see [`gives_details`]. async fn entry(&self, id: &str) -> Result> { match self { Self::AniList(provider) => provider.entry(id).await, + Self::TvMaze(provider) => provider.entry(id).await, Self::Tmdb(_) => Ok(None), } } - /// Whether [`Source::entry`] has anything to give. - fn has_details(&self) -> bool { - matches!(self, Self::AniList(_)) - } - async fn enrich(&self, title: &Title, found: &TitleMetadata) -> Result { match self { Self::AniList(provider) => provider.enrich(title, found).await, Self::Tmdb(provider) => provider.enrich(title, found).await, + Self::TvMaze(provider) => provider.enrich(title, found).await, } } } -/// Metadata lookups, against one provider. +/// The providers asked when the viewer chose `chosen`, in order. +/// +/// AniList is followed by TVmaze, which has the television AniList does not +/// and needs no key either. TMDB has that television itself, so it is asked +/// alone. A stored answer from a provider outside the chain is the previous +/// choice's, and is re-asked. +pub fn chain_of(chosen: ProviderId) -> &'static [ProviderId] { + match chosen { + ProviderId::AniList => &[ProviderId::AniList, ProviderId::TvMaze], + ProviderId::Tmdb => &[ProviderId::Tmdb], + ProviderId::TvMaze => &[ProviderId::TvMaze], + } +} + +/// [`chain_of`] `chosen`, for a sentence: `AniList and TVmaze`. +pub fn chain_label(chosen: ProviderId) -> String { + chain_of(chosen) + .iter() + .map(|provider| provider.label()) + .collect::>() + .join(" and ") +} + +/// Whether `provider` fills in [`TitleMetadata::details`], and so whether a +/// match it gave without them, or with an older revision of them, is worth +/// fetching again. +fn gives_details(provider: ProviderId) -> bool { + matches!(provider, ProviderId::AniList | ProviderId::TvMaze) +} + +/// Metadata lookups, against a chain of providers. /// /// Cheap to clone — the HTTP client inside is a connection pool that wants /// sharing, not duplicating. #[derive(Clone)] pub struct MetadataService { - source: Arc, + /// In the order they are asked. Never empty. + sources: Arc<[Source]>, http: reqwest::Client, } impl MetadataService { - /// Build a service for `config`. + /// Build a service for `config`, asking [`chain_of`] its provider. /// /// `api_key` is only consulted for a provider that needs one; for AniList it /// is ignored, and passing `None` there is not an error. - pub fn new(config: &MetadataConfig, api_key: Option) -> Result { + pub fn new(config: &MetadataConfig, mut api_key: Option) -> Result { let http = reqwest::Client::builder() .user_agent(USER_AGENT) .timeout(TIMEOUT) .build() .map_err(Error::Network)?; - let source = match config.provider { - ProviderId::AniList => { - Source::AniList(AniList::new(http.clone(), config.language.clone())) - } - ProviderId::Tmdb => Source::Tmdb(Tmdb::new( - http.clone(), - api_key.ok_or(Error::MissingApiKey(ProviderId::Tmdb))?, - config.language.clone(), - )), - }; + let sources = chain_of(config.provider) + .iter() + .map(|provider| { + Ok(match provider { + ProviderId::AniList => { + Source::AniList(AniList::new(http.clone(), config.language.clone())) + } + ProviderId::Tmdb => Source::Tmdb(Tmdb::new( + http.clone(), + api_key + .take() + .ok_or(Error::MissingApiKey(ProviderId::Tmdb))?, + config.language.clone(), + )), + ProviderId::TvMaze => Source::TvMaze(TvMaze::new(http.clone())), + }) + }) + .collect::>>()?; Ok(Self { - source: Arc::new(source), + sources: sources.into(), http, }) } + /// The provider the viewer chose: the first one asked. pub fn provider(&self) -> ProviderId { - self.source.id() + self.sources[0].id() } - /// Ask the provider about one title. + /// Every provider asked, in order. What [`plan`] and + /// [`MetadataRecord::is_fresh`] judge a stored answer against. + pub fn chain(&self) -> Vec { + self.sources.iter().map(Source::id).collect() + } + + /// The chain, for a status line: `AniList and TVmaze`. + pub fn label(&self) -> String { + chain_label(self.provider()) + } + + fn source(&self, provider: ProviderId) -> Result<&Source> { + self.sources + .iter() + .find(|source| source.id() == provider) + .ok_or_else(|| Error::Config(format!("{} is not in use", provider.label()))) + } + + /// Ask the chain about one title, stopping at the first match. /// - /// `Ok(None)` is a real answer — nothing matched well enough — and the - /// caller should store it. An `Err` is not, and the caller should not. + /// `Ok(None)` is a real answer — nothing matched well enough anywhere — + /// and the caller should store it. An `Err` is not, and the caller should + /// not: a provider that could not be asked has not said it lacks the + /// title, and settling for the next one's answer would store a guess in + /// its place. pub async fn lookup(&self, title: &Title) -> Result> { let mut query = Query::new(title.name.clone(), title.year, title.kind); // Nothing in the files numbered itself, so the kind came from counting @@ -141,34 +212,48 @@ impl MetadataService { if !title.states_its_numbering() { query = query.with_guessed_kind(); } - let candidates = self.source.search(&query).await?; - let found = candidates.len(); - let best = matching::best(&query, candidates); - - tracing::debug!( - "{}: {found} candidates from {}, {}", - title.name, - self.provider().label(), - match &best { - Some(found) => format!("matched {:?}", found.name), - None => "no match".to_string(), + for source in self.sources.iter() { + let candidates = source.search(&query).await?; + let found = candidates.len(); + let best = matching::best(&query, candidates); + tracing::debug!( + "{}: {found} candidates from {}, {}", + title.name, + source.id().label(), + match &best { + Some(found) => format!("matched {:?}", found.name), + None => "no match".to_string(), + } + ); + if best.is_some() { + return Ok(best); } - ); - Ok(best) + } + Ok(None) } /// The same lookup, as a record ready to store — misses included. + /// + /// A match is stored under the provider that gave it; a miss under the + /// last one asked, which is what makes it stand — see + /// [`MetadataRecord::is_fresh`]. pub async fn record(&self, title: &Title) -> Result { let metadata = self.lookup(title).await?; Ok(MetadataRecord { title_key: title_key(&title.name), - provider: self.provider(), + provider: metadata + .as_ref() + .map_or_else(|| self.last(), |found| found.provider), metadata, fetched_at: now(), manual: false, }) } + fn last(&self) -> ProviderId { + self.sources[self.sources.len() - 1].id() + } + /// Everything the provider thinks `name` might be, unscored and in its own /// order. /// @@ -183,39 +268,57 @@ impl MetadataService { /// Takes a kind because the providers key their search on it, and no year: /// the point of a hand search is that the library's own guesses are what /// went wrong. + /// + /// Every provider in the chain is asked, the first one's answers first, + /// and each answer says which provider gave it. One that fails leaves its + /// answers out; only when all of them fail is that an error. pub async fn search(&self, name: &str, kind: TitleKind) -> Result> { let query = Query::new(name.trim().to_string(), None, kind).with_guessed_kind(); if query.name.is_empty() { return Ok(Vec::new()); } - let candidates = self.source.search(&query).await?; - tracing::debug!( - "hand search {:?}: {} candidates from {}", - query.name, - candidates.len(), - self.provider().label() - ); - Ok(candidates - .into_iter() - .map(|candidate| candidate.metadata) - .collect()) + let mut options = Vec::new(); + let mut failure = None; + for source in self.sources.iter() { + match source.search(&query).await { + Ok(candidates) => { + tracing::debug!( + "hand search {:?}: {} candidates from {}", + query.name, + candidates.len(), + source.id().label() + ); + options.extend(candidates.into_iter().map(|candidate| candidate.metadata)); + } + Err(error) => { + tracing::warn!("hand search on {}: {error}", source.id().label()); + failure.get_or_insert(error); + } + } + } + match failure { + Some(error) if options.is_empty() => Err(error), + _ => Ok(options), + } } - /// A record for an entry the viewer picked themselves. + /// A record for an entry the viewer picked themselves, under the provider + /// it came from. /// /// Marked [`MetadataRecord::manual`], which is what keeps the next match run /// — including a forced one — from undoing it. pub fn chosen(&self, title_key: String, found: TitleMetadata) -> MetadataRecord { MetadataRecord { title_key, - provider: self.provider(), + provider: found.provider, metadata: Some(found), fetched_at: now(), manual: true, } } - /// The episodes of a matched title, and any art its match lacked. + /// The episodes of a matched title, and any art its match lacked, from + /// the provider that matched it. /// /// Only ever called for a title that already has a match, because the /// provider's own id is what it takes — there is no second search here, and @@ -225,12 +328,12 @@ impl MetadataService { /// un-enriched and the next match run asks again. An empty answer is an /// answer, and is — see `Catalog::set_enrichment`. pub async fn enrich(&self, title: &Title, found: &TitleMetadata) -> Result { - let enrichment = self.source.enrich(title, found).await?; + let enrichment = self.source(found.provider)?.enrich(title, found).await?; tracing::debug!( "{}: {} episodes from {}{}", title.name, enrichment.episodes.len(), - self.provider().label(), + found.provider.label(), if enrichment.backdrop_url.is_some() { ", with a backdrop" } else { @@ -285,6 +388,9 @@ impl Work { #[derive(Debug)] pub struct Outcome { pub title_key: String, + /// The provider the title is matched to, and so the one + /// [`Outcome::enrichment`] is stored under. + pub provider: ProviderId, /// The new answer, for [`Work::Match`] and [`Work::Refresh`]. Store it — /// misses included. pub record: Option, @@ -308,22 +414,21 @@ pub struct Outcome { /// the viewer already fixed by hand is the opposite of what they asked for. It /// is still enriched and refreshed. /// -/// `details` is whether the provider has [`TitleMetadata::details`] to give -/// — see [`MetadataService::has_details`]. +/// `chain` is [`MetadataService::chain`]. Enriching and refreshing go to the +/// provider each title is matched to. pub fn plan( titles: Vec, stored: &HashMap<String, MetadataRecord>, enriched: &HashMap<String, (ProviderId, i64)>, - provider: ProviderId, - details: bool, + chain: &[ProviderId], force: bool, ) -> Vec<Work> { let now = now(); let mut work = Vec::new(); for title in titles { let record = stored.get(&title.key); - let pinned = record.is_some_and(|record| record.manual && record.provider == provider); - if !pinned && (force || !is_usable(record, provider)) { + let pinned = record.is_some_and(|record| record.manual && chain.contains(&record.provider)); + if !pinned && (force || !is_usable(record, chain)) { work.push(Work::Match(title)); continue; } @@ -331,9 +436,9 @@ pub fn plan( continue; }; let fresh = enriched.get(&title.key).is_some_and(|(asked, at)| { - *asked == provider && now.saturating_sub(*at) < MATCH_TTL_SECS + *asked == record.provider && now.saturating_sub(*at) < MATCH_TTL_SECS }); - let stale_details = details + let stale_details = gives_details(record.provider) && record .metadata .as_ref() @@ -352,12 +457,6 @@ pub fn plan( } impl MetadataService { - /// Whether this provider fills in [`TitleMetadata::details`], and so - /// whether a match stored without them is worth fetching again. - pub fn has_details(&self) -> bool { - self.source.has_details() - } - /// Do one piece of [`Work`]. /// /// A failed search is an `Err` and nothing is stored. A failed enrichment @@ -375,12 +474,14 @@ impl MetadataService { }; Ok(Outcome { title_key: record.title_key.clone(), + provider: record.provider, record: Some(record), enrichment, }) } Work::Enrich(title, found) => Ok(Outcome { title_key: title.key.clone(), + provider: found.provider, record: None, enrichment: Some(self.enrich(title, found).await?), }), @@ -392,6 +493,7 @@ impl MetadataService { let Some(stored) = &record.metadata else { return Ok(Outcome { title_key: title.key.clone(), + provider: record.provider, record: None, enrichment: None, }); @@ -399,7 +501,7 @@ impl MetadataService { // An id the provider no longer has keeps what was stored, its // details marked current so it is not asked about on every run. let found = self - .source + .source(stored.provider)? .entry(&stored.remote_id) .await? .unwrap_or_else(|| TitleMetadata { @@ -415,6 +517,7 @@ impl MetadataService { }; Ok(Outcome { title_key: title.key.clone(), + provider: record.provider, record: Some(MetadataRecord { metadata: Some(found), fetched_at: now(), @@ -437,6 +540,7 @@ impl MetadataService { let enrichment = self.enrich_or_log(title, &found).await; Outcome { title_key: title.key.clone(), + provider: found.provider, record: Some(self.chosen(title.key.clone(), found)), enrichment, } @@ -450,9 +554,9 @@ impl MetadataService { } } -/// Whether a stored answer can be used as-is. -pub fn is_usable(record: Option<&MetadataRecord>, provider: ProviderId) -> bool { - record.is_some_and(|record| record.is_fresh(now(), provider)) +/// Whether a stored answer can be used as-is by `chain`. +pub fn is_usable(record: Option<&MetadataRecord>, chain: &[ProviderId]) -> bool { + record.is_some_and(|record| record.is_fresh(now(), chain)) } fn now() -> i64 { @@ -516,13 +620,29 @@ mod tests { genres: Vec::new(), episodes: None, url: None, - details: None, + details: Some(TitleDetails { + revision: TitleDetails::REVISION, + ..TitleDetails::default() + }), }), fetched_at: now(), manual, } } + /// [`matched`], stored before there were details. + fn without_details(key: &str, manual: bool, provider: ProviderId) -> MetadataRecord { + let mut record = matched(key, manual); + record.provider = provider; + if let Some(found) = record.metadata.as_mut() { + found.provider = provider; + found.details = None; + } + record + } + + const ANILIST: &[ProviderId] = &[ProviderId::AniList, ProviderId::TvMaze]; + fn kinds(work: &[Work]) -> Vec<(&str, &str)> { work.iter() .map(|work| match work { @@ -551,14 +671,7 @@ mod tests { ]); let titles = ["enriched", "stale", "bare", "new"].map(title).to_vec(); - let work = plan( - titles, - &stored, - &enriched, - ProviderId::AniList, - false, - false, - ); + let work = plan(titles, &stored, &enriched, ANILIST, false); assert_eq!( kinds(&work), vec![("stale", "enrich"), ("bare", "enrich"), ("new", "match")] @@ -575,14 +688,7 @@ mod tests { ]); let titles = ["pinned", "automatic"].map(title).to_vec(); - let work = plan( - titles, - &stored, - &HashMap::new(), - ProviderId::AniList, - false, - true, - ); + let work = plan(titles, &stored, &HashMap::new(), ANILIST, true); assert_eq!( kinds(&work), vec![("pinned", "enrich"), ("automatic", "match")] @@ -590,23 +696,30 @@ mod tests { } /// A match stored before details existed is fetched again by id — a - /// pinned one included, and without being searched for — and only by a + /// pinned one included, and without being searched for — and only from a /// provider that has details to give. #[test] fn a_match_without_details_is_refreshed_rather_than_searched_for() { - let stored = HashMap::from([ - ("pinned".to_string(), matched("pinned", true)), - ("automatic".to_string(), matched("automatic", false)), - ]); - let enriched = HashMap::from([("pinned".to_string(), (ProviderId::AniList, now()))]); + let records = |provider| { + HashMap::from([ + ( + "pinned".to_string(), + without_details("pinned", true, provider), + ), + ( + "automatic".to_string(), + without_details("automatic", false, provider), + ), + ]) + }; + let enriched = |provider| HashMap::from([("pinned".to_string(), (provider, now()))]); let titles = ["pinned", "automatic"].map(title).to_vec(); let work = plan( titles.clone(), - &stored, - &enriched, - ProviderId::AniList, - true, + &records(ProviderId::AniList), + &enriched(ProviderId::AniList), + ANILIST, false, ); assert_eq!( @@ -618,15 +731,52 @@ mod tests { let work = plan( titles, - &stored, - &enriched, - ProviderId::AniList, - false, + &records(ProviderId::Tmdb), + &enriched(ProviderId::Tmdb), + &[ProviderId::Tmdb], false, ); assert_eq!(kinds(&work), vec![("automatic", "enrich")]); } + /// A title AniList had nothing for is asked of TVmaze at once, rather than + /// waiting out AniList's stored miss; a match from TVmaze is enriched by + /// TVmaze, and a hand-picked one stays pinned. + #[test] + fn titles_are_planned_against_the_provider_each_is_matched_to() { + let miss = MetadataRecord { + metadata: None, + ..matched("anilist miss", false) + }; + let tvmaze = |key: &str, manual| { + let mut record = matched(key, manual); + record.provider = ProviderId::TvMaze; + if let Some(found) = record.metadata.as_mut() { + found.provider = ProviderId::TvMaze; + } + record + }; + let stored = HashMap::from([ + ("anilist miss".to_string(), miss), + ("on tvmaze".to_string(), tvmaze("on tvmaze", false)), + ("pinned".to_string(), tvmaze("pinned", true)), + ]); + let enriched = HashMap::from([("pinned".to_string(), (ProviderId::TvMaze, now()))]); + let titles = ["anilist miss", "on tvmaze", "pinned"].map(title).to_vec(); + + let work = plan(titles.clone(), &stored, &enriched, ANILIST, false); + assert_eq!( + kinds(&work), + vec![("anilist miss", "match"), ("on tvmaze", "enrich")] + ); + + let work = plan(titles, &stored, &enriched, ANILIST, true); + assert_eq!( + kinds(&work), + vec![("anilist miss", "match"), ("on tvmaze", "match")] + ); + } + /// Details from an older build are refetched by id, and kept meanwhile; /// details this build wrote are not asked for again. #[test] @@ -644,14 +794,7 @@ mod tests { let enriched = HashMap::from([("k".to_string(), (ProviderId::AniList, now()))]); let run = |record: MetadataRecord| { let stored = HashMap::from([("k".to_string(), record)]); - let work = plan( - vec![title("k")], - &stored, - &enriched, - ProviderId::AniList, - true, - false, - ); + let work = plan(vec![title("k")], &stored, &enriched, ANILIST, false); kinds(&work) .into_iter() .map(|(_, kind)| kind.to_string()) @@ -661,8 +804,9 @@ mod tests { assert!(run(with(TitleDetails::REVISION)).is_empty()); } - /// A record from the other provider is never usable — switching provider has - /// to re-ask, including about titles the previous one found nothing for. + /// A record from a provider outside the chain is never usable — switching + /// provider has to re-ask, including about titles the previous one found + /// nothing for. #[test] fn a_stored_answer_is_only_usable_for_the_provider_that_gave_it() { let record = MetadataRecord { @@ -672,8 +816,29 @@ mod tests { fetched_at: now(), manual: false, }; - assert!(is_usable(Some(&record), ProviderId::AniList)); - assert!(!is_usable(Some(&record), ProviderId::Tmdb)); - assert!(!is_usable(None, ProviderId::AniList)); + assert!(is_usable(Some(&record), &[ProviderId::AniList])); + assert!(!is_usable(Some(&record), &[ProviderId::Tmdb])); + assert!(!is_usable(None, &[ProviderId::AniList])); + } + + /// AniList is followed by TVmaze, which needs no key either; TMDB, which + /// has television of its own, is asked alone. + #[test] + fn anilist_is_followed_by_tvmaze_and_tmdb_stands_alone() { + let config = MetadataConfig { + enabled: true, + ..MetadataConfig::default() + }; + let service = MetadataService::new(&config, None).expect("service"); + assert_eq!(service.chain(), ANILIST); + assert_eq!(service.provider(), ProviderId::AniList); + assert_eq!(service.label(), "AniList and TVmaze"); + + let tmdb = MetadataConfig { + provider: ProviderId::Tmdb, + ..config + }; + let service = MetadataService::new(&tmdb, Some("key".into())).expect("service"); + assert_eq!(service.chain(), vec![ProviderId::Tmdb]); } } diff --git a/crates/pstr-meta/src/tvmaze.rs b/crates/pstr-meta/src/tvmaze.rs new file mode 100644 index 0000000..42bc5e1 --- /dev/null +++ b/crates/pstr-meta/src/tvmaze.rs @@ -0,0 +1,446 @@ +//! TVmaze, over its public REST API. +//! +//! Television AniList does not have — which is everything that is not anime — +//! with no API key and no account. It is asked only about what AniList found +//! nothing for (see [`crate::service`]), so a library of anime sends it +//! nothing, and a library with one live-action show in it sends it that one +//! name. +//! +//! What it does not have is film: TVmaze indexes shows and nothing else, so a +//! title the library knows to be a film is not asked about at all. A show's +//! name is also a film's often enough that asking would mostly find the wrong +//! thing. +//! +//! Its art is good where it matters here: a 16:9 still for most episodes and a +//! full-HD background for most shows. Its "typography" image would be a logo +//! but is a JPEG on a solid ground, so there is no logo from here. + +use pstr_core::library::{Title, TitleKind}; +use pstr_core::metadata::{Enrichment, EpisodeMetadata, ProviderId, TitleDetails, TitleMetadata}; +use serde::Deserialize; + +use crate::anilist::strip_markup; +use crate::error::{Error, Result}; +use crate::limiter::RateLimiter; +use crate::matching::{Candidate, Query}; +use crate::provider::Provider; + +const API_BASE: &str = "https://api.tvmaze.com"; + +/// TVmaze allows at least twenty calls every ten seconds per address. A little +/// under it, because the limit is counted on its side and a burst that lands +/// at the edge of its window gets a 429 for nothing. +const REQUESTS_PER_MINUTE: u32 = 100; + +/// As [`crate::anilist`]'s. +const RETRIES: usize = 2; + +/// The background width worth asking for. Shows often carry a 4K one beside +/// it, which is four times the bytes for a band no window draws that wide. +const BACKDROP_WIDTH: u32 = 1920; + +pub struct TvMaze { + http: reqwest::Client, + limiter: RateLimiter, +} + +impl TvMaze { + pub fn new(http: reqwest::Client) -> Self { + Self { + http, + limiter: RateLimiter::per_minute(REQUESTS_PER_MINUTE), + } + } +} + +impl Provider for TvMaze { + fn id(&self) -> ProviderId { + ProviderId::TvMaze + } + + async fn search(&self, query: &Query) -> Result<Vec<Candidate>> { + if !asks_about(query) { + return Ok(Vec::new()); + } + let hits: Vec<SearchHit> = self + .get("/search/shows", &[("q", query.name.as_str())]) + .await? + .unwrap_or_default(); + Ok(hits + .into_iter() + .map(|hit| hit.show.into_candidate()) + .collect()) + } + + /// Every episode and the show's background, in one request. + async fn enrich(&self, _title: &Title, found: &TitleMetadata) -> Result<Enrichment> { + let show: Option<Show> = self + .get( + &format!("/shows/{}", found.remote_id), + &[("embed[]", "episodes"), ("embed[]", "images")], + ) + .await?; + Ok(show.map(Show::into_enrichment).unwrap_or_default()) + } +} + +impl TvMaze { + /// The show the TVmaze id `id` names, as search would have found it. + /// `None` for an id TVmaze no longer has. + pub async fn entry(&self, id: &str) -> Result<Option<TitleMetadata>> { + let show: Option<Show> = self.get(&format!("/shows/{id}"), &[]).await?; + Ok(show.map(|show| show.into_candidate().metadata)) + } + + /// One GET, paced. `None` for a 404, which TVmaze answers for an id it + /// does not have. + async fn get<T: serde::de::DeserializeOwned>( + &self, + path: &str, + query: &[(&str, &str)], + ) -> Result<Option<T>> { + let mut attempts = 0usize; + let response = loop { + self.limiter.acquire().await; + let response = self + .http + .get(format!("{API_BASE}{path}")) + .query(query) + .send() + .await?; + let status = response.status(); + + if status.as_u16() == 429 { + let waited = self.limiter.throttled(response.headers()).await; + attempts += 1; + if attempts > RETRIES { + return Err(Error::RateLimited); + } + tracing::debug!("TVmaze is throttling; waiting {waited:?}"); + continue; + } + if status.as_u16() == 404 { + return Ok(None); + } + if !status.is_success() { + return Err(Error::Http(format!("TVmaze answered {status}"))); + } + break response; + }; + Ok(Some(response.json().await?)) + } +} + +/// Whether TVmaze could have `query` at all: not when the library knows it is +/// a film. +fn asks_about(query: &Query) -> bool { + !(query.kind_known && query.kind == TitleKind::Film) +} + +#[derive(Deserialize)] +struct SearchHit { + show: Show, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Show { + id: i64, + name: String, + url: Option<String>, + #[serde(default)] + genres: Vec<String>, + status: Option<String>, + runtime: Option<u32>, + average_runtime: Option<u32>, + premiered: Option<String>, + rating: Option<Rating>, + network: Option<Channel>, + web_channel: Option<Channel>, + image: Option<Image>, + summary: Option<String>, + #[serde(rename = "_embedded")] + embedded: Option<Embedded>, +} + +#[derive(Deserialize)] +struct Rating { + average: Option<f32>, +} + +#[derive(Deserialize)] +struct Channel { + name: String, +} + +#[derive(Deserialize)] +struct Image { + medium: Option<String>, + original: Option<String>, +} + +#[derive(Deserialize, Default)] +struct Embedded { + #[serde(default)] + episodes: Vec<Episode>, + #[serde(default)] + images: Vec<Picture>, +} + +#[derive(Deserialize)] +struct Episode { + name: Option<String>, + season: Option<u32>, + /// `null` on a special, which is numbered nowhere a file name would say. + number: Option<u32>, + airdate: Option<String>, + runtime: Option<u32>, + image: Option<Image>, + summary: Option<String>, +} + +#[derive(Deserialize)] +struct Picture { + #[serde(rename = "type")] + kind: Option<String>, + #[serde(default)] + main: bool, + resolutions: Resolutions, +} + +#[derive(Deserialize)] +struct Resolutions { + original: Option<Resolution>, +} + +#[derive(Deserialize)] +struct Resolution { + url: String, + width: Option<u32>, +} + +impl Show { + fn into_candidate(self) -> Candidate { + let status = match self.status.as_deref() { + Some("Running") => Some("RELEASING"), + Some("Ended") => Some("FINISHED"), + Some("To Be Determined") => Some("HIATUS"), + Some("In Development") => Some("NOT_YET_RELEASED"), + _ => None, + }; + let channel = self.network.or(self.web_channel); + let details = TitleDetails { + start_date: self.premiered.clone().filter(|date| !date.is_empty()), + // Not a studio, but what the facts line names in its place: who + // made it, as a viewer would put it. + studios: channel.map(|channel| channel.name).into_iter().collect(), + status: status.map(str::to_string), + runtime: self + .runtime + .or(self.average_runtime) + .filter(|minutes| *minutes > 0), + revision: TitleDetails::REVISION, + ..TitleDetails::default() + }; + + Candidate { + aliases: vec![self.name.clone()], + metadata: TitleMetadata { + provider: ProviderId::TvMaze, + remote_id: self.id.to_string(), + year: self + .premiered + .as_deref() + .and_then(|date| date.get(..4)?.parse().ok()), + name: self.name, + original_name: None, + overview: self.summary.as_deref().and_then(overview), + kind: TitleKind::Series, + poster_url: self.image.and_then(|image| image.original.or(image.medium)), + backdrop_url: None, + logo_url: None, + rating: self + .rating + .and_then(|rating| rating.average) + .filter(|rating| *rating > 0.0), + genres: self.genres, + episodes: None, + url: self.url, + details: Some(details), + }, + // Always: TVmaze has nothing but shows. + kind_known: true, + } + } + + fn into_enrichment(self) -> Enrichment { + let embedded = self.embedded.unwrap_or_default(); + let episodes = embedded + .episodes + .into_iter() + .filter_map(|episode| { + Some(EpisodeMetadata { + season: episode.season, + number: episode.number?, + absolute: None, + entry_season: None, + entry_number: None, + name: episode.name.filter(|name| !name.is_empty()), + overview: episode.summary.as_deref().and_then(overview), + // The row draws a still about this wide; the original is + // a full-HD frame per episode. + still_url: episode + .image + .and_then(|image| image.medium.or(image.original)), + air_date: episode.airdate.filter(|date| !date.is_empty()), + runtime: episode.runtime.filter(|minutes| *minutes > 0), + }) + }) + .collect(); + Enrichment { + episodes, + backdrop_url: backdrop(&embedded.images), + logo_url: None, + chain: Vec::new(), + } + } +} + +/// The background to draw: the show's main one if it marked one, else the one +/// nearest [`BACKDROP_WIDTH`]. +fn backdrop(images: &[Picture]) -> Option<String> { + images + .iter() + .filter(|picture| picture.kind.as_deref() == Some("background")) + .filter_map(|picture| Some((picture, picture.resolutions.original.as_ref()?))) + .min_by_key(|(picture, resolution)| { + ( + !picture.main, + resolution.width.unwrap_or(0).abs_diff(BACKDROP_WIDTH), + ) + }) + .map(|(_, resolution)| resolution.url.clone()) +} + +/// TVmaze's summaries are HTML paragraphs. Each one keeps its own line. +fn overview(html: &str) -> Option<String> { + let text = strip_markup(&html.replace("</p>", "</p>\n")); + (!text.is_empty()).then_some(text) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SHOW: &str = r#"{ + "id": 82, "url": "https://www.tvmaze.com/shows/82/game-of-thrones", + "name": "Game of Thrones", "type": "Scripted", + "genres": ["Drama", "Adventure", "Fantasy"], "status": "Ended", + "runtime": 60, "averageRuntime": 61, "premiered": "2011-04-17", + "rating": {"average": 8.9}, "network": {"id": 8, "name": "HBO"}, + "webChannel": null, + "image": {"medium": "https://static.tvmaze.com/m/1.jpg", + "original": "https://static.tvmaze.com/o/1.jpg"}, + "summary": "<p>Based on the novels.</p><p>Seven noble families fight.</p>", + "_embedded": { + "episodes": [ + {"name": "Winter is Coming", "season": 1, "number": 1, + "type": "regular", "airdate": "2011-04-17", "runtime": 60, + "image": {"medium": "https://static.tvmaze.com/m/e1.jpg", + "original": "https://static.tvmaze.com/o/e1.jpg"}, + "summary": "<p>Lord Stark is troubled.</p>"}, + {"name": "The Kingsroad", "season": 1, "number": 2, + "type": "regular", "airdate": "2011-04-24", "runtime": null, + "image": null, "summary": null}, + {"name": "Inside the Making", "season": 1, "number": null, + "type": "significant_special", "airdate": "2011-04-10"} + ], + "images": [ + {"type": "poster", "main": true, "resolutions": + {"original": {"url": "poster", "width": 2030}}}, + {"type": "background", "main": false, "resolutions": + {"original": {"url": "uhd", "width": 3840}}}, + {"type": "background", "main": false, "resolutions": + {"original": {"url": "hd", "width": 1920}}}, + {"type": "typography", "main": false, "resolutions": + {"original": {"url": "lettering", "width": 840}}} + ] + } + }"#; + + #[test] + fn a_show_becomes_a_series_with_its_details() { + let show: Show = serde_json::from_str(SHOW).expect("parse"); + let found = show.into_candidate().metadata; + assert_eq!(found.provider, ProviderId::TvMaze); + assert_eq!(found.remote_id, "82"); + assert_eq!(found.kind, TitleKind::Series); + assert_eq!(found.year, Some(2011)); + assert_eq!(found.rating, Some(8.9)); + assert_eq!( + found.poster_url.as_deref(), + Some("https://static.tvmaze.com/o/1.jpg") + ); + assert_eq!( + found.overview.as_deref(), + Some("Based on the novels.\nSeven noble families fight.") + ); + + let details = found.details.expect("details"); + assert!(details.is_current()); + assert_eq!(details.studios, vec!["HBO"]); + assert_eq!(details.status.as_deref(), Some("FINISHED")); + assert_eq!(details.runtime, Some(60)); + assert_eq!(details.start_date.as_deref(), Some("2011-04-17")); + } + + /// Specials are numbered nowhere a file would be, so they are left out + /// rather than colliding with the episode they share a season with. + #[test] + fn enrichment_keeps_numbered_episodes_and_the_full_hd_background() { + let show: Show = serde_json::from_str(SHOW).expect("parse"); + let enrichment = show.into_enrichment(); + assert_eq!(enrichment.episodes.len(), 2); + + let first = &enrichment.episodes[0]; + assert_eq!((first.season, first.number), (Some(1), 1)); + assert_eq!(first.name.as_deref(), Some("Winter is Coming")); + assert_eq!(first.overview.as_deref(), Some("Lord Stark is troubled.")); + assert_eq!( + first.still_url.as_deref(), + Some("https://static.tvmaze.com/m/e1.jpg") + ); + assert_eq!(first.runtime, Some(60)); + assert_eq!(enrichment.episodes[1].runtime, None); + + assert_eq!(enrichment.backdrop_url.as_deref(), Some("hd")); + assert_eq!(enrichment.logo_url, None); + } + + #[test] + fn a_background_the_show_marked_main_wins_over_a_better_sized_one() { + let images: Vec<Picture> = serde_json::from_str( + r#"[{"type": "background", "main": false, "resolutions": + {"original": {"url": "hd", "width": 1920}}}, + {"type": "background", "main": true, "resolutions": + {"original": {"url": "chosen", "width": 1280}}}]"#, + ) + .expect("parse"); + assert_eq!(backdrop(&images).as_deref(), Some("chosen")); + assert_eq!(backdrop(&[]), None); + } + + /// TVmaze has no films, and a show sharing a film's name is the wrong + /// answer far more often than it is an adaptation. + #[test] + fn a_known_film_is_not_asked_about_and_a_guessed_one_is() { + let film = Query::new("Paprika", None, TitleKind::Film); + assert!(!asks_about(&film)); + assert!(asks_about(&film.with_guessed_kind())); + assert!(asks_about(&Query::new( + "Game of Thrones", + None, + TitleKind::Series + ))); + } +} diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index ec3e28a..f364043 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -10,7 +10,7 @@ | `pstr-stream` — block cache, read-ahead | **done, live-verified** — 68 offline tests; benchmarked against a real 761 MiB episode (see `docs/TESTING.md`) | | `pstr-player` — libmpv `stream_cb` + render API | **done, live-verified** — 33 offline tests; plays a real 1080p HEVC episode over a public link, seeks, resumes from the disk cache, and renders into the app's GL context | | `pstr-app` — egui UI, poster grid, embedded video | **done, offline-tested** — 15 offline tests; library wall, title pages, player page with overlay controls, themes, share management | -| Metadata providers (AniList / TMDB) | **done** — 32 offline tests; posters, synopses, per-episode titles, hand-pinned matches. Opt-in, off by default | +| Metadata providers (AniList, then TVmaze / TMDB) | **done** — 32 offline tests; posters, synopses, per-episode titles, hand-pinned matches. Opt-in, off by default | | Packaging | **done, locally verified** — `scripts/build.sh` (tarball, `.deb`, `.rpm`, `.app`, `.dmg`) and `scripts/build.ps1` (portable `.zip`, WiX `.msi`), plus `packaging/PKGBUILD`. See `packaging/README.md`. The macOS half is written but has not been run on a Mac | ## Build order from here diff --git a/docs/METADATA.md b/docs/METADATA.md index 30ad0ed..b93e71f 100644 --- a/docs/METADATA.md +++ b/docs/METADATA.md @@ -46,7 +46,7 @@ good. What hung off it was not: | **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. | +| **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. | @@ -192,9 +192,37 @@ 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_fresh` + takes 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. + +Still to do: + - **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. - **Skip intro** from AniSkip for files without chapters, and filler flags from Jikan. diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 550bd3f..51137a4 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -9,6 +9,7 @@ and to nothing else unless you turn on posters and descriptions. This page lists |---|---|---| | Proton | Requests for the shares you added and, if you signed in, your account's Drive | Always | | AniList and ani.zip, or TMDB | The names of the titles in your library, and the ids of matched titles | Only with **Posters and descriptions** on | +| TVmaze | With AniList chosen: the names of the titles AniList has no match for, and the ids of those TVmaze matched | Only with **Posters and descriptions** on | Nothing else leaves your device. There is no telemetry, no crash reporting and no update check. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index d8a0e73..1bb278e 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -65,9 +65,10 @@ Click a title to open its page. **Esc** goes back. - **Resume S01E03 · 12:04** continues where you stopped. **Start over** plays the episode from the beginning. - **Download show** keeps every episode on this device. See [Downloads](#downloads). -- **Match…** opens the matcher, where you choose which AniList or TMDB entry the title is. Use it - when the app guessed wrong or did not guess at all. A match you pick is never replaced. -- **More on AniList** (or TMDB) opens the title's page there. +- **Match…** opens the matcher, where you choose which AniList, TVmaze or TMDB entry the title + is. Each result names the site it came from. Use it when the app guessed wrong or did not guess + at all. A match you pick is never replaced. +- **More on AniList** (or TVmaze, or TMDB) opens the title's page there. - Each season has **Mark season watched** and **Download season**. - Each episode shows a tick when you have watched it. Right-click an episode and choose **Mark watched up to here** to tick it and every episode before it. @@ -220,6 +221,8 @@ a video that is playing changes at once. **Posters and descriptions** - Off by default. When it is on, the app sends the names of your titles to the provider you pick: - **AniList** (no key needed) or **TMDB** (needs a free API key). See [Privacy](PRIVACY.md). + **AniList** (no key needed) or **TMDB** (needs a free API key). With AniList, a title it does + not have, such as a live-action show, is looked up on **TVmaze** next, which needs no key + either. See [Privacy](PRIVACY.md). - **Match the library** looks up new titles now. **Match everything again** looks up every title, except those you matched by hand. diff --git a/ios/ProtonStream/UI/SettingsView.swift b/ios/ProtonStream/UI/SettingsView.swift index 25f27d8..4260003 100644 --- a/ios/ProtonStream/UI/SettingsView.swift +++ b/ios/ProtonStream/UI/SettingsView.swift @@ -321,7 +321,7 @@ private struct MetadataSettingsSheet: View { var body: some View { DialogSheet("Metadata enrichment") { - Text("Off by default: enabling sends the titles in your library to a third party, associated with your IP address and subject to their privacy policy. With AniList, the matched ids also go to ani.zip for episode names and wide artwork.") + Text("Off by default: enabling sends the titles in your library to a third party, associated with your IP address and subject to their privacy policy. With AniList, a title it has nothing for is then sent to TVmaze, and the matched ids go to ani.zip for episode names and wide artwork.") Toggle("Enable enrichment", isOn: $enabled).tint(scheme.primary).foregroundStyle(scheme.onSurface).padding(.vertical, 6) HStack(spacing: 8) { ForEach([MetadataProvider.aniList, .tmdb], id: \.self) { option in @@ -329,7 +329,7 @@ private struct MetadataSettingsSheet: View { .buttonStyle(option == provider ? ProtonButtonStyle(kind: .accent) : ProtonButtonStyle(kind: .edged)) } } - Text(provider == .aniList ? "Anime; no account or API key required." : "Film and television; requires a free TMDB API key.") + Text(provider == .aniList ? "Anime, and television from TVmaze; no account or API key required." : "Film and television; requires a free TMDB API key.") .textStyle(.bodySmall) if provider == .tmdb { Field(label: "Language", text: $language) diff --git a/ios/ProtonStream/UI/TitleView.swift b/ios/ProtonStream/UI/TitleView.swift index a4cf6cf..f4af142 100644 --- a/ios/ProtonStream/UI/TitleView.swift +++ b/ios/ProtonStream/UI/TitleView.swift @@ -376,10 +376,16 @@ extension MetadataProvider { switch self { case .aniList: "AniList" case .tmdb: "TMDB" + case .tvMaze: "TVmaze" } } } +extension MatchRecord { + /// Unique across one search: the same id can come back from two providers. + var choiceId: String { "\(provider.displayName):\(remoteId)" } +} + extension EpisodeRecord { /// `S03E38`, `E38`, or the row's place in the season. /// @@ -515,7 +521,7 @@ struct ChangeMatchSheet: View { var body: some View { DialogSheet("Change match") { - Text("Search the configured metadata provider. Nothing is stored until you choose an entry.") + Text("Search the configured metadata providers. Nothing is stored until you choose an entry.") Field(label: "Title", text: $term, submit: .search) .onSubmit(search) Button(searching ? "Searching…" : "Search", action: search) @@ -534,11 +540,11 @@ struct ChangeMatchSheet: View { if !searching, options.isEmpty, searched { Text("Nothing found. Try the title's original or English name.").textStyle(.bodySmall) } - ForEach(options, id: \.remoteId) { option in + ForEach(options, id: \.choiceId) { option in Button { choose(option) } label: { VStack(alignment: .leading, spacing: 2) { Text(option.name).textStyle(.labelLarge, semibold: true) - Text([option.year.map(String.init), option.originalName].compactMap { $0 }.joined(separator: " · ")) + Text([option.year.map(String.init), option.originalName, option.provider.displayName].compactMap { $0 }.joined(separator: " · ")) .textStyle(.labelLarge) } .frame(maxWidth: .infinity, alignment: .leading)