//! 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, HashSet}; 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, Hash, 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. Episode names and wide art come from \ ani.zip, which is sent the matched ids." } 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, /// The title's name as its own lettering: a transparent PNG, drawn over /// wide art in place of the name in type. Found by enrichment, like the /// backdrop, and padded by its source — see `pstr-app`'s `trim_alpha`. #[serde(default)] pub logo_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, /// Everything else the provider said: what the library is sorted, grouped /// and shelved by. `None` on a record stored before there was any of it, /// which is what tells a match run to fetch it — see /// `pstr_meta::service::plan`. pub details: Option, } /// What a provider says about a title beyond what a tile draws. /// /// Stored whole as one JSON column rather than a column each: nothing here is /// ever queried by SQLite, every field is optional at every provider, and a /// field added later has to read as absent from a row written before it, not /// fail the row. TMDB fills none of it yet. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct TitleDetails { /// The title in romaji — `Sousou no Frieren` — for a viewer who would /// rather see that than the English one. pub romaji: Option, /// AniList's ultra-wide banner, around 4.75:1. Never drawn into a 16:9 /// tile — it is what a strip that shape falls back to when there is no /// fanart. pub banner_url: Option, /// As the provider writes it: `TV`, `TV_SHORT`, `MOVIE`, `SPECIAL`, `OVA`, /// `ONA`, `MUSIC`. [`TitleDetails::format_label`] is what is shown. pub format: Option, /// The broadcast season: `WINTER`, `SPRING`, `SUMMER` or `FALL`. pub season: Option, /// When it first aired, `YYYY-MM-DD` with whatever parts are known — /// `2013-04`, `2013`. Sorts as text, which is why it is kept that way. pub start_date: Option, /// The main animation studios. pub studios: Vec, pub directors: Vec, /// The provider's descriptive tags, most relevant first, spoilers left out. pub tags: Vec, /// How many people follow it at the provider. Only ever compared with /// other titles from the same provider. pub popularity: Option, /// `FINISHED`, `RELEASING`, `NOT_YET_RELEASED`, `CANCELLED` or `HIATUS`. pub status: Option, /// The next episode to air, and when — Unix seconds. As of when the /// answer was fetched, so a time in the past means nothing. pub next_episode: Option, pub next_airing_at: Option, /// The provider ids of entries in the same story — prequels, sequels, /// side stories, recaps, retellings. What the library groups a franchise /// by; see `crate::franchise`. pub related: Vec, /// Minutes: an episode's length for a series, the whole running time for /// a film. pub runtime: Option, /// Which [`TitleDetails::REVISION`] wrote this. A stored record from an /// older one lacks a field a newer build asks for, and is fetched again /// by id — see `pstr_meta::service::plan`. Not a reason to blank it in a /// migration: until the refetch lands, the old details still group the /// library and fill its shelves. pub revision: u32, /// The entries enrichment walked through to find the title's episodes — /// its own sequels, by provider id. Stored apart from the rest, because /// it is enrichment's answer rather than the match's. #[serde(skip)] pub chain: Vec, } impl TitleDetails { /// What a provider that fills these in today writes into /// [`TitleDetails::revision`]. Raise it with a field that existing /// records should be fetched again for. `1` added /// [`TitleDetails::runtime`]. pub const REVISION: u32 = 1; /// Whether these were written by this build's [`TitleDetails::REVISION`] /// or a later one. pub fn is_current(&self) -> bool { self.revision >= Self::REVISION } /// `TV` → `TV`, `MOVIE` → `Film`, `TV_SHORT` → `TV short`. pub fn format_label(&self) -> Option<&'static str> { Some(match self.format.as_deref()? { "TV" => "TV", "TV_SHORT" => "TV short", "MOVIE" => "Film", "SPECIAL" => "Special", "OVA" => "OVA", "ONA" => "ONA", "MUSIC" => "Music video", _ => return None, }) } /// `Spring 2013`, or just the year. pub fn season_label(&self) -> Option { let year = self.start_date.as_deref()?.get(..4)?; let season = match self.season.as_deref() { Some("WINTER") => "Winter", Some("SPRING") => "Spring", Some("SUMMER") => "Summer", Some("FALL") => "Fall", _ => return Some(year.to_string()), }; Some(format!("{season} {year}")) } /// Whether it is still airing. pub fn is_airing(&self) -> bool { self.status.as_deref() == Some("RELEASING") } /// The next episode and when it airs, when that is still after `now`. pub fn next_airing(&self, now: i64) -> Option<(u32, i64)> { let at = self.next_airing_at.filter(|at| *at > now)?; Some((self.next_episode?, at)) } } impl TitleMetadata { /// Ultra-wide art: the backdrop where there is one, else the banner. /// /// For the library's hero and the title page's band, which are much wider /// than 16:9 — a backdrop cropped to them loses some sky, a banner fits /// them as drawn. pub fn wide_art(&self) -> Option<&str> { self.backdrop_url.as_deref().or_else(|| { self.details .as_ref() .and_then(|details| details.banner_url.as_deref()) }) } /// The name to show, as the viewer asked for it. pub fn display_name(&self, names: TitleNames) -> Option<&str> { match names { TitleNames::Library => None, TitleNames::English => Some(&self.name), TitleNames::Romaji => self .details .as_ref() .and_then(|details| details.romaji.as_deref()) .or(Some(&self.name)), } } } /// Which name a title goes by in the library. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum TitleNames { /// The folder's — what the share calls it. Library, /// The provider's English title, where it has one. The default, because /// it is what Android always showed, and a folder name is whatever an /// uploader typed — `Kaijuu 8-gou`, `Fate Stay Night - Heaven s Feel - /// THE MOVIE I presage flower`. #[default] English, /// The provider's romaji title. Romaji, } impl TitleNames { pub const ALL: [Self; 3] = [Self::Library, Self::English, Self::Romaji]; pub fn label(self) -> &'static str { match self { Self::Library => "As in the share", Self::English => "English", Self::Romaji => "Romaji", } } } 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, /// Its place counting straight through every season, when the provider /// knows it. What an absolutely-numbered file inside a season folder — /// `Attack on Titan/Season 2/… - 26.mkv` — is matched on. #[serde(default)] pub absolute: Option, /// Its season and number counting each provider *entry* as a season: the /// position of the entry it belongs to among the franchise's series /// entries, and its number within that entry. /// /// Not TVDB's season. A release that follows AniList rather than TVDB /// names Mushishi's *Zoku Shou 2* `S03`, where TVDB files it as the second /// half of season two; this is what that file is answered from. See /// [`EpisodeGuide::get`] for when it is allowed to be. #[serde(default)] pub entry_season: Option, #[serde(default)] pub entry_number: Option, 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, /// Minutes. #[serde(default)] pub runtime: Option, } /// What enriching a matched title found: its episodes, and art the search /// answer did not carry. /// /// Stored as one unit by `Catalog::set_enrichment`, and recorded as asked even /// when both are empty — a film has no episode list, and without the record it /// would be asked again on every match run. #[derive(Debug, Clone, Default, PartialEq)] pub struct Enrichment { pub episodes: Vec, /// A 16:9 picture of the whole title, when one was found. `None` leaves /// whatever backdrop the match already had. pub backdrop_url: Option, /// The title's logo, when one was found. `None` leaves whatever the match /// already had, as with the backdrop. pub logo_url: Option, /// Every provider entry the episodes were taken from, the match's own /// included. See [`TitleDetails::chain`]. pub chain: Vec, } /// 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>, /// [`EpisodeMetadata::absolute`] to the key of the entry holding it. absolute: HashMap, u32)>, /// [`EpisodeMetadata::entry_season`] and `entry_number` to the key of the /// entry holding them. by_entry: HashMap<(u32, u32), (Option, u32)>, /// Every season some entry is filed under, for deciding when the entry /// numbering may answer. stated: HashSet, } impl EpisodeGuide { pub fn new(episodes: Vec) -> Self { let mut guide = Self::default(); for episode in episodes { let key = (episode.season, episode.number); if let Some(absolute) = episode.absolute { guide.absolute.insert(absolute, key); } if let (Some(season), Some(number)) = (episode.entry_season, episode.entry_number) { guide.by_entry.insert((season, number), key); } if let Some(season) = episode.season { guide.stated.insert(season); } guide.entries.insert(key, episode); } guide } 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: /// /// 1. the season and number the filename states; /// 2. for a file in no season or in season one, the provider's own absolute /// list, and for a file in no season, season one — what makes `#057` /// line up with TMDB filing a 64-episode anime under one season; /// 3. the absolute number, into the season the file states — what makes /// `Season 2/… - 26.mkv` the second season's first episode; /// 4. the entry numbering, for a season the provider files nothing under. /// /// **No fallback crosses into another season.** Step 2 stops at season one /// and step 3 only answers inside the season the file named, because /// answering `S02E07` with season one's seventh episode 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. Step 4 /// only answers a season the provider has nothing filed under, so a release /// numbered the provider's way is never re-read the other way: Mushoku /// Tensei's second *entry* is the second half of season one, and its /// `S02E01` is season two's. 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); } if season.is_none() && let Some(found) = self.entries.get(&(Some(1), number)) { return Some(found); } if let Some(found) = self .absolute .get(&number) .and_then(|key| self.entries.get(key)) && (season.is_none() || found.season == season) { return Some(found); } let season = season?; if self.stated.contains(&season) { return None; } self.by_entry .get(&(season, number)) .and_then(|key| self.entries.get(key)) } /// Which seasons this guide actually has answers for. `None` is the /// provider's own absolute numbering. pub fn seasons(&self) -> 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, logo_url: None, rating: None, genres: Vec::new(), episodes: Some(26), url: None, details: None, } } fn episode(season: Option, number: u32, name: &str) -> EpisodeMetadata { EpisodeMetadata { season, number, absolute: None, entry_season: None, entry_number: None, name: Some(name.into()), overview: None, still_url: None, air_date: None, runtime: None, } } /// An episode as ani.zip describes one: TVDB's season and number, TVDB's /// absolute number, and its place in the AniList entry it came from. fn numbered( (season, number): (u32, u32), absolute: u32, (entry_season, entry_number): (u32, u32), name: &str, ) -> EpisodeMetadata { EpisodeMetadata { absolute: Some(absolute), entry_season: Some(entry_season), entry_number: Some(entry_number), ..episode(Some(season), number, name) } } fn name_of(guide: &EpisodeGuide, season: Option, number: u32) -> Option { guide.get(season, number).and_then(|e| e.name.clone()) } /// `Attack on Titan/Season 2/[Anime Time] Attack On Titan - 26.mkv`: a /// season folder full of absolutely-numbered files. #[test] fn an_absolute_number_in_a_season_folder_finds_that_seasons_episode() { let guide = EpisodeGuide::new(vec![ numbered((1, 1), 1, (1, 1), "To You, in 2000 Years"), numbered((2, 1), 26, (2, 1), "Beast Titan"), ]); assert_eq!(name_of(&guide, Some(2), 26).as_deref(), Some("Beast Titan")); // And with no season at all, the absolute number is the whole answer. assert_eq!(name_of(&guide, None, 26).as_deref(), Some("Beast Titan")); } /// The absolute fallback must not carry a file into a season it did not /// name: `S03E13` of a show whose 13th episode is in season one is not that /// episode. #[test] fn an_absolute_number_never_answers_for_another_season() { let guide = EpisodeGuide::new(vec![ numbered((1, 13), 13, (1, 13), "Season one's thirteenth"), numbered((3, 1), 38, (3, 1), "Smoke Signal"), ]); assert_eq!(guide.get(Some(3), 13), None); } /// Mushishi: TVDB files both halves of *Zoku Shou* as season two, and the /// release calls the second half `S03`. Season three exists nowhere at the /// provider, so the entry numbering answers it. #[test] fn a_season_the_provider_does_not_have_is_answered_by_entry() { let guide = EpisodeGuide::new(vec![ numbered((1, 1), 1, (1, 1), "The Green Seat"), numbered((2, 1), 28, (2, 1), "The Hand That Caresses the Night"), numbered((2, 11), 39, (3, 1), "Lightning's End"), ]); assert_eq!( name_of(&guide, Some(3), 1).as_deref(), Some("Lightning's End") ); assert_eq!( name_of(&guide, Some(2), 1).as_deref(), Some("The Hand That Caresses the Night") ); } /// Mushoku Tensei: AniList's second entry is the second half of TVDB's /// season one. A release numbered TVDB's way names season two `S02`, and /// the entry numbering must not answer it with that second half. #[test] fn the_entry_numbering_never_overrides_a_season_the_provider_has() { let guide = EpisodeGuide::new(vec![ numbered((1, 12), 12, (2, 1), "Cour two's first"), numbered((2, 1), 26, (3, 1), "Season two's first"), ]); assert_eq!( name_of(&guide, Some(2), 1).as_deref(), Some("Season two's first") ); // A second-season episode the provider does not list is unanswered, // not answered from the entry numbering. assert_eq!(guide.get(Some(2), 2), 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)); } }