diff --git a/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt b/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt index 145a2f6..9d59b8e 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt @@ -727,8 +727,10 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie val stored = runCatching { withContext(Dispatchers.IO) { val engine = NativeRuntime.engine() - if (provider == MetadataProvider.TMDB && apiKey.isNotBlank()) { - engine.setMetadataApiKey(provider, apiKey) + // TMDB's is the only key: the provider's with TMDB chosen, and + // film art's with AniList. + if (apiKey.isNotBlank()) { + engine.setMetadataApiKey(MetadataProvider.TMDB, apiKey) } engine.setMetadataSettings(MetadataSettingsRecord(enabled, provider, language, true)) engine.metadataSettings() 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 0756459..e4c3729 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 @@ -447,21 +447,33 @@ private fun MetadataSettingsDialog( } } Text( - 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.", + if (provider == MetadataProvider.ANI_LIST) { + "Anime, and television from TVmaze; no account or API key required. " + + "With a TMDB API key, films get TMDB's backdrops and logos, asked for by id alone." + } else { + "Film and television; requires a free TMDB API key." + }, style = MaterialTheme.typography.bodySmall, ) if (provider == MetadataProvider.TMDB) { OutlinedTextField(language, { language = it }, label = { Text("Language") }, singleLine = true) - OutlinedTextField( - apiKey, - { apiKey = it }, - label = { Text(if (current.ready) "TMDB API key (leave blank to keep)" else "TMDB API key") }, - visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - singleLine = true, - ) } + OutlinedTextField( + apiKey, + { apiKey = it }, + label = { + Text( + when { + provider == MetadataProvider.ANI_LIST -> "TMDB API key (optional)" + current.ready -> "TMDB API key (leave blank to keep)" + else -> "TMDB API key" + }, + ) + }, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + singleLine = true, + ) if (needsKey) Text("Enter a TMDB API key to save.", color = MaterialTheme.colorScheme.error) // Said before Save rather than after: what is forgotten // includes matches chosen by hand, which no look-up redoes. diff --git a/crates/pstr-android/src/lib.rs b/crates/pstr-android/src/lib.rs index 10a2e21..c3e96b1 100644 --- a/crates/pstr-android/src/lib.rs +++ b/crates/pstr-android/src/lib.rs @@ -2432,7 +2432,7 @@ impl AndroidEngine { reason: "turn on metadata enrichment first".to_owned(), }); } - let key = pstr_meta::settings::api_key_in(self.secrets.as_ref(), config.provider); + let key = pstr_meta::settings::api_key_in(self.secrets.as_ref(), ProviderId::Tmdb); pstr_meta::MetadataService::new(&config, key).map_err(BridgeError::from_display) } diff --git a/crates/pstr-app/src/engine.rs b/crates/pstr-app/src/engine.rs index 37a51d5..f372db6 100644 --- a/crates/pstr-app/src/engine.rs +++ b/crates/pstr-app/src/engine.rs @@ -460,7 +460,7 @@ impl Enrichment { /// Resolve a service for `config`, if one can be had. fn build(config: MetadataConfig) -> Self { let service = config.enabled.then(|| { - let key = pstr_meta::settings::api_key(config.provider); + let key = pstr_meta::settings::api_key(ProviderId::Tmdb); MetadataService::new(&config, key) .inspect_err(|error| tracing::debug!("metadata provider unavailable: {error}")) .ok() @@ -508,12 +508,13 @@ impl Engine { Appearance::default() }); - // A provider with an API key needs it out of the OS credential store, - // and that can be a D-Bus round trip to a keyring that has to unlock - // first — not something to hold the first frame for. Built on the - // blocking pool instead; until it lands, everything that needs the - // provider waits for it rather than concluding there is none. - let deferred = config.enabled && config.provider.needs_api_key(); + // The TMDB key, which every provider looks for, comes out of the OS + // credential store, and that can be a D-Bus round trip to a keyring + // that has to unlock first — not something to hold the first frame + // for. Built on the blocking pool instead; until it lands, everything + // that needs the provider waits for it rather than concluding there is + // none. + let deferred = config.enabled; let enrichment = if deferred { Enrichment { config: config.clone(), diff --git a/crates/pstr-app/src/ui/settings.rs b/crates/pstr-app/src/ui/settings.rs index e2f02d7..8db48cd 100644 --- a/crates/pstr-app/src/ui/settings.rs +++ b/crates/pstr-app/src/ui/settings.rs @@ -901,28 +901,35 @@ fn metadata_form( } } + // TMDB's is the only key there is: the provider's with TMDB chosen, + // and with AniList, the art of films TVDB has none for. + ui.add_space(theme::space::L); if settings.provider.needs_api_key() { - ui.add_space(theme::space::L); - ui.label(ui::muted(format!("{} API key", settings.provider.label()))); - ui.horizontal(|ui| { - ui.add( - egui::TextEdit::singleline(api_key) - .password(true) - .hint_text("v3 API key") - .desired_width(300.0), - ); - if ui.button("Save key").clicked() { - actions.push(Action::SetApiKey { - provider: settings.provider, - key: std::mem::take(api_key), - }); - } - }); + ui.label(ui::muted("TMDB API key")); + } else { ui.label(ui::muted( - "Stored in your system keyring, never in a config file. Leave the box \ - empty and save to forget it.", + "TMDB API key, optional. With one, films get TMDB's backdrops and logos, \ + asked for by the film's TMDB id alone.", )); } + ui.horizontal(|ui| { + ui.add( + egui::TextEdit::singleline(api_key) + .password(true) + .hint_text("v3 API key") + .desired_width(300.0), + ); + if ui.button("Save key").clicked() { + actions.push(Action::SetApiKey { + provider: ProviderId::Tmdb, + key: std::mem::take(api_key), + }); + } + }); + ui.label(ui::muted( + "Stored in your system keyring, never in a config file. Leave the box \ + empty and save to forget it.", + )); ui.add_space(theme::space::L); ui.horizontal(|ui| { diff --git a/crates/pstr-cli/src/main.rs b/crates/pstr-cli/src/main.rs index 076f1c7..4c20584 100644 --- a/crates/pstr-cli/src/main.rs +++ b/crates/pstr-cli/src/main.rs @@ -546,15 +546,16 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> { settings.provider.label(), settings.provider.description() ); - if settings.provider.needs_api_key() { - println!( - "api key: {}", - match pstr_meta::settings::api_key(settings.provider) { - Some(_) => "stored in the credential store", - None => "not set — run `pstr metadata set --api-key`", - } - ); - } + let stored_key = pstr_meta::settings::api_key(ProviderId::Tmdb).is_some(); + println!( + "tmdb key: {}", + match (stored_key, settings.provider.needs_api_key()) { + (true, true) => "stored in the credential store", + (true, false) => "stored — used for the art of films TVDB has none for", + (false, true) => "not set — run `pstr metadata set --api-key`", + (false, false) => "not set (optional, for film art)", + } + ); let catalog = Catalog::open(&dirs.catalog_db()).context("open catalog")?; let stored = catalog.all_metadata()?; @@ -594,12 +595,11 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> { } if api_key { - let key = rpassword::prompt_password(format!( - "{} API key (empty to forget it): ", - settings.provider.label() - )) - .context("read the API key")?; - pstr_meta::settings::set_api_key(settings.provider, &key) + // TMDB's is the only key there is: the provider with TMDB + // chosen, and film art with AniList. + let key = rpassword::prompt_password("TMDB API key (empty to forget it): ") + .context("read the API key")?; + pstr_meta::settings::set_api_key(ProviderId::Tmdb, &key) .context("store the API key")?; } @@ -638,7 +638,7 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> { let service = pstr_meta::MetadataService::new( &settings, - pstr_meta::settings::api_key(settings.provider), + pstr_meta::settings::api_key(ProviderId::Tmdb), ) .context("build the metadata provider")?; diff --git a/crates/pstr-meta/src/anilist.rs b/crates/pstr-meta/src/anilist.rs index 76e1ce6..eed9784 100644 --- a/crates/pstr-meta/src/anilist.rs +++ b/crates/pstr-meta/src/anilist.rs @@ -27,6 +27,7 @@ use crate::error::{Error, Result}; use crate::limiter::RateLimiter; use crate::matching::{Candidate, Query}; use crate::provider::Provider; +use crate::tmdb::{Tmdb, TmdbId}; const ENDPOINT: &str = "https://graphql.anilist.co"; @@ -159,16 +160,20 @@ pub struct AniList { /// Shared by every lookup in flight — see [`crate::limiter`]. limiter: RateLimiter, anizip: AniZip, + /// Asked by id for the art TVDB has not got, when a TMDB key is stored. + tmdb: Option, } impl AniList { /// `language` is the one episode names are wanted in, where ani.zip has - /// them in it. - pub fn new(http: reqwest::Client, language: String) -> Self { + /// them in it. `tmdb` is asked for the art of entries TVDB has none for — + /// see [`Tmdb::art`]. + pub fn new(http: reqwest::Client, language: String, tmdb: Option) -> Self { Self { anizip: AniZip::new(http.clone(), language), http, limiter: RateLimiter::per_minute(REQUESTS_PER_MINUTE), + tmdb, } } } @@ -204,7 +209,8 @@ impl Provider for AniList { } /// The episodes of every entry the library's files are spread across, and - /// the series' fanart. + /// the series' fanart and logo — from TMDB, by the id ani.zip gives, for + /// an entry TVDB has no art for. /// /// AniList files a sequel as its own entry — Bleach's seventeenth season is /// four of them, Mushoku Tensei's first is two — so a title's match is @@ -235,6 +241,8 @@ impl Provider for AniList { // The same ids as `visited`, in the order they were read. let mut chain = Vec::new(); let mut series_entries = 0; + // The match's own: a sequel's art is not the title's. + let mut tmdb_id: Option = None; let mut next = Some(Link { id: first, series: found.kind == TitleKind::Series, @@ -247,6 +255,9 @@ impl Provider for AniList { series_entries }); let mapping = self.anizip.mapping(link.id, entry_season).await?; + if chain.len() == 1 { + tmdb_id = mapping.tmdb; + } if enrichment.backdrop_url.is_none() { enrichment.backdrop_url = mapping.fanart; } @@ -268,6 +279,14 @@ impl Provider for AniList { next = self.sequel(link.id, &visited).await?; } + if let (Some(tmdb), Some(id)) = (&self.tmdb, tmdb_id) + && (enrichment.backdrop_url.is_none() || enrichment.logo_url.is_none()) + { + let art = tmdb.art_or_nothing(id).await; + enrichment.backdrop_url = enrichment.backdrop_url.or(art.backdrop); + enrichment.logo_url = enrichment.logo_url.or(art.logo); + } + tracing::debug!( "{}: {} episodes over {} entries", found.name, diff --git a/crates/pstr-meta/src/anizip.rs b/crates/pstr-meta/src/anizip.rs index aecd4ae..f542494 100644 --- a/crates/pstr-meta/src/anizip.rs +++ b/crates/pstr-meta/src/anizip.rs @@ -20,6 +20,7 @@ use serde::Deserialize; use crate::error::{Error, Result}; use crate::limiter::RateLimiter; +use crate::tmdb::TmdbId; const ENDPOINT: &str = "https://api.ani.zip/mappings"; @@ -38,6 +39,8 @@ pub struct Mapping { pub fanart: Option, /// TVDB's clear logo: the series' name as lettering, a transparent PNG. pub logo: Option, + /// The same entry on TMDB, where TVDB has none of the films. + pub tmdb: Option, } pub struct AniZip { @@ -92,6 +95,31 @@ struct Payload { episodes: std::collections::BTreeMap, #[serde(default)] images: Vec, + mappings: Option, +} + +/// The entry's ids elsewhere. +#[derive(Deserialize)] +struct Ids { + /// AniList's format, upper-cased: `TV`, `MOVIE`, `OVA`… + #[serde(rename = "type")] + kind: Option, + #[serde(default, deserialize_with = "lenient_number")] + themoviedb_id: Option, +} + +impl Ids { + /// The TMDB entry, where its kind says which list the id is from. An OVA + /// or a special is filed either way on TMDB, so it is left alone rather + /// than guessed. + fn tmdb(&self) -> Option { + let id = self.themoviedb_id?; + match self.kind.as_deref()? { + "MOVIE" => Some(TmdbId::Movie(id)), + "TV" | "TV_SHORT" | "ONA" => Some(TmdbId::Tv(id)), + _ => None, + } + } } #[derive(Deserialize)] @@ -135,6 +163,7 @@ impl Payload { }; let fanart = image("Fanart"); let logo = image("Clearlogo"); + let tmdb = self.mappings.as_ref().and_then(Ids::tmdb); let episodes = self .episodes @@ -173,6 +202,7 @@ impl Payload { episodes, fanart, logo, + tmdb, } } } @@ -322,5 +352,32 @@ mod tests { assert!(mapping.episodes.is_empty()); assert_eq!(mapping.fanart, None); assert_eq!(mapping.logo, None); + assert_eq!(mapping.tmdb, None); + } + + /// Paprika: a film with no TVDB art at all, and a TMDB id written as a + /// string. + #[test] + fn a_films_tmdb_id_is_kept_as_a_film() { + let mapping = serde_json::from_str::( + r#"{"images": [], "mappings": {"type": "MOVIE", "themoviedb_id": "4977"}}"#, + ) + .expect("parse") + .into_mapping("en", None); + assert_eq!(mapping.tmdb, Some(TmdbId::Movie(4977))); + } + + #[test] + fn a_series_tmdb_id_is_kept_and_a_specials_is_not() { + let tmdb = |kind: &str| { + serde_json::from_str::(&format!( + r#"{{"mappings": {{"type": "{kind}", "themoviedb_id": 209867}}}}"# + )) + .expect("parse") + .into_mapping("en", Some(1)) + .tmdb + }; + assert_eq!(tmdb("TV"), Some(TmdbId::Tv(209867))); + assert_eq!(tmdb("OVA"), None); } } diff --git a/crates/pstr-meta/src/service.rs b/crates/pstr-meta/src/service.rs index a8e0c96..20b5bc2 100644 --- a/crates/pstr-meta/src/service.rs +++ b/crates/pstr-meta/src/service.rs @@ -141,29 +141,33 @@ pub struct MetadataService { impl MetadataService { /// 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, mut api_key: Option) -> Result { + /// `tmdb_key` is the stored TMDB key, if there is one. TMDB as the provider + /// cannot do without it; AniList uses one for the art of films TVDB has + /// none for, and does without it otherwise. + pub fn new(config: &MetadataConfig, tmdb_key: Option) -> Result { let http = reqwest::Client::builder() .user_agent(USER_AGENT) .timeout(TIMEOUT) .build() .map_err(Error::Network)?; + let tmdb_key = tmdb_key.filter(|key| !key.trim().is_empty()); + let tmdb = || { + tmdb_key + .clone() + .map(|key| Tmdb::new(http.clone(), key, config.language.clone())) + .ok_or(Error::MissingApiKey(ProviderId::Tmdb)) + }; 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( + ProviderId::AniList => Source::AniList(AniList::new( http.clone(), - api_key - .take() - .ok_or(Error::MissingApiKey(ProviderId::Tmdb))?, config.language.clone(), + tmdb().ok(), )), + ProviderId::Tmdb => Source::Tmdb(tmdb()?), ProviderId::TvMaze => Source::TvMaze(TvMaze::new(http.clone())), }) }) @@ -412,7 +416,7 @@ pub struct Outcome { /// A hand-picked entry is never searched for again, not even when `force`d — /// that 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. It -/// is still enriched and refreshed. +/// is still enriched and refreshed, and `force` enriches it again. /// /// `chain` is [`MetadataService::chain`]. Enriching and refreshing go to the /// provider each title is matched to. @@ -435,9 +439,12 @@ pub fn plan( let Some(record) = record.filter(|record| record.metadata.is_some()) else { continue; }; - let fresh = enriched.get(&title.key).is_some_and(|(asked, at)| { - *asked == record.provider && now.saturating_sub(*at) < MATCH_TTL_SECS - }); + // Forced, a pinned title is enriched again even when it is fresh: art + // a newly saved key reaches is the usual reason to force. + let fresh = !force + && enriched.get(&title.key).is_some_and(|(asked, at)| { + *asked == record.provider && now.saturating_sub(*at) < MATCH_TTL_SECS + }); let stale_details = gives_details(record.provider) && record .metadata @@ -578,6 +585,8 @@ mod tests { ..MetadataConfig::default() }; assert!(MetadataService::new(&anilist, None).is_ok()); + // A stored TMDB key is used for art, not demanded. + assert!(MetadataService::new(&anilist, Some("key".into())).is_ok()); let tmdb = MetadataConfig { provider: ProviderId::Tmdb, @@ -741,7 +750,8 @@ mod tests { /// 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. + /// TVmaze, and a hand-picked one stays pinned — re-enriched when forced, + /// never searched for. #[test] fn titles_are_planned_against_the_provider_each_is_matched_to() { let miss = MetadataRecord { @@ -770,10 +780,16 @@ mod tests { vec![("anilist miss", "match"), ("on tvmaze", "enrich")] ); + // Forced, the pinned title is not searched for, but is enriched again + // although it is fresh. let work = plan(titles, &stored, &enriched, ANILIST, true); assert_eq!( kinds(&work), - vec![("anilist miss", "match"), ("on tvmaze", "match")] + vec![ + ("anilist miss", "match"), + ("on tvmaze", "match"), + ("pinned", "enrich") + ] ); } diff --git a/crates/pstr-meta/src/tmdb.rs b/crates/pstr-meta/src/tmdb.rs index 083ea32..6e0544c 100644 --- a/crates/pstr-meta/src/tmdb.rs +++ b/crates/pstr-meta/src/tmdb.rs @@ -9,6 +9,11 @@ //! not evidence enough to pick an endpoint. The `media_type` on each result is //! better evidence than the guess, and [`crate::matching`] only nudges on kind //! anyway. +//! +//! A stored key is also used with AniList chosen, by id alone: ani.zip names +//! the TMDB entry an AniList match is, and [`Tmdb::art`] fetches its backdrop +//! and logo — what the films TVDB has no art for are missing. No title is +//! searched for that way. use pstr_core::library::{Title, TitleKind}; use pstr_core::metadata::{Enrichment, EpisodeMetadata, ProviderId, TitleMetadata}; @@ -34,13 +39,16 @@ const STILL_SIZE: &str = "w300"; /// Where TMDB serves artwork from, and the sizes worth asking for. /// -/// `w500` for a poster and `w780` for a backdrop are what the images are drawn -/// at here, give or take; `original` is several megabytes of print-resolution -/// artwork per title, which is bandwidth spent on pixels no tile can show. +/// `w500` for a poster and a logo and `w1280` for a backdrop are what the +/// images are drawn at here, give or take — a backdrop fills the width of the +/// title page and the library's banner; `original` is several megabytes of +/// print-resolution artwork per title, which is bandwidth spent on pixels no +/// window shows. const IMAGE_BASE: &str = "https://image.tmdb.org/t/p"; const POSTER_SIZE: &str = "w500"; -const BACKDROP_SIZE: &str = "w780"; +const BACKDROP_SIZE: &str = "w1280"; +const LOGO_SIZE: &str = "w500"; /// TMDB's published ceiling is around fifty requests a second and it no longer /// enforces a daily one. This is well under it: the point of pacing here is a @@ -68,6 +76,30 @@ impl Tmdb { } } +/// A TMDB entry, by id. Films and television are numbered separately, so the +/// id alone does not name one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TmdbId { + Movie(u32), + Tv(u32), +} + +impl TmdbId { + fn path(self) -> String { + match self { + Self::Movie(id) => format!("movie/{id}"), + Self::Tv(id) => format!("tv/{id}"), + } + } +} + +/// An entry's wide picture and its logo. +#[derive(Debug, Default, PartialEq)] +pub struct Art { + pub backdrop: Option, + pub logo: Option, +} + impl Provider for Tmdb { fn id(&self) -> ProviderId { ProviderId::Tmdb @@ -95,14 +127,25 @@ impl Provider for Tmdb { .collect()) } - /// The episode list, and nothing else: the match already carried a proper + /// The episode list and the logo. The match already carried a proper /// backdrop, and TMDB files every season under one show, so the library's /// seasons need no walking. async fn enrich(&self, _title: &Title, found: &TitleMetadata) -> Result { + let logo_url = match found.remote_id.parse() { + Ok(id) => { + self.art_or_nothing(match found.kind { + TitleKind::Series => TmdbId::Tv(id), + TitleKind::Film => TmdbId::Movie(id), + }) + .await + .logo + } + Err(_) => None, + }; Ok(Enrichment { episodes: self.episodes(found).await?, backdrop_url: None, - logo_url: None, + logo_url, chain: Vec::new(), }) } @@ -169,7 +212,7 @@ impl Tmdb { still_url: episode .still_path .as_deref() - .map(|path| image(STILL_SIZE, path)), + .map(|path| image_url(STILL_SIZE, path)), air_date: episode.air_date.clone().filter(|date| !date.is_empty()), runtime: episode.runtime.filter(|minutes| *minutes > 0), } @@ -178,6 +221,37 @@ impl Tmdb { Ok(episodes) } + /// The best backdrop and logo TMDB has for `id`. + /// + /// Artwork in the viewer's language, English or Japanese is asked for, and + /// artwork with no language at all, which for a backdrop is the one without + /// lettering on it. One request. + pub async fn art(&self, id: TmdbId) -> Result { + let languages = format!("{},en,ja,null", self.language_code()); + let images: Images = self + .get( + &format!("{API_BASE}/{}/images", id.path()), + &[("include_image_language", languages.as_str())], + ) + .await?; + Ok(images.into_art(self.language_code())) + } + + /// [`Tmdb::art`], with a failure logged and taken as no art: the art is + /// decoration on an answer that is already complete, and a bad key or a + /// slow minute must not stop the episodes beside it being stored. + pub async fn art_or_nothing(&self, id: TmdbId) -> Art { + self.art(id) + .await + .inspect_err(|error| tracing::debug!("TMDB art for {id:?}: {error}")) + .unwrap_or_default() + } + + /// `de` out of `de-DE`: images are tagged by language alone. + fn language_code(&self) -> &str { + self.language.split('-').next().unwrap_or("en") + } + /// One authenticated GET, paced, with TMDB's failures named. /// /// TMDB is far more generous than AniList and a library-sized run rarely @@ -278,6 +352,64 @@ struct EpisodeRow { runtime: Option, } +/// `/{movie,tv}/{id}/images`. +#[derive(Deserialize, Default)] +struct Images { + #[serde(default)] + backdrops: Vec, + #[serde(default)] + logos: Vec, +} + +#[derive(Deserialize)] +struct Image { + file_path: String, + iso_639_1: Option, + #[serde(default)] + vote_average: f32, +} + +impl Images { + fn into_art(self, language: &str) -> Art { + // Bare is best for a backdrop: a logo or a title is drawn over it, + // and lettering already in the picture fights that. + let backdrop = best(&self.backdrops, |image| match image.iso_639_1.as_deref() { + None => 2, + Some(tag) if tag == language || tag == "en" => 1, + Some(_) => 0, + }); + // A logo is lettering, so it is wanted in a language the viewer reads. + // Only PNGs: an SVG is nothing the art loaders here can draw. + let logos: Vec<&Image> = self + .logos + .iter() + .filter(|image| image.file_path.ends_with(".png")) + .collect(); + let logo = best(&logos, |image| match image.iso_639_1.as_deref() { + Some(tag) if tag == language => 3, + Some("en") => 2, + None => 1, + Some(_) => 0, + }); + Art { + backdrop: backdrop.map(|image| image_url(BACKDROP_SIZE, &image.file_path)), + logo: logo.map(|image| image_url(LOGO_SIZE, &image.file_path)), + } + } +} + +/// The image `rank` likes most, the better-voted one among equals. +fn best>( + images: &[T], + rank: impl Fn(&Image) -> u8, +) -> Option<&Image> { + images.iter().map(|image| image.borrow()).max_by(|a, b| { + rank(a) + .cmp(&rank(b)) + .then(a.vote_average.total_cmp(&b.vote_average)) + }) +} + #[derive(Deserialize)] struct SearchResponse { #[serde(default)] @@ -339,8 +471,10 @@ impl Result_ { overview: self.overview.filter(|text| !text.is_empty()), year: year_of(self.first_air_date.or(self.release_date).as_deref()), kind, - poster_url: self.poster_path.map(|path| image(POSTER_SIZE, &path)), - backdrop_url: self.backdrop_path.map(|path| image(BACKDROP_SIZE, &path)), + poster_url: self.poster_path.map(|path| image_url(POSTER_SIZE, &path)), + backdrop_url: self + .backdrop_path + .map(|path| image_url(BACKDROP_SIZE, &path)), logo_url: None, rating: self.vote_average.filter(|rating| *rating > 0.0), // `search/multi` carries neither, and a detail request per @@ -368,7 +502,7 @@ impl Result_ { } } -fn image(size: &str, path: &str) -> String { +fn image_url(size: &str, path: &str) -> String { format!("{IMAGE_BASE}/{size}{path}") } @@ -442,7 +576,7 @@ mod tests { ); assert_eq!( series.metadata.backdrop_url.as_deref(), - Some("https://image.tmdb.org/t/p/w780/b.jpg") + Some("https://image.tmdb.org/t/p/w1280/b.jpg") ); let film: Result_ = serde_json::from_str( @@ -456,6 +590,61 @@ mod tests { assert_eq!(film.metadata.year, Some(1988)); } + /// Paprika's images as TMDB answers them, trimmed: a bare backdrop beside + /// better-voted lettered ones, and logos in three languages and two + /// formats. + #[test] + fn the_bare_backdrop_and_a_png_logo_in_the_viewers_language_are_chosen() { + let images: Images = serde_json::from_str( + r#"{ + "backdrops": [ + {"file_path": "/en.jpg", "iso_639_1": "en", "vote_average": 5.5}, + {"file_path": "/bare.jpg", "iso_639_1": null, "vote_average": 5.2}, + {"file_path": "/bare-worse.jpg", "iso_639_1": null, "vote_average": 4.0}, + {"file_path": "/ja.jpg", "iso_639_1": "ja", "vote_average": 6.0} + ], + "logos": [ + {"file_path": "/ja.png", "iso_639_1": "ja", "vote_average": 6.0}, + {"file_path": "/de.svg", "iso_639_1": "de", "vote_average": 5.0}, + {"file_path": "/en.png", "iso_639_1": "en", "vote_average": 3.0}, + {"file_path": "/de.png", "iso_639_1": "de", "vote_average": 1.0} + ] + }"#, + ) + .expect("parse"); + let art = images.into_art("en"); + assert_eq!( + art.backdrop.as_deref(), + Some("https://image.tmdb.org/t/p/w1280/bare.jpg") + ); + assert_eq!( + art.logo.as_deref(), + Some("https://image.tmdb.org/t/p/w500/en.png") + ); + } + + #[test] + fn a_logo_in_the_viewers_own_language_beats_an_english_one() { + let images: Images = serde_json::from_str( + r#"{"logos": [ + {"file_path": "/en.png", "iso_639_1": "en", "vote_average": 9.0}, + {"file_path": "/de.svg", "iso_639_1": "de", "vote_average": 9.0}, + {"file_path": "/de.png", "iso_639_1": "de", "vote_average": 1.0} + ]}"#, + ) + .expect("parse"); + assert_eq!( + images.into_art("de").logo.as_deref(), + Some("https://image.tmdb.org/t/p/w500/de.png") + ); + } + + #[test] + fn an_entry_with_no_images_has_no_art() { + let images: Images = serde_json::from_str("{}").expect("parse"); + assert_eq!(images.into_art("en"), Art::default()); + } + /// A rating of zero means "nobody has voted", not "everybody hated it", and /// showing it as 0.0/10 would be a lie about the title. #[test] diff --git a/docs/METADATA.md b/docs/METADATA.md index 16fffbc..980dbfd 100644 --- a/docs/METADATA.md +++ b/docs/METADATA.md @@ -45,7 +45,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. | +| **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. | @@ -225,6 +225,21 @@ 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. + Still to do: - **Local matching** against the offline database, so only ids leave the diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 51137a4..e4e9d61 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -10,6 +10,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 | +| TMDB, with AniList chosen | The TMDB ids of matched titles that have no wide picture or logo yet. Never a title's name | Only with **Posters and descriptions** on and a TMDB key saved | 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 1bb278e..e7dbc15 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -224,5 +224,8 @@ a video that is playing changes at once. **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). +- **TMDB API key:** needed for TMDB, optional with AniList. With a key, films and shows that have + no wide picture or logo get TMDB's, asked for by their TMDB id alone. After saving a key, + **Match everything again** fetches them for titles already matched. - **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/App/AppModel.swift b/ios/ProtonStream/App/AppModel.swift index 559670c..ffbb25e 100644 --- a/ios/ProtonStream/App/AppModel.swift +++ b/ios/ProtonStream/App/AppModel.swift @@ -637,8 +637,10 @@ final class AppModel { func saveMetadataSettings(enabled: Bool, provider: MetadataProvider, language: String, apiKey: String) async -> String? { do { metadataSettings = try await run { engine in - if provider == .tmdb, !apiKey.trimmingCharacters(in: .whitespaces).isEmpty { - try engine.setMetadataApiKey(provider: provider, key: apiKey) + // TMDB's is the only key: the provider's with TMDB chosen, and + // film art's with AniList. + if !apiKey.trimmingCharacters(in: .whitespaces).isEmpty { + try engine.setMetadataApiKey(provider: .tmdb, key: apiKey) } try engine.setMetadataSettings(settings: MetadataSettingsRecord(enabled: enabled, provider: provider, language: language, ready: true)) return try engine.metadataSettings() diff --git a/ios/ProtonStream/UI/SettingsView.swift b/ios/ProtonStream/UI/SettingsView.swift index 4260003..f8ee103 100644 --- a/ios/ProtonStream/UI/SettingsView.swift +++ b/ios/ProtonStream/UI/SettingsView.swift @@ -329,12 +329,12 @@ private struct MetadataSettingsSheet: View { .buttonStyle(option == provider ? ProtonButtonStyle(kind: .accent) : ProtonButtonStyle(kind: .edged)) } } - Text(provider == .aniList ? "Anime, and television from TVmaze; 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. With a TMDB API key, films get TMDB's backdrops and logos, asked for by id alone." : "Film and television; requires a free TMDB API key.") .textStyle(.bodySmall) if provider == .tmdb { Field(label: "Language", text: $language) - Field(label: current.ready ? "TMDB API key (leave blank to keep)" : "TMDB API key", text: $apiKey, secret: true) } + Field(label: provider == .aniList ? "TMDB API key (optional)" : current.ready ? "TMDB API key (leave blank to keep)" : "TMDB API key", text: $apiKey, secret: true) if enabled, provider == .tmdb, !current.ready, apiKey.trimmingCharacters(in: .whitespaces).isEmpty { Text("Enter a TMDB API key to save.").foregroundStyle(scheme.error) }