//! What a metadata provider knows about a title, and whether to ask one. //! //! The *model* is here rather than in `pstr-meta` because the catalog stores it //! and the UI draws it, and neither of those should have to depend on the crate //! that talks HTTP to AniList. `pstr-meta` fills these in; everything else only //! reads them. //! //! ## Enrichment is off by default, and says why //! //! Matching a library against a provider means sending that provider the titles //! in it. For a library of public-domain films that is nothing; for a library of //! anything else it is a list of what someone watches, attached to an IP //! address, held by a third party under their retention policy and not ours. //! That is a real cost and the viewer is the only one who can weigh it, so //! [`MetadataConfig::enabled`] starts `false`, the UI states the cost in the //! same breath as the switch, and nothing here is requested until it is on. //! //! With it off, posters fall back to Proton's own thumbnails — which for a share //! filled by the Linux client means no posters at all, because Proton renders //! thumbnails at upload time and that client attaches none. See //! `docs/DEVELOPMENT.md`. use std::collections::HashMap; use serde::{Deserialize, Serialize}; use crate::library::TitleKind; /// Which provider a match came from. /// /// Stored as a string in the catalog rather than an integer: a row whose /// provider this build does not recognise should read as "some other provider", /// which is a re-match, not a parse failure. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] pub enum ProviderId { /// AniList. Anime only, no API key, no account. #[default] AniList, /// The Movie Database. Film and television, needs a free API key. Tmdb, } impl ProviderId { pub const ALL: [Self; 2] = [Self::AniList, Self::Tmdb]; pub fn as_str(self) -> &'static str { match self { Self::AniList => "anilist", Self::Tmdb => "tmdb", } } /// What to call it in the UI. pub fn label(self) -> &'static str { match self { Self::AniList => "AniList", Self::Tmdb => "TMDB", } } /// One line on what it covers and what it costs to use. pub fn description(self) -> &'static str { match self { Self::AniList => "Anime, with no API key and no account.", Self::Tmdb => "Film and television. Needs a free API key from themoviedb.org.", } } /// Whether this provider cannot be used without a key. pub fn needs_api_key(self) -> bool { matches!(self, Self::Tmdb) } pub fn parse(text: &str) -> Option { Self::ALL .into_iter() .find(|provider| provider.as_str() == text) } } /// What a provider said about one title. /// /// Every field past the identity is optional, because every field is optional at /// some provider: AniList has no backdrop for most shows, TMDB has no episode /// count on a film, and a brand-new entry may be nothing but a name. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct TitleMetadata { pub provider: ProviderId, /// The provider's own id, so a re-fetch does not re-search. pub remote_id: String, /// The canonical title, in the viewer's language where the provider has one. pub name: String, /// The title in its original language, when it differs. pub original_name: Option, pub overview: Option, pub year: Option, pub kind: TitleKind, /// Portrait art, around 2:3. pub poster_url: Option, /// Landscape art, around 16:9. Much rarer than a poster, and the reason the /// grid cannot simply prefer one shape. pub backdrop_url: Option, /// Out of 10, however the provider scores. pub rating: Option, pub genres: Vec, /// How many episodes the provider thinks there are — useful next to how /// many the library actually holds. pub episodes: Option, /// The title's page, for a viewer who wants the rest of it. pub url: Option, } impl TitleMetadata { /// The art to put on a 16:9 tile, most appropriate first. /// /// A backdrop is the right shape; a poster is the wrong shape but the right /// picture, and is drawn letterboxed rather than cropped — cropping a 2:3 /// poster to 16:9 removes most of what makes it recognisable. pub fn tile_art(&self) -> Option<(&str, ArtShape)> { if let Some(url) = &self.backdrop_url { return Some((url, ArtShape::Landscape)); } self.poster_url .as_deref() .map(|url| (url, ArtShape::Portrait)) } } /// What a provider said about one episode. /// /// Thin on purpose. The point of this is the two lines a viewer reads before /// picking an episode — what it is called and what happens in it — and every /// extra field is another column to migrate for something no row shows. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct EpisodeMetadata { /// The season the provider files it under, or `None` when it numbers /// episodes straight through — which is how AniList counts, and how most /// anime releases name their files. pub season: Option, pub number: u32, pub name: Option, pub overview: Option, /// A frame from the episode, around 16:9. pub still_url: Option, /// As the provider writes it — `2009-10-10`. Not parsed: it is shown, not /// sorted on, and a provider that answers `2009` should not be a parse /// error. pub air_date: Option, } /// Every episode a provider listed for one title, ready to look up. /// /// The lookup is the whole reason this is a type rather than a `Vec`: a file /// numbered `#057` and a provider that files episodes under seasons have to /// meet somewhere, and that reconciliation should happen in one tested place /// rather than in each caller. #[derive(Debug, Clone, Default, PartialEq)] pub struct EpisodeGuide { /// Keyed `(season, number)`, where `None` is the provider's own absolute /// numbering. entries: HashMap<(Option, u32), EpisodeMetadata>, } impl EpisodeGuide { pub fn new(episodes: Vec) -> Self { Self { entries: episodes .into_iter() .map(|episode| ((episode.season, episode.number), episode)) .collect(), } } pub fn is_empty(&self) -> bool { self.entries.is_empty() } pub fn len(&self) -> usize { self.entries.len() } /// What the provider says about the file numbered like this. /// /// Tried in order: the season and number the filename states; the /// provider's absolute numbering; and, for a file with no season at all, /// season one. That second fallback is what makes an absolutely-numbered /// release line up with a provider that files everything under a single /// season — the common case for anime on TMDB. /// /// **The absolute fallback stops at season one.** A provider that numbers /// straight through is one that split nothing, so its episode seven is the /// seventh of the show — which is season one's, not season two's. Answering /// `S02E07` with it captions every episode of every later season with the /// wrong name, and a wrong caption does not look like a bug: it looks like /// the library is wrong. Season two upwards is answered from an entry that /// states that season or not at all — see /// `Provider::seasons_are_separate_entries`. pub fn get(&self, season: Option, number: u32) -> Option<&EpisodeMetadata> { if let Some(found) = self.entries.get(&(season, number)) { return Some(found); } if matches!(season, None | Some(1)) && let Some(found) = self.entries.get(&(None, number)) { return Some(found); } season .is_none() .then(|| self.entries.get(&(Some(1), number)))? } /// Which seasons this guide actually has answers for. `None` is the /// provider's own absolute numbering. pub fn seasons(&self) -> std::collections::HashSet> { self.entries .values() .map(|episode| episode.season) .collect() } /// Every entry, for storing. pub fn episodes(&self) -> impl Iterator { self.entries.values() } } /// How a piece of art should be fitted into a tile. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ArtShape { /// Roughly the tile's shape: crop to fill. Landscape, /// Taller than the tile: fit inside it, and leave the sides. Portrait, } /// What the catalog remembers about one title's metadata, including that there /// was none. #[derive(Debug, Clone, PartialEq)] pub struct MetadataRecord { /// [`crate::library::title_key`] of the title this describes. pub title_key: String, /// The provider that was asked. Kept even on a miss, so switching provider /// re-asks rather than trusting the other one's silence. pub provider: ProviderId, /// `None` means the provider was asked and had nothing. That is a real /// answer and it is cached: without it every grid render pays a round-trip /// per unmatched title, which is the trap `proton-drive-linux`'s photo grid /// hit and this one is not going to. pub metadata: Option, /// Unix seconds. What makes a miss expire rather than being forever. pub fetched_at: i64, /// The viewer picked this entry themselves. /// /// A hand-picked match outranks anything the matcher would decide: it never /// expires, and "match again" leaves it alone. Otherwise the one title the /// scorer gets wrong — the case this flag exists for — would be corrected by /// hand and then quietly un-corrected by the next run. pub manual: bool, } /// How long a stored answer is trusted. /// /// A match is effectively permanent — a show does not stop being that show — but /// artwork and ratings move, and more to the point a *miss* has to expire, or a /// title the provider had not indexed yet stays blank for the life of the /// install. Misses are retried far sooner than matches are refreshed. pub const MATCH_TTL_SECS: i64 = 60 * 60 * 24 * 30; 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 { return false; } // A hand-picked match does not go stale: nothing a re-ask could learn // would be better than what the viewer already said it was. if self.manual { return true; } let ttl = if self.metadata.is_some() { MATCH_TTL_SECS } else { MISS_TTL_SECS }; now.saturating_sub(self.fetched_at) < ttl } } /// Whether to enrich, with what. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct MetadataConfig { /// Off until the viewer turns it on. See the module note. pub enabled: bool, pub provider: ProviderId, /// BCP-47-ish, as the provider wants it. Only TMDB uses it; AniList /// answers in romaji and English and the match picks between them. pub language: String, } impl Default for MetadataConfig { fn default() -> Self { Self { enabled: false, provider: ProviderId::AniList, language: "en".into(), } } } #[cfg(test)] mod tests { use super::*; fn metadata() -> TitleMetadata { TitleMetadata { provider: ProviderId::AniList, remote_id: "1".into(), name: "Cowboy Bebop".into(), original_name: None, overview: None, year: Some(1998), kind: TitleKind::Series, poster_url: None, backdrop_url: None, rating: None, genres: Vec::new(), episodes: Some(26), url: None, } } fn episode(season: Option, number: u32, name: &str) -> EpisodeMetadata { EpisodeMetadata { season, number, name: Some(name.into()), overview: None, still_url: None, air_date: None, } } #[test] fn a_file_that_states_its_season_is_answered_from_that_season() { let guide = EpisodeGuide::new(vec![ episode(Some(1), 2, "Stray Dog Strut"), episode(Some(2), 2, "Somewhere else entirely"), ]); assert_eq!( guide.get(Some(1), 2).and_then(|e| e.name.clone()), Some("Stray Dog Strut".to_string()) ); // The fallback to season one must never reach a file that said it was // in season two — that would caption every episode with the wrong one. assert_eq!(guide.get(Some(3), 2), None); } #[test] fn an_absolutely_numbered_file_finds_an_absolutely_numbered_answer() { let guide = EpisodeGuide::new(vec![episode(None, 57, "The Immortal Legion")]); assert_eq!( guide.get(None, 57).and_then(|e| e.name.clone()), Some("The Immortal Legion".to_string()) ); // A provider that numbers straight through answers a file that states // season *one*: the release split nothing there. assert_eq!(guide.get(Some(1), 57).map(|e| e.number), Some(57)); } /// **What captioned every episode of Oshi no Ko season two with season /// one's names.** AniList files a sequel as its own entry numbered from /// one, so its "episode 1" is season one's — and a file that says it is in /// season two must not be answered with it. #[test] fn an_absolutely_numbered_answer_never_reaches_a_later_season() { let guide = EpisodeGuide::new(vec![episode(None, 1, "Mother and Children")]); assert_eq!(guide.get(Some(2), 1), None); assert_eq!(guide.get(Some(3), 1), None); // And once the season's own entry has been fetched, it answers. let guide = EpisodeGuide::new(vec![ episode(None, 1, "Mother and Children"), episode(Some(2), 1, "The Franchise"), ]); assert_eq!( guide.get(Some(2), 1).and_then(|e| e.name.clone()), Some("The Franchise".to_string()) ); assert_eq!( guide.get(None, 1).and_then(|e| e.name.clone()), Some("Mother and Children".to_string()) ); } #[test] fn an_absolutely_numbered_file_falls_back_to_the_only_season_there_is() { // TMDB files a 64-episode anime under one season; the release numbers // its files `#001`–`#064`. Without this they never meet. let guide = EpisodeGuide::new(vec![episode(Some(1), 57, "The Immortal Legion")]); assert_eq!(guide.get(None, 57).map(|e| e.number), Some(57)); assert_eq!(guide.get(None, 99), None); } #[test] fn enrichment_is_off_until_it_is_asked_for() { assert!(!MetadataConfig::default().enabled); } #[test] fn provider_names_round_trip_through_the_catalog_column() { for provider in ProviderId::ALL { assert_eq!(ProviderId::parse(provider.as_str()), Some(provider)); } assert_eq!(ProviderId::parse("letterboxd"), None); } /// A backdrop is the tile's shape and wins; a poster still gets used, but /// says it needs fitting rather than cropping. #[test] fn tile_art_prefers_the_landscape_picture() { let mut data = metadata(); data.poster_url = Some("poster".into()); assert_eq!(data.tile_art(), Some(("poster", ArtShape::Portrait))); data.backdrop_url = Some("backdrop".into()); assert_eq!(data.tile_art(), Some(("backdrop", ArtShape::Landscape))); } /// A miss goes stale far sooner than a match: a show the provider had not /// indexed yet must not stay blank forever. #[test] fn a_miss_expires_sooner_than_a_match() { let now = 1_000_000_000; let miss = MetadataRecord { title_key: "k".into(), provider: ProviderId::AniList, metadata: None, fetched_at: now - MISS_TTL_SECS - 1, manual: false, }; assert!(!miss.is_fresh(now, ProviderId::AniList)); let matched = MetadataRecord { metadata: Some(metadata()), ..miss.clone() }; assert!(matched.is_fresh(now, ProviderId::AniList)); } /// What the viewer picked by hand outlives every TTL: it is not an answer /// that could go stale, it is an instruction. #[test] fn a_hand_picked_match_never_expires() { let record = MetadataRecord { title_key: "k".into(), provider: ProviderId::AniList, metadata: Some(metadata()), fetched_at: 0, manual: true, }; 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)); } /// Switching provider must re-ask rather than trust the other one's answer — /// especially its silence. #[test] fn a_record_from_another_provider_is_never_fresh() { let record = MetadataRecord { title_key: "k".into(), provider: ProviderId::AniList, metadata: Some(metadata()), fetched_at: 1_000_000_000, manual: false, }; assert!(!record.is_fresh(1_000_000_000, ProviderId::Tmdb)); } }