Files
proton-stream/crates/pstr-meta/src/service.rs
T

449 lines
16 KiB
Rust

//! Looking a title up, once, and remembering the answer.
//!
//! ```text
//! Title ──▶ catalog cache ──hit──▶ MetadataRecord
//! │ miss / stale
//! ▼
//! provider.search ──▶ matching::best ──▶ store ──▶ MetadataRecord
//! ```
//!
//! Two rules govern what gets written back, and they are the difference between
//! a library that settles down and one that hammers a third party forever:
//!
//! * **A miss is stored.** "The provider looked and had nothing" is an answer.
//! Without storing it, every render of the grid re-asks for every unmatched
//! title — the trap `proton-drive-linux`'s photo grid hit.
//! * **A failure is not.** A timeout, a 500, a rate-limit or a missing API key
//! are failures to *ask*. Storing them as misses would blank a title for days
//! over a minute of trouble, so they propagate and leave the title askable.
//!
//! Nothing here is requested unless [`MetadataConfig::enabled`] is set. See the
//! note in `pstr_core::metadata` on why that is off by default.
use std::sync::Arc;
use pstr_core::library::{Title, TitleKind, title_key};
use pstr_core::metadata::{
EpisodeMetadata, MetadataConfig, MetadataRecord, ProviderId, TitleMetadata,
};
use crate::anilist::AniList;
use crate::error::{Error, Result};
use crate::matching::{self, Query};
use crate::provider::Provider;
use crate::tmdb::Tmdb;
/// How long to wait on a provider.
///
/// Short: this is decoration. A poster that takes fifteen seconds to arrive has
/// already lost its argument with the placeholder that is on screen.
const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
/// Identifies this client to the providers, which both ask for it.
const USER_AGENT: &str = concat!("proton-stream/", env!("CARGO_PKG_VERSION"));
/// The configured provider, resolved.
enum Source {
AniList(AniList),
Tmdb(Tmdb),
}
impl Source {
fn id(&self) -> ProviderId {
match self {
Self::AniList(provider) => provider.id(),
Self::Tmdb(provider) => provider.id(),
}
}
async fn search(&self, query: &Query) -> Result<Vec<matching::Candidate>> {
match self {
Self::AniList(provider) => provider.search(query).await,
Self::Tmdb(provider) => provider.search(query).await,
}
}
async fn episodes(&self, title: &TitleMetadata) -> Result<Vec<EpisodeMetadata>> {
match self {
Self::AniList(provider) => provider.episodes(title).await,
Self::Tmdb(provider) => provider.episodes(title).await,
}
}
fn seasons_are_separate_entries(&self) -> bool {
match self {
Self::AniList(provider) => provider.seasons_are_separate_entries(),
Self::Tmdb(provider) => provider.seasons_are_separate_entries(),
}
}
}
/// Metadata lookups, against one provider.
///
/// Cheap to clone — the HTTP client inside is a connection pool that wants
/// sharing, not duplicating.
#[derive(Clone)]
pub struct MetadataService {
source: Arc<Source>,
http: reqwest::Client,
}
impl MetadataService {
/// Build a service for `config`.
///
/// `api_key` is only consulted for a provider that needs one; for AniList it
/// is ignored, and passing `None` there is not an error.
pub fn new(config: &MetadataConfig, api_key: Option<String>) -> Result<Self> {
let http = reqwest::Client::builder()
.user_agent(USER_AGENT)
.timeout(TIMEOUT)
.build()
.map_err(Error::Network)?;
let source = match config.provider {
ProviderId::AniList => Source::AniList(AniList::new(http.clone())),
ProviderId::Tmdb => Source::Tmdb(Tmdb::new(
http.clone(),
api_key.ok_or(Error::MissingApiKey(ProviderId::Tmdb))?,
config.language.clone(),
)),
};
Ok(Self {
source: Arc::new(source),
http,
})
}
pub fn provider(&self) -> ProviderId {
self.source.id()
}
/// Ask the provider about one title.
///
/// `Ok(None)` is a real answer — nothing matched well enough — and the
/// caller should store it. An `Err` is not, and the caller should not.
pub async fn lookup(&self, title: &Title) -> Result<Option<TitleMetadata>> {
let mut query = Query::new(title.name.clone(), title.year, title.kind);
// Nothing in the files numbered itself, so the kind came from counting
// them — see `Query::kind_known`.
if !title.states_its_numbering() {
query = query.with_guessed_kind();
}
let candidates = self.source.search(&query).await?;
let found = candidates.len();
let best = matching::best(&query, candidates);
tracing::debug!(
"{}: {found} candidates from {}, {}",
title.name,
self.provider().label(),
match &best {
Some(found) => format!("matched {:?}", found.name),
None => "no match".to_string(),
}
);
Ok(best)
}
/// The same lookup, as a record ready to store — misses included.
pub async fn record(&self, title: &Title) -> Result<MetadataRecord> {
let metadata = self.lookup(title).await?;
Ok(MetadataRecord {
title_key: title_key(&title.name),
provider: self.provider(),
metadata,
fetched_at: now(),
manual: false,
})
}
/// Everything the provider thinks `name` might be, unscored and in its own
/// order.
///
/// The escape hatch from [`matching::best`], for a viewer picking an entry
/// by hand. Nothing is filtered here and nothing is ranked: the floor exists
/// to stop the *matcher* guessing, and a person reading the list is not
/// guessing. The `Fate/stay night [Heaven's Feel]` trilogy is the case that
/// wants it — three films in one folder, matched against a provider that
/// files each of them separately, where no single entry is the right answer
/// and only the viewer knows which one they meant.
///
/// Takes a kind because the providers key their search on it, and no year:
/// the point of a hand search is that the library's own guesses are what
/// went wrong.
pub async fn search(&self, name: &str, kind: TitleKind) -> Result<Vec<TitleMetadata>> {
let query = Query::new(name.trim().to_string(), None, kind).with_guessed_kind();
if query.name.is_empty() {
return Ok(Vec::new());
}
let candidates = self.source.search(&query).await?;
tracing::debug!(
"hand search {:?}: {} candidates from {}",
query.name,
candidates.len(),
self.provider().label()
);
Ok(candidates
.into_iter()
.map(|candidate| candidate.metadata)
.collect())
}
/// A record for an entry the viewer picked themselves.
///
/// Marked [`MetadataRecord::manual`], which is what keeps the next match run
/// — including a forced one — from undoing it.
pub fn chosen(&self, title_key: String, found: TitleMetadata) -> MetadataRecord {
MetadataRecord {
title_key,
provider: self.provider(),
metadata: Some(found),
fetched_at: now(),
manual: true,
}
}
/// What the provider lists as the episodes of a title it matched.
///
/// Only ever called for a title that already has a match, because the
/// provider's own id is what it takes — there is no second search here, and
/// nothing about the library goes out that the match did not already send.
/// An empty list is a real answer and belongs in the catalog: a film has no
/// episodes, and asking again per render is the trap `MetadataRecord`
/// exists to avoid.
pub async fn episodes(&self, title: &TitleMetadata) -> Result<Vec<EpisodeMetadata>> {
let episodes = self.source.episodes(title).await?;
tracing::debug!(
"{}: {} episodes from {}",
title.name,
episodes.len(),
self.provider().label()
);
Ok(episodes)
}
/// The episode list for a whole title, with a failure treated as an empty
/// one.
///
/// Deliberately not fatal to the title it belongs to: a poster and a
/// synopsis that arrived are worth keeping even when the episode request
/// was the one that hit the rate limit. The empty result is not cached as
/// an answer — the next match run asks again — because the only thing that
/// triggers one is a viewer pressing the button.
///
/// The match itself only ever answers for *one* entry, and on a provider
/// that files each sequel separately — AniList — that entry is season one.
/// So each further season of the title is searched for by name and its
/// episodes are tagged with the season they came from; without that,
/// seasons two and three have no episode names at all. See
/// [`MetadataService::season_episodes`].
pub async fn title_episodes(
&self,
title: &Title,
found: &TitleMetadata,
) -> Vec<EpisodeMetadata> {
let mut episodes = self
.episodes(found)
.await
.inspect_err(|error| tracing::warn!("episodes for {}: {error}", found.name))
.unwrap_or_default();
if !self.splits_seasons() {
return episodes;
}
// Season one is what the title's own match already answered for.
let later: Vec<u32> = title
.seasons
.iter()
.filter_map(|season| season.number)
.filter(|number| *number > 1)
.collect();
for season in later {
match self.season_episodes(title, season).await {
Ok(found) => episodes.extend(found),
Err(error) => {
tracing::warn!("episodes for {} season {season}: {error}", title.name);
}
}
}
episodes
}
/// Whether this provider files a sequel as its own entry, so that a title
/// with several seasons has to be searched for once per season.
pub fn splits_seasons(&self) -> bool {
self.source.seasons_are_separate_entries()
}
/// The episodes of one season of `title`, for a provider that files each
/// season separately.
///
/// A second search, not a second guess: the title's own match is season
/// one's entry, and asking it about episode one of season two would answer
/// with episode one of season one. So the season is searched for by the
/// name the provider itself uses — `Oshi no Ko 2nd Season`, and `Oshi no Ko
/// Season 2` when that finds nothing, which are the two conventions between
/// them covering everything AniList carries.
///
/// A miss is an empty list rather than an error: a season the provider has
/// never heard of is ordinary, and it leaves those rows named by their
/// filenames exactly as they were before.
pub async fn season_episodes(
&self,
title: &Title,
season: u32,
) -> Result<Vec<EpisodeMetadata>> {
if !self.splits_seasons() {
return Ok(Vec::new());
}
let mut found = None;
for name in season_names(&title.name, season) {
// No year: a sequel airs years after the series the library named
// the folder for, so carrying the title's year over would penalise
// the very entry being looked for.
let query = Query::new(name, None, title.kind);
let candidates = self.source.search(&query).await?;
if let Some(matched) = matching::best(&query, candidates) {
found = Some(matched);
break;
}
}
let Some(found) = found else {
tracing::debug!("{}: no entry for season {season}", title.name);
return Ok(Vec::new());
};
let episodes = self.source.episodes(&found).await?;
tracing::debug!(
"{} season {season}: {} episodes from {:?}",
title.name,
episodes.len(),
found.name
);
// The entry numbers its own episodes from one; what makes them season
// two's is which entry they came from, and that has to be recorded here
// or nothing can look them up again.
Ok(episodes
.into_iter()
.map(|episode| EpisodeMetadata {
season: Some(season),
..episode
})
.collect())
}
/// Download one piece of artwork.
///
/// Separate from the lookup because artwork is fetched from a CDN on a
/// different schedule to the metadata that names it — a cached record still
/// needs its poster on a machine that has never had one.
pub async fn artwork(&self, url: &str) -> Result<Vec<u8>> {
let response = self.http.get(url).send().await?;
let status = response.status();
if !status.is_success() {
return Err(Error::Http(format!("artwork answered {status}")));
}
Ok(response.bytes().await?.to_vec())
}
}
/// What to search for when looking for one season of a series, best first.
///
/// `2nd Season` is AniList's own convention and matches its romaji titles
/// outright; `Season 2` is what its English titles and most western libraries
/// use. Season one is never searched for this way — that is the title's own
/// match.
fn season_names(title: &str, season: u32) -> Vec<String> {
vec![
format!("{title} {} Season", ordinal(season)),
format!("{title} Season {season}"),
]
}
/// `2` → `2nd`. English ordinals, because that is what the provider writes.
fn ordinal(number: u32) -> String {
let suffix = match (number % 10, number % 100) {
(_, 11..=13) => "th",
(1, _) => "st",
(2, _) => "nd",
(3, _) => "rd",
_ => "th",
};
format!("{number}{suffix}")
}
/// Whether a stored answer can be used as-is.
pub fn is_usable(record: Option<&MetadataRecord>, provider: ProviderId) -> bool {
record.is_some_and(|record| record.is_fresh(now(), provider))
}
fn now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|since| since.as_secs() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn anilist_needs_no_api_key_and_tmdb_does() {
let anilist = MetadataConfig {
enabled: true,
provider: ProviderId::AniList,
..MetadataConfig::default()
};
assert!(MetadataService::new(&anilist, None).is_ok());
let tmdb = MetadataConfig {
provider: ProviderId::Tmdb,
..anilist
};
assert!(matches!(
MetadataService::new(&tmdb, None),
Err(Error::MissingApiKey(ProviderId::Tmdb))
));
assert!(MetadataService::new(&tmdb, Some("key".into())).is_ok());
}
/// The names a season is searched for by, in the order they are tried.
#[test]
fn a_season_is_searched_for_the_way_the_provider_names_it() {
assert_eq!(
season_names("Oshi no Ko", 2),
vec!["Oshi no Ko 2nd Season", "Oshi no Ko Season 2"]
);
assert_eq!(ordinal(1), "1st");
assert_eq!(ordinal(3), "3rd");
assert_eq!(ordinal(4), "4th");
// The teens are the ones a naive rule gets wrong, and a show does reach
// them: `11th` is not `11st`.
assert_eq!(ordinal(11), "11th");
assert_eq!(ordinal(12), "12th");
assert_eq!(ordinal(13), "13th");
assert_eq!(ordinal(21), "21st");
}
/// A record from the other provider is never usable — switching provider has
/// to re-ask, including about titles the previous one found nothing for.
#[test]
fn a_stored_answer_is_only_usable_for_the_provider_that_gave_it() {
let record = MetadataRecord {
title_key: "cowboy bebop".into(),
provider: ProviderId::AniList,
metadata: None,
fetched_at: now(),
manual: false,
};
assert!(is_usable(Some(&record), ProviderId::AniList));
assert!(!is_usable(Some(&record), ProviderId::Tmdb));
assert!(!is_usable(None, ProviderId::AniList));
}
}