mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-04 16:57:00 +01:00
481 lines
18 KiB
Rust
481 lines
18 KiB
Rust
//! 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> {
|
||
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<String>,
|
||
pub overview: Option<String>,
|
||
pub year: Option<u32>,
|
||
pub kind: TitleKind,
|
||
/// Portrait art, around 2:3.
|
||
pub poster_url: Option<String>,
|
||
/// Landscape art, around 16:9. Much rarer than a poster, and the reason the
|
||
/// grid cannot simply prefer one shape.
|
||
pub backdrop_url: Option<String>,
|
||
/// Out of 10, however the provider scores.
|
||
pub rating: Option<f32>,
|
||
pub genres: Vec<String>,
|
||
/// How many episodes the provider thinks there are — useful next to how
|
||
/// many the library actually holds.
|
||
pub episodes: Option<u32>,
|
||
/// The title's page, for a viewer who wants the rest of it.
|
||
pub url: Option<String>,
|
||
}
|
||
|
||
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<u32>,
|
||
pub number: u32,
|
||
pub name: Option<String>,
|
||
pub overview: Option<String>,
|
||
/// A frame from the episode, around 16:9.
|
||
pub still_url: Option<String>,
|
||
/// 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<String>,
|
||
}
|
||
|
||
/// 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>, u32), EpisodeMetadata>,
|
||
}
|
||
|
||
impl EpisodeGuide {
|
||
pub fn new(episodes: Vec<EpisodeMetadata>) -> 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<u32>, 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<Option<u32>> {
|
||
self.entries
|
||
.values()
|
||
.map(|episode| episode.season)
|
||
.collect()
|
||
}
|
||
|
||
/// Every entry, for storing.
|
||
pub fn episodes(&self) -> impl Iterator<Item = &EpisodeMetadata> {
|
||
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<TitleMetadata>,
|
||
/// 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<u32>, 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));
|
||
}
|
||
}
|