This commit is contained in:
nvrl committed 2026-08-02 19:37:03 +02:00
commit a1b345a029
80 files changed
+28033

No files matched your search

+519
View File
@@ -0,0 +1,519 @@
//! AniList, over its public GraphQL API.
//!
//! No API key and no account, which is why it is the default: turning
//! enrichment on costs the viewer a decision about privacy, and it should not
//! also cost them a signup. The trade is coverage — AniList is anime and nothing
//! else, so a library of films wants [`crate::tmdb`].
//!
//! One query shape is used for everything: a `Page` of `media` matching the
//! search string, with every name AniList knows the show by. The alias list is
//! the whole reason this works — see [`crate::matching`].
use pstr_core::library::TitleKind;
use pstr_core::metadata::{EpisodeMetadata, ProviderId, TitleMetadata};
use serde::Deserialize;
use crate::error::{Error, Result};
use crate::matching::{Candidate, Query};
use crate::provider::Provider;
const ENDPOINT: &str = "https://graphql.anilist.co";
/// How many answers to score. AniList's own relevance ordering is good, and the
/// right title is essentially always in the first handful; asking for more costs
/// them bandwidth and buys nothing.
const PAGE_SIZE: u32 = 8;
/// AniList scores out of 100 and the rest of the app works out of 10.
const SCORE_SCALE: f32 = 10.0;
const SEARCH: &str = r#"
query ($search: String, $perPage: Int) {
Page(page: 1, perPage: $perPage) {
media(search: $search, type: ANIME, sort: SEARCH_MATCH) {
id
title { romaji english native }
synonyms
description(asHtml: false)
startDate { year }
coverImage { extraLarge large }
bannerImage
averageScore
genres
episodes
format
siteUrl
}
}
}
"#;
/// Episode titles, such as AniList has them.
///
/// `streamingEpisodes` is the only place AniList keeps them — there is no
/// per-episode overview in its API at all — and the entries are what the
/// streaming sites published: `"Episode 57 - The Immortal Legion"`, in airing
/// order, occasionally with a gap. So the number is read out of the title where
/// it says one, and only falls back to the position in the list.
const EPISODES: &str = r#"
query ($id: Int) {
Media(id: $id, type: ANIME) {
episodes
streamingEpisodes { title thumbnail }
}
}
"#;
pub struct AniList {
http: reqwest::Client,
}
impl AniList {
pub fn new(http: reqwest::Client) -> Self {
Self { http }
}
}
impl Provider for AniList {
fn id(&self) -> ProviderId {
ProviderId::AniList
}
/// A sequel is its own entry here, numbering its episodes from one.
fn seasons_are_separate_entries(&self) -> bool {
true
}
async fn search(&self, query: &Query) -> Result<Vec<Candidate>> {
let data: Option<SearchData> = self
.query(serde_json::json!({
"query": SEARCH,
"variables": { "search": query.name, "perPage": PAGE_SIZE },
}))
.await?;
Ok(data
.map(|data| data.page.media)
.unwrap_or_default()
.into_iter()
.map(Media::into_candidate)
.collect())
}
async fn episodes(&self, title: &TitleMetadata) -> Result<Vec<EpisodeMetadata>> {
// The id came from our own search response, so a non-numeric one is a
// record written by some other version — not worth an error, and there
// is nothing to ask about.
let Ok(id) = title.remote_id.parse::<i64>() else {
return Ok(Vec::new());
};
let data: Option<MediaData> = self
.query(serde_json::json!({
"query": EPISODES,
"variables": { "id": id },
}))
.await?;
Ok(data
.and_then(|data| data.media)
.map(|media| media.into_episodes())
.unwrap_or_default())
}
}
impl AniList {
/// One GraphQL request, with AniList's two ways of failing — an HTTP status
/// and an `errors` array under a 200 — folded into one.
async fn query<T: serde::de::DeserializeOwned>(
&self,
body: serde_json::Value,
) -> Result<Option<T>> {
let response = self.http.post(ENDPOINT).json(&body).send().await?;
let status = response.status();
if !status.is_success() {
// 429 is the one worth naming: AniList rate-limits by the minute,
// and a caller that knows it was throttled can back off rather than
// cache a miss for a title that would have matched.
return Err(if status.as_u16() == 429 {
Error::RateLimited
} else {
Error::Http(format!("AniList answered {status}"))
});
}
let payload: Response<T> = response.json().await?;
if let Some(errors) = payload.errors
&& !errors.is_empty()
{
let message = errors
.into_iter()
.map(|error| error.message)
.collect::<Vec<_>>()
.join("; ");
return Err(Error::Http(format!("AniList: {message}")));
}
Ok(payload.data)
}
}
#[derive(Deserialize)]
struct Response<T> {
data: Option<T>,
errors: Option<Vec<GraphQlError>>,
}
#[derive(Deserialize)]
struct GraphQlError {
message: String,
}
#[derive(Deserialize)]
struct SearchData {
#[serde(rename = "Page")]
page: Page,
}
#[derive(Deserialize)]
struct MediaData {
#[serde(rename = "Media")]
media: Option<EpisodeMedia>,
}
#[derive(Deserialize)]
struct EpisodeMedia {
#[serde(rename = "streamingEpisodes", default)]
streaming_episodes: Vec<StreamingEpisode>,
}
#[derive(Deserialize)]
struct StreamingEpisode {
title: Option<String>,
thumbnail: Option<String>,
}
impl EpisodeMedia {
fn into_episodes(self) -> Vec<EpisodeMetadata> {
self.streaming_episodes
.into_iter()
.enumerate()
.map(|(index, episode)| {
let raw = episode.title.unwrap_or_default();
let (number, name) = split_numbering(&raw);
EpisodeMetadata {
// AniList counts straight through, with no seasons: a
// sequel is a separate entry, not a second season.
season: None,
// The position is the fallback and not the answer: the
// lists have gaps, and an off-by-one here would caption
// every episode of a show with the next one's name.
number: number.unwrap_or(index as u32 + 1),
name: name.filter(|name| !name.is_empty()),
// AniList has no per-episode synopsis to give.
overview: None,
still_url: episode.thumbnail,
air_date: None,
}
})
.collect()
}
}
/// Split `"Episode 57 - The Immortal Legion"` into its number and its name.
///
/// Both parts are optional: plenty of entries are just `"Episode 12"`, and a
/// few are only a name.
fn split_numbering(title: &str) -> (Option<u32>, Option<String>) {
let trimmed = title.trim();
let rest = trimmed
.strip_prefix("Episode ")
.or_else(|| trimmed.strip_prefix("episode "))
.or_else(|| trimmed.strip_prefix("EP"))
.or_else(|| trimmed.strip_prefix("E"));
let Some(rest) = rest else {
return (None, Some(trimmed.to_string()).filter(|t| !t.is_empty()));
};
let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
if digits.is_empty() {
return (None, Some(trimmed.to_string()));
}
let name = rest[digits.len()..]
.trim_start()
.trim_start_matches(['-', '–', ':'])
.trim()
.to_string();
(digits.parse().ok(), Some(name).filter(|n| !n.is_empty()))
}
#[derive(Deserialize)]
struct Page {
#[serde(default)]
media: Vec<Media>,
}
#[derive(Deserialize)]
struct Media {
id: i64,
title: Title,
#[serde(default)]
synonyms: Vec<String>,
description: Option<String>,
#[serde(rename = "startDate")]
start_date: Option<FuzzyDate>,
#[serde(rename = "coverImage")]
cover_image: Option<CoverImage>,
#[serde(rename = "bannerImage")]
banner_image: Option<String>,
#[serde(rename = "averageScore")]
average_score: Option<f32>,
#[serde(default)]
genres: Vec<String>,
episodes: Option<u32>,
format: Option<String>,
#[serde(rename = "siteUrl")]
site_url: Option<String>,
}
#[derive(Deserialize)]
struct Title {
romaji: Option<String>,
english: Option<String>,
native: Option<String>,
}
#[derive(Deserialize)]
struct FuzzyDate {
year: Option<u32>,
}
#[derive(Deserialize)]
struct CoverImage {
#[serde(rename = "extraLarge")]
extra_large: Option<String>,
large: Option<String>,
}
impl Media {
fn into_candidate(self) -> Candidate {
let aliases: Vec<String> = [
self.title.english.clone(),
self.title.romaji.clone(),
self.title.native.clone(),
]
.into_iter()
.flatten()
.chain(self.synonyms)
.collect();
// English first when there is one: it is what the viewer's library is
// most likely named after, and romaji is the fallback AniList itself
// uses.
let name = self
.title
.english
.clone()
.or_else(|| self.title.romaji.clone())
.or_else(|| self.title.native.clone())
.unwrap_or_else(|| format!("AniList #{}", self.id));
let original_name = self
.title
.native
.clone()
.or_else(|| self.title.romaji.clone())
.filter(|original| *original != name);
let cover = self
.cover_image
.and_then(|cover| cover.extra_large.or(cover.large));
Candidate {
metadata: TitleMetadata {
provider: ProviderId::AniList,
remote_id: self.id.to_string(),
name,
original_name,
overview: self.description.map(|text| strip_markup(&text)),
year: self.start_date.and_then(|date| date.year),
kind: kind_of(self.format.as_deref()),
poster_url: cover,
// Usually `null`. AniList only has banners for the better-known
// shows, which is why a tile has to cope with a poster's shape.
backdrop_url: self.banner_image,
rating: self.average_score.map(|score| score / SCORE_SCALE),
genres: self.genres,
episodes: self.episodes,
url: self.site_url,
},
aliases,
// Null on anything AniList has not seen air yet, which is not the
// same as "it is a film". See `Candidate::kind_known`.
kind_known: self.format.is_some(),
}
}
}
/// AniList's formats, reduced to the two kinds a library has.
///
/// Anything episodic is a series; a film, a one-shot special or an unknown
/// format is not. An unknown format reading as a film costs a badge, and the
/// match itself only nudges on kind — see [`crate::matching::score`].
fn kind_of(format: Option<&str>) -> TitleKind {
match format {
Some("TV" | "TV_SHORT" | "ONA" | "OVA" | "SPECIAL") => TitleKind::Series,
_ => TitleKind::Film,
}
}
/// AniList descriptions carry a little HTML even with `asHtml: false` — `<br>`
/// mostly, and the occasional `<i>`. egui draws no markup, so tags become
/// nothing and `<br>` becomes the line break it stands for.
fn strip_markup(text: &str) -> String {
let text = text
.replace("<br>", "\n")
.replace("<br/>", "\n")
.replace("<br />", "\n");
let mut out = String::with_capacity(text.len());
let mut in_tag = false;
for character in text.chars() {
match character {
'<' => in_tag = true,
'>' => in_tag = false,
_ if !in_tag => out.push(character),
_ => {}
}
}
out.trim().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn episodic_formats_are_series_and_everything_else_is_not() {
assert_eq!(kind_of(Some("TV")), TitleKind::Series);
assert_eq!(kind_of(Some("OVA")), TitleKind::Series);
assert_eq!(kind_of(Some("MOVIE")), TitleKind::Film);
assert_eq!(kind_of(None), TitleKind::Film);
}
#[test]
fn markup_becomes_text_and_breaks_become_newlines() {
assert_eq!(
strip_markup("A <i>story</i>.<br>Then more.<br />And more."),
"A story.\nThen more.\nAnd more."
);
assert_eq!(strip_markup(" plain "), "plain");
}
/// The alias list is what makes a romaji-named library match an
/// English-named entry, so it has to hold every name AniList gave.
#[test]
fn a_candidate_carries_every_name_the_show_is_known_by() {
let media: Media = serde_json::from_str(
r#"{
"id": 16498,
"title": {"romaji": "Shingeki no Kyojin", "english": "Attack on Titan",
"native": "進撃の巨人"},
"synonyms": ["AoT"],
"description": "Several hundred years ago…",
"startDate": {"year": 2013},
"coverImage": {"extraLarge": "big.jpg", "large": "small.jpg"},
"bannerImage": null,
"averageScore": 84,
"genres": ["Action"],
"episodes": 25,
"format": "TV",
"siteUrl": "https://anilist.co/anime/16498"
}"#,
)
.expect("parse");
let candidate = media.into_candidate();
assert_eq!(candidate.metadata.name, "Attack on Titan");
assert_eq!(
candidate.metadata.original_name.as_deref(),
Some("進撃の巨人")
);
assert!(
candidate
.aliases
.contains(&"Shingeki no Kyojin".to_string())
);
assert!(candidate.aliases.contains(&"AoT".to_string()));
// Out of 10, not out of 100.
assert_eq!(candidate.metadata.rating, Some(8.4));
assert_eq!(candidate.metadata.poster_url.as_deref(), Some("big.jpg"));
}
#[test]
fn an_episode_title_gives_up_its_number_and_its_name() {
assert_eq!(
split_numbering("Episode 57 - The Immortal Legion"),
(Some(57), Some("The Immortal Legion".to_string()))
);
assert_eq!(split_numbering("Episode 12"), (Some(12), None));
// Some entries are only a name, and some sites write it differently.
assert_eq!(
split_numbering("The Day of the Beginning"),
(None, Some("The Day of the Beginning".to_string()))
);
assert_eq!(
split_numbering("E3 – Whatever"),
(Some(3), Some("Whatever".to_string()))
);
assert_eq!(split_numbering(""), (None, None));
}
#[test]
fn episode_numbers_come_from_the_titles_and_not_from_the_order() {
// AniList lists what streaming sites published, and those lists have
// gaps. Numbering by position would caption episode 4 as episode 3.
let media: EpisodeMedia = serde_json::from_str(
r#"{"streamingEpisodes": [
{"title": "Episode 1 - Fullmetal Alchemist", "thumbnail": "one.jpg"},
{"title": "Episode 3 - City of Heresy", "thumbnail": null}
]}"#,
)
.expect("parse");
let episodes = media.into_episodes();
assert_eq!(episodes[0].number, 1);
assert_eq!(episodes[0].name.as_deref(), Some("Fullmetal Alchemist"));
assert_eq!(episodes[0].still_url.as_deref(), Some("one.jpg"));
assert_eq!(episodes[1].number, 3);
// AniList counts straight through; nothing here is a season.
assert!(episodes.iter().all(|episode| episode.season.is_none()));
}
#[test]
fn an_untitled_list_still_numbers_its_episodes() {
let media: EpisodeMedia = serde_json::from_str(
r#"{"streamingEpisodes": [{"title": null, "thumbnail": null},
{"title": "", "thumbnail": null}]}"#,
)
.expect("parse");
let episodes = media.into_episodes();
assert_eq!(episodes[0].number, 1);
assert_eq!(episodes[1].number, 2);
assert!(episodes.iter().all(|episode| episode.name.is_none()));
}
/// Missing fields are the normal case for a recently added entry, and must
/// not fail the whole page of results.
#[test]
fn an_entry_with_almost_nothing_in_it_still_parses() {
let media: Media = serde_json::from_str(
r#"{"id": 1, "title": {"romaji": null, "english": null, "native": null}}"#,
)
.expect("parse");
let candidate = media.into_candidate();
assert_eq!(candidate.metadata.name, "AniList #1");
assert!(candidate.aliases.is_empty());
}
}
+39
View File
@@ -0,0 +1,39 @@
//! One error type for looking a title up.
use pstr_core::metadata::ProviderId;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("network: {0}")]
Network(#[from] reqwest::Error),
/// The provider answered, and the answer was not one.
#[error("{0}")]
Http(String),
/// The provider is throttling.
///
/// Its own variant because the caller must treat it differently from every
/// other failure: a rate-limited lookup has to be retried later, never
/// cached as "this title has no match". Caching a throttle would blank a
/// title for days over a burst that lasted a minute.
#[error("the provider is rate-limiting; try again shortly")]
RateLimited,
/// The chosen provider needs an API key and none is stored.
#[error("{} needs an API key", .0.label())]
MissingApiKey(ProviderId),
/// Enrichment is off. Not a failure — the answer to "look this up" when the
/// viewer has not agreed to lookups is that there is nothing to do.
#[error("metadata lookups are turned off")]
Disabled,
#[error("config: {0}")]
Config(String),
#[error(transparent)]
Core(#[from] pstr_core::Error),
}
pub type Result<T> = std::result::Result<T, Error>;
+41
View File
@@ -0,0 +1,41 @@
//! Matching a library's titles against a metadata provider.
//!
//! What this crate is for is the gap between what a filename says and what a
//! poster wall wants: a share filled by the Proton Drive Linux client carries no
//! thumbnails at all — Proton renders those at upload time and that client
//! attaches none — so without enrichment every tile in the grid is a pair of
//! initials on a grey rectangle. See `docs/DEVELOPMENT.md`.
//!
//! ```text
//! Title ──▶ Query ──▶ Provider::search ──▶ [Candidate] ──▶ matching::best
//! (anilist | tmdb) │
//! ▼
//! TitleMetadata
//! ```
//!
//! Three things are worth knowing before touching any of it:
//!
//! * **It is off by default and says why.** Enrichment sends the titles in
//! someone's library to a third party. The switch is theirs, the reasoning is
//! in `pstr_core::metadata`, and nothing here runs until it is on.
//! * **A wrong match is worse than no match.** The wrong poster does not look
//! like a bug, it looks like the library is wrong — so
//! [`matching::MATCH_FLOOR`] is high and a weak field yields nothing.
//! * **Misses are cached and failures are not.** See [`service`].
//!
//! The model — [`pstr_core::metadata::TitleMetadata`] and friends — deliberately
//! lives in `pstr-core`, so the catalog can store it and the UI can draw it
//! without either depending on this crate.
pub mod anilist;
pub mod error;
pub mod matching;
pub mod provider;
pub mod service;
pub mod settings;
pub mod tmdb;
pub use error::{Error, Result};
pub use matching::{Candidate, Query};
pub use provider::Provider;
pub use service::MetadataService;
+372
View File
@@ -0,0 +1,372 @@
//! Deciding which of a provider's answers is the title you actually have.
//!
//! A release-named library does not hand you clean titles. `[SubsGroup] Shingeki
//! no Kyojin S3` and `Attack on Titan (2013)` are the same show, and the
//! provider knows it under four names in three scripts. So matching is scoring,
//! not lookup, and the two things that make it work are:
//!
//! * **Every alias counts.** A candidate is scored on its best name, not its
//! canonical one. AniList's romaji, English, native and synonym lists exist
//! precisely because none of them is *the* title.
//! * **A bad match is worse than none.** Putting the poster of a different show
//! on a title is a mistake nobody can see is a mistake — it just looks like
//! the library is wrong. So the floor is high, and anything under it is a
//! cached miss rather than the best of a bad set.
//!
//! The similarity itself is Sørensen–Dice over character bigrams. It is not the
//! most sophisticated measure available, but it has the properties that matter
//! here: it is order-insensitive enough to survive `Bebop, Cowboy`, length-aware
//! enough not to match `Gintama` to `Gintama°: Enchousen`, and it needs no
//! tuning.
use pstr_core::library::{TitleKind, title_key};
use pstr_core::metadata::TitleMetadata;
/// A candidate's score has to clear this to be used.
///
/// Chosen from what the failure modes cost rather than from a curve: at 0.72 a
/// missing subtitle or a swapped season number still matches, while two
/// different shows sharing a franchise name — the common near-miss in an anime
/// library — do not. See the tests, which pin both sides.
pub const MATCH_FLOOR: f32 = 0.72;
/// The title being looked up.
#[derive(Debug, Clone)]
pub struct Query {
/// The title as the library has it, already stripped of release tags by
/// [`pstr_core::naming`].
pub name: String,
pub year: Option<u32>,
pub kind: TitleKind,
}
impl Query {
pub fn new(name: impl Into<String>, year: Option<u32>, kind: TitleKind) -> Self {
Self {
name: name.into(),
year,
kind,
}
}
}
/// One of a provider's answers, with every name it is known by.
#[derive(Debug, Clone)]
pub struct Candidate {
pub metadata: TitleMetadata,
/// Romaji, English, native, synonyms — whatever the provider had. The
/// canonical name is included by the providers that build these.
pub aliases: Vec<String>,
/// Whether the provider actually stated what kind of thing this is.
///
/// [`TitleMetadata::kind`] has no "unknown", because a badge has to say
/// something — but scoring must not read that fallback as a fact. AniList
/// leaves `format` null on anything unaired, and treating that as "film"
/// then *penalising* it against a series is how an announced-but-unreleased
/// entry loses to a worse match. Absence of evidence, not evidence.
pub kind_known: bool,
}
/// How well `candidate` answers `query`, from 0 to 1.
pub fn score(query: &Query, candidate: &Candidate) -> f32 {
let wanted = title_key(&query.name);
if wanted.is_empty() {
return 0.0;
}
let best = candidate
.aliases
.iter()
.map(|alias| similarity(&wanted, &title_key(alias)))
.fold(0.0_f32, f32::max);
// Adjustments, not tie-breakers: they move a score across the floor only
// when it was already close to it.
let mut score = best;
match (query.year, candidate.metadata.year) {
// Same year is strong evidence for two titles that already read alike.
(Some(wanted), Some(found)) if wanted == found => score += 0.06,
// A year apart is normal — a season airing across New Year, a provider
// dating by première rather than release. Further apart is not.
(Some(wanted), Some(found)) if wanted.abs_diff(found) > 1 => score -= 0.15,
_ => {}
}
if candidate.kind_known && query.kind != candidate.metadata.kind {
// A film and a series of the same name are usually genuinely related —
// an adaptation, a compilation — so this is a nudge, not a veto. And
// only when the provider said; see `Candidate::kind_known`.
score -= 0.05;
}
score.clamp(0.0, 1.0)
}
/// The best answer above [`MATCH_FLOOR`], if there is one.
///
/// Ties go to whichever the provider listed first, and that is load-bearing
/// rather than arbitrary. Franchises share synonyms — every *Evangelion* entry
/// on AniList carries `EVANGELION:30+;`, so a library folder named plainly
/// `Evangelion` scores several of them identically — and when the text cannot
/// separate them, the provider's own relevance ordering is the only evidence
/// left. Taking the last one instead, which is what `Iterator::max_by` does,
/// picks the *least* relevant of the tied set: it is how a folder of the 1995
/// series came back matched to a 2026 anniversary screening.
pub fn best(query: &Query, candidates: Vec<Candidate>) -> Option<TitleMetadata> {
let mut best: Option<(TitleMetadata, f32)> = None;
for candidate in candidates {
let score = score(query, &candidate);
// Strictly greater: an equal score leaves the earlier candidate in
// place, which is what keeps the provider's ordering as the tie-break.
if best.as_ref().is_none_or(|(_, best)| score > *best) {
best = Some((candidate.metadata, score));
}
}
let (metadata, score) = best?;
if score < MATCH_FLOOR {
tracing::debug!(
"no match for {:?}: best candidate scored {score:.2}, floor is {MATCH_FLOOR}",
query.name
);
return None;
}
Some(metadata)
}
/// Sørensen–Dice over character bigrams, on already-normalised strings.
///
/// Identical strings are 1.0 and a one-character string is compared directly,
/// since it has no bigrams to compare.
fn similarity(left: &str, right: &str) -> f32 {
if left == right {
return 1.0;
}
if left.is_empty() || right.is_empty() {
return 0.0;
}
let left: Vec<char> = left.chars().collect();
let right: Vec<char> = right.chars().collect();
if left.len() < 2 || right.len() < 2 {
return 0.0;
}
// Multiset intersection: a bigram appearing twice on the left and once on
// the right counts once, which is what keeps `aaaa` from matching `aa`.
let mut theirs: Vec<[char; 2]> = right.windows(2).map(|pair| [pair[0], pair[1]]).collect();
let mut shared = 0usize;
for pair in left.windows(2) {
let pair = [pair[0], pair[1]];
if let Some(position) = theirs.iter().position(|other| *other == pair) {
theirs.swap_remove(position);
shared += 1;
}
}
let total = (left.len() - 1) + (right.len() - 1);
(2.0 * shared as f32) / total as f32
}
#[cfg(test)]
mod tests {
use super::*;
use pstr_core::metadata::ProviderId;
fn candidate(name: &str, aliases: &[&str], year: Option<u32>, kind: TitleKind) -> Candidate {
Candidate {
metadata: TitleMetadata {
provider: ProviderId::AniList,
remote_id: "1".into(),
name: name.into(),
original_name: None,
overview: None,
year,
kind,
poster_url: None,
backdrop_url: None,
rating: None,
genres: Vec::new(),
episodes: None,
url: None,
},
aliases: std::iter::once(name)
.chain(aliases.iter().copied())
.map(str::to_string)
.collect(),
kind_known: true,
}
}
#[test]
fn an_identical_title_scores_perfectly() {
let query = Query::new("Cowboy Bebop", None, TitleKind::Series);
let candidate = candidate("Cowboy Bebop", &[], None, TitleKind::Series);
assert!(score(&query, &candidate) >= 1.0);
}
/// The case the alias list exists for: the library has the romaji name and
/// the provider's canonical name is the English one, or the reverse.
#[test]
fn a_match_on_an_alias_is_as_good_as_one_on_the_name() {
let query = Query::new("Shingeki no Kyojin", None, TitleKind::Series);
let candidate = candidate(
"Attack on Titan",
&["Shingeki no Kyojin"],
Some(2013),
TitleKind::Series,
);
assert!(score(&query, &candidate) >= MATCH_FLOOR);
}
/// Punctuation and case are the parser's business, not the matcher's — and
/// `title_key` has already removed them.
#[test]
fn punctuation_and_case_do_not_affect_the_score() {
let query = Query::new("FULLMETAL ALCHEMIST: BROTHERHOOD", None, TitleKind::Series);
let candidate = candidate(
"Fullmetal Alchemist: Brotherhood",
&[],
None,
TitleKind::Series,
);
assert!(score(&query, &candidate) >= 1.0);
}
/// The mistake worth preventing: two entries in one franchise, where the
/// wrong poster looks like a working feature rather than a bug.
#[test]
fn a_different_entry_in_the_same_franchise_does_not_match() {
let query = Query::new("Gintama", None, TitleKind::Series);
let candidate = candidate("Gintama°: Enchousen", &[], None, TitleKind::Series);
assert!(
score(&query, &candidate) < MATCH_FLOOR,
"scored {}",
score(&query, &candidate)
);
}
#[test]
fn a_wholly_unrelated_title_does_not_match() {
let query = Query::new("Cowboy Bebop", None, TitleKind::Series);
let candidate = candidate("Neon Genesis Evangelion", &[], None, TitleKind::Series);
assert!(score(&query, &candidate) < MATCH_FLOOR);
}
/// A decade apart is a different work with a reused name — a remake, or a
/// coincidence. One year apart is the same work dated differently.
#[test]
fn a_distant_year_is_penalised_and_a_near_one_is_not() {
let query = Query::new("Bebop", Some(1998), TitleKind::Series);
let near = candidate("Bebop", &[], Some(1999), TitleKind::Series);
let far = candidate("Bebop", &[], Some(2021), TitleKind::Series);
assert!(score(&query, &near) > score(&query, &far));
assert!(score(&query, &near) >= MATCH_FLOOR);
}
#[test]
fn the_best_candidate_wins_and_a_weak_field_yields_nothing() {
let query = Query::new("Cowboy Bebop", Some(1998), TitleKind::Series);
let found = best(
&query,
vec![
candidate(
"Neon Genesis Evangelion",
&[],
Some(1995),
TitleKind::Series,
),
candidate("Cowboy Bebop", &[], Some(1998), TitleKind::Series),
],
);
assert_eq!(found.map(|found| found.name), Some("Cowboy Bebop".into()));
assert!(
best(
&query,
vec![candidate(
"Serial Experiments Lain",
&[],
None,
TitleKind::Series
)]
)
.is_none()
);
assert!(best(&query, Vec::new()).is_none());
}
/// Repeated bigrams must not inflate a score: without multiset counting,
/// a short repetitive name matches a long repetitive one perfectly.
#[test]
fn repeated_bigrams_are_counted_once_each() {
assert!(similarity("aaaa", "aa") < 1.0);
}
/// Measured against the real thing: searching AniList for `Evangelion`
/// returns several entries that all carry the synonym `EVANGELION:30+;`,
/// so they score identically and nothing in the text can separate them.
/// The provider's own ordering is then the only evidence there is, and
/// taking the last of the tied set — which is what `max_by` does — picked
/// a 2026 anniversary screening over the series the folder was named for.
#[test]
fn tied_candidates_are_settled_by_the_providers_own_ordering() {
let query = Query::new("Evangelion", None, TitleKind::Series);
let tied = vec![
candidate(
"Evangelion (Shinsaku Series)",
&["EVANGELION:30+;"],
None,
TitleKind::Series,
),
candidate(
"Evangelion 30th Anniversary Special Screening",
&["EVANGELION:30+;"],
Some(2026),
TitleKind::Series,
),
];
// Both clear the floor on the shared synonym; the first one listed wins.
assert!(score(&query, &tied[0]) >= MATCH_FLOOR);
assert_eq!(
best(&query, tied).map(|found| found.name),
Some("Evangelion (Shinsaku Series)".into())
);
}
/// The other half of the Evangelion mismatch, and the more subtle half.
///
/// AniList leaves `format` null on anything unaired. [`TitleMetadata::kind`]
/// has to say *something*, so it falls back to `Film` — and scoring must not
/// then read that fallback as the provider having said "film" and dock the
/// candidate for it. Doing so cost the top-ranked entry 0.05 and handed the
/// match to a 2026 anniversary screening that happened to have a format.
#[test]
fn an_unstated_kind_is_not_penalised_the_way_a_wrong_one_is() {
let query = Query::new("Evangelion", None, TitleKind::Series);
let mut unstated = candidate("Evangelion", &[], None, TitleKind::Film);
unstated.kind_known = false;
let stated = candidate("Evangelion", &[], None, TitleKind::Film);
assert!(score(&query, &unstated) > score(&query, &stated));
assert_eq!(score(&query, &unstated), 1.0);
}
/// The tie-break must not override the score: a later candidate that
/// genuinely matches better still wins.
#[test]
fn a_better_later_candidate_still_beats_an_earlier_weaker_one() {
let query = Query::new("Cowboy Bebop", None, TitleKind::Series);
let found = best(
&query,
vec![
candidate("Cowboy Bebop: The Movie", &[], None, TitleKind::Film),
candidate("Cowboy Bebop", &[], None, TitleKind::Series),
],
);
assert_eq!(found.map(|found| found.name), Some("Cowboy Bebop".into()));
}
}
+52
View File
@@ -0,0 +1,52 @@
//! What every metadata source has to be able to do.
use pstr_core::metadata::{EpisodeMetadata, ProviderId, TitleMetadata};
use crate::error::Result;
use crate::matching::{Candidate, Query};
/// A source of titles.
///
/// Deliberately narrow: search, and the episode list of something search
/// already matched. Both are requests to a third party, and neither happens
/// until the viewer has turned enrichment on — but note what the second one
/// does *not* need: it takes the id search returned, so it sends the provider
/// nothing about the library it did not already learn from the match.
///
/// Not `#[async_trait]`: this is only ever used through a concrete type or an
/// enum, never as `dyn Provider`, so the native `async fn` costs nothing here.
pub trait Provider: Send + Sync {
fn id(&self) -> ProviderId;
/// Whether a sequel is a *separate entry* rather than a second season of
/// the same one.
///
/// AniList works that way — `Oshi no Ko`, `Oshi no Ko 2nd Season` and
/// `Oshi no Ko 3rd Season` are three ids, each numbering its episodes from
/// one — so a library that files all three under one title has to search
/// again per season or seasons two upwards get no episode names at all (and,
/// worse, would be answered with season one's). TMDB files them as seasons
/// of one show and its episode list already carries season numbers, so
/// searching per season there would only find the wrong entry.
fn seasons_are_separate_entries(&self) -> bool;
/// Everything the provider thinks `query` might be, in its own order.
///
/// Scoring is not the provider's job — it returns candidates and
/// [`crate::matching::best`] decides. That is what keeps the "wrong poster"
/// threshold in one place rather than one per provider.
fn search(
&self,
query: &Query,
) -> impl std::future::Future<Output = Result<Vec<Candidate>>> + Send;
/// Every episode the provider lists for a title it already matched.
///
/// An empty list is a real answer — a film has no episodes, and plenty of
/// series entries have none listed — and the caller caches it rather than
/// asking again on the next render.
fn episodes(
&self,
title: &TitleMetadata,
) -> impl std::future::Future<Output = Result<Vec<EpisodeMetadata>>> + Send;
}
+347
View File
@@ -0,0 +1,347 @@
//! 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, 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 query = Query::new(title.name.clone(), title.year, title.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(),
})
}
/// 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)
}
/// 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(),
};
assert!(is_usable(Some(&record), ProviderId::AniList));
assert!(!is_usable(Some(&record), ProviderId::Tmdb));
assert!(!is_usable(None, ProviderId::AniList));
}
}
+140
View File
@@ -0,0 +1,140 @@
//! Reading and writing the enrichment settings, and the API key.
//!
//! The settings file sits beside `shares.json` and is written the same way —
//! atomically, and never overwritten when it will not parse. The API key does
//! *not* go in it: it is a credential, it belongs in the OS credential store,
//! and the same rule that keeps share fragments out of the config keeps this out
//! too.
use pstr_core::config::{AppDirs, read_json, write_json};
use pstr_core::metadata::{MetadataConfig, ProviderId};
use crate::error::{Error, Result};
/// The credential-store service name, shared with [`pstr_core::shares`]. Keys
/// are namespaced by provider, so two providers' keys can coexist and switching
/// back does not mean typing one in again.
const KEYRING_SERVICE: &str = "proton-stream";
fn settings_file(dirs: &AppDirs) -> std::path::PathBuf {
dirs.config.join("metadata.json")
}
fn keyring_entry(provider: ProviderId) -> Result<keyring::Entry> {
keyring::Entry::new(KEYRING_SERVICE, &format!("metadata-{}", provider.as_str()))
.map_err(|error| Error::Config(format!("open the credential store: {error}")))
}
/// The stored settings, or the defaults on a first run.
pub fn load(dirs: &AppDirs) -> Result<MetadataConfig> {
Ok(read_json(&settings_file(dirs))?.unwrap_or_default())
}
/// Write the settings.
pub fn save(dirs: &AppDirs, config: &MetadataConfig) -> Result<()> {
write_json(&settings_file(dirs), config)?;
Ok(())
}
/// The stored API key for a provider, if there is one.
///
/// A credential store that is locked or absent is reported as *no key* rather
/// than as an error: the caller's next move is the same either way — tell the
/// viewer the provider needs a key — and a keyring failure phrased as one is a
/// worse message than that.
pub fn api_key(provider: ProviderId) -> Option<String> {
if !provider.needs_api_key() {
return None;
}
match keyring_entry(provider).and_then(|entry| {
entry
.get_password()
.map_err(|error| Error::Config(error.to_string()))
}) {
Ok(key) => Some(key),
Err(error) => {
tracing::debug!("no stored API key for {}: {error}", provider.label());
None
}
}
}
/// Store an API key, or forget it when `key` is empty.
pub fn set_api_key(provider: ProviderId, key: &str) -> Result<()> {
let entry = keyring_entry(provider)?;
let result = if key.trim().is_empty() {
match entry.delete_credential() {
// Clearing a key that was never set is what the viewer asked for.
Err(keyring::Error::NoEntry) => Ok(()),
other => other,
}
} else {
entry.set_password(key)
};
result.map_err(|error| Error::Config(format!("store the API key: {error}")))
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_dirs(tag: &str) -> AppDirs {
let root = std::env::temp_dir().join(format!(
"pstr-meta-{tag}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("create temp dir");
AppDirs {
config: root.clone(),
data: root.clone(),
cache: root,
}
}
#[test]
fn settings_round_trip_and_default_to_off() {
let dirs = temp_dirs("settings");
assert_eq!(load(&dirs).expect("load"), MetadataConfig::default());
assert!(!load(&dirs).expect("load").enabled);
let config = MetadataConfig {
enabled: true,
provider: ProviderId::Tmdb,
language: "de".into(),
};
save(&dirs, &config).expect("save");
assert_eq!(load(&dirs).expect("load"), config);
std::fs::remove_dir_all(&dirs.config).ok();
}
/// The key is a credential and must not be in the settings file, however
/// convenient that would be.
#[test]
fn the_settings_file_never_contains_an_api_key() {
let dirs = temp_dirs("nokey");
save(
&dirs,
&MetadataConfig {
enabled: true,
provider: ProviderId::Tmdb,
language: "en".into(),
},
)
.expect("save");
let written = std::fs::read_to_string(settings_file(&dirs)).expect("read");
assert!(!written.contains("key"), "{written}");
std::fs::remove_dir_all(&dirs.config).ok();
}
/// AniList has no key to look up, and asking for one must not reach the
/// credential store at all.
#[test]
fn a_provider_that_needs_no_key_never_has_one() {
assert_eq!(api_key(ProviderId::AniList), None);
}
}
+436
View File
@@ -0,0 +1,436 @@
//! The Movie Database, over its v3 REST API.
//!
//! Film and television, which is the coverage AniList does not have. The cost is
//! an API key: TMDB's are free but they require an account, so this is not the
//! default and the UI has somewhere to put the key before it can be chosen.
//!
//! `search/multi` rather than `search/tv` or `search/movie`: a library holds both
//! and the parser's guess at which one a title is comes from a filename, which is
//! 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.
use pstr_core::library::TitleKind;
use pstr_core::metadata::{EpisodeMetadata, ProviderId, TitleMetadata};
use serde::Deserialize;
use crate::error::{Error, Result};
use crate::matching::{Candidate, Query};
use crate::provider::Provider;
const ENDPOINT: &str = "https://api.themoviedb.org/3/search/multi";
const API_BASE: &str = "https://api.themoviedb.org/3";
/// How many seasons of episodes to ask for in one go.
///
/// TMDB's `append_to_response` takes at most twenty sub-requests, which is more
/// seasons than anything in a library actually has; a show past that gets its
/// first twenty and no error.
const MAX_APPENDED_SEASONS: usize = 20;
/// A frame from an episode. `w300` is what the row draws it at, near enough.
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.
const IMAGE_BASE: &str = "https://image.tmdb.org/t/p";
const POSTER_SIZE: &str = "w500";
const BACKDROP_SIZE: &str = "w780";
pub struct Tmdb {
http: reqwest::Client,
api_key: String,
language: String,
}
impl Tmdb {
pub fn new(http: reqwest::Client, api_key: String, language: String) -> Self {
Self {
http,
api_key,
language,
}
}
}
impl Provider for Tmdb {
fn id(&self) -> ProviderId {
ProviderId::Tmdb
}
/// One show, every season under it — and [`Self::episodes`] already returns
/// them with their season numbers.
fn seasons_are_separate_entries(&self) -> bool {
false
}
async fn search(&self, query: &Query) -> Result<Vec<Candidate>> {
if self.api_key.trim().is_empty() {
return Err(Error::MissingApiKey(ProviderId::Tmdb));
}
let response = self
.http
.get(ENDPOINT)
.query(&[
("api_key", self.api_key.as_str()),
("query", query.name.as_str()),
("language", self.language.as_str()),
("include_adult", "false"),
])
.send()
.await?;
let status = response.status();
if !status.is_success() {
return Err(match status.as_u16() {
401 => Error::MissingApiKey(ProviderId::Tmdb),
429 => Error::RateLimited,
_ => Error::Http(format!("TMDB answered {status}")),
});
}
let payload: SearchResponse = response.json().await?;
Ok(payload
.results
.into_iter()
// `search/multi` also returns people, who have neither a poster of a
// show nor a year — and whose `name` would match a title named after
// its lead.
.filter(|result| matches!(result.media_type.as_deref(), Some("tv" | "movie")))
.map(Result_::into_candidate)
.collect())
}
/// Two requests: the show, to learn which seasons exist, and then those
/// seasons appended onto one more.
///
/// `append_to_response` is why this is two rather than one-per-season — a
/// five-season show costs the same as a one-season show, which matters when
/// a library of thirty series is matched in one run.
async fn episodes(&self, title: &TitleMetadata) -> Result<Vec<EpisodeMetadata>> {
// Films have no episodes, and asking `/tv/{id}` about one answers 404
// for an id that belongs to a film.
if title.kind != TitleKind::Series {
return Ok(Vec::new());
}
let Ok(id) = title.remote_id.parse::<i64>() else {
return Ok(Vec::new());
};
let show: Show = self.get(&format!("{API_BASE}/tv/{id}"), &[]).await?;
let numbers: Vec<u32> = show
.seasons
.iter()
.map(|season| season.season_number)
// Season zero is TMDB's bin for specials and recaps, which are not
// numbered the way a release names them and would collide with the
// real season they sit beside.
.filter(|number| *number > 0)
.take(MAX_APPENDED_SEASONS)
.collect();
if numbers.is_empty() {
return Ok(Vec::new());
}
let append = numbers
.iter()
.map(|number| format!("season/{number}"))
.collect::<Vec<_>>()
.join(",");
let detail: SeasonsResponse = self
.get(
&format!("{API_BASE}/tv/{id}"),
&[("append_to_response", append.as_str())],
)
.await?;
let mut episodes = Vec::new();
for number in numbers {
let Some(season) = detail.season(number) else {
continue;
};
episodes.extend(season.episodes.iter().map(|episode| {
EpisodeMetadata {
season: Some(number),
number: episode.episode_number,
name: episode.name.clone().filter(|name| !name.is_empty()),
overview: episode.overview.clone().filter(|text| !text.is_empty()),
still_url: episode
.still_path
.as_deref()
.map(|path| image(STILL_SIZE, path)),
air_date: episode.air_date.clone().filter(|date| !date.is_empty()),
}
}));
}
Ok(episodes)
}
}
impl Tmdb {
/// One authenticated GET, with TMDB's failures named.
async fn get<T: serde::de::DeserializeOwned>(
&self,
url: &str,
extra: &[(&str, &str)],
) -> Result<T> {
if self.api_key.trim().is_empty() {
return Err(Error::MissingApiKey(ProviderId::Tmdb));
}
let mut request = self.http.get(url).query(&[
("api_key", self.api_key.as_str()),
("language", self.language.as_str()),
]);
for pair in extra {
request = request.query(&[pair]);
}
let response = request.send().await?;
let status = response.status();
if !status.is_success() {
return Err(match status.as_u16() {
401 => Error::MissingApiKey(ProviderId::Tmdb),
429 => Error::RateLimited,
_ => Error::Http(format!("TMDB answered {status}")),
});
}
Ok(response.json().await?)
}
}
/// `/tv/{id}`, for the season list alone.
#[derive(Deserialize)]
struct Show {
#[serde(default)]
seasons: Vec<SeasonSummary>,
}
#[derive(Deserialize)]
struct SeasonSummary {
season_number: u32,
}
/// The same endpoint with seasons appended. The appended objects arrive under
/// the key that asked for them — `"season/1"` — alongside every ordinary field
/// of the show, so the map is collected untyped and each season is parsed out
/// of it: typing the map as a season would make `"id": 1234` a parse error and
/// lose the whole response.
#[derive(Deserialize)]
struct SeasonsResponse {
#[serde(flatten)]
fields: std::collections::HashMap<String, serde_json::Value>,
}
impl SeasonsResponse {
fn season(&self, number: u32) -> Option<SeasonDetail> {
let value = self.fields.get(&format!("season/{number}"))?;
serde_json::from_value(value.clone())
.inspect_err(|error| tracing::debug!("TMDB season {number}: {error}"))
.ok()
}
}
#[derive(Deserialize)]
struct SeasonDetail {
#[serde(default)]
episodes: Vec<EpisodeRow>,
}
#[derive(Deserialize)]
struct EpisodeRow {
episode_number: u32,
name: Option<String>,
overview: Option<String>,
still_path: Option<String>,
air_date: Option<String>,
}
#[derive(Deserialize)]
struct SearchResponse {
#[serde(default)]
results: Vec<Result_>,
}
/// One `search/multi` hit. TMDB names the same field `name` on television and
/// `title` on film, and likewise for the dates, so both are optional here and
/// resolved after parsing.
#[derive(Deserialize)]
struct Result_ {
id: i64,
media_type: Option<String>,
name: Option<String>,
title: Option<String>,
original_name: Option<String>,
original_title: Option<String>,
overview: Option<String>,
first_air_date: Option<String>,
release_date: Option<String>,
poster_path: Option<String>,
backdrop_path: Option<String>,
vote_average: Option<f32>,
}
impl Result_ {
fn into_candidate(self) -> Candidate {
let kind = match self.media_type.as_deref() {
Some("tv") => TitleKind::Series,
_ => TitleKind::Film,
};
let name = self
.name
.clone()
.or_else(|| self.title.clone())
.unwrap_or_else(|| format!("TMDB #{}", self.id));
let original_name = self
.original_name
.clone()
.or_else(|| self.original_title.clone())
.filter(|original| *original != name);
let aliases = [
self.name,
self.title,
self.original_name,
self.original_title,
]
.into_iter()
.flatten()
.collect();
Candidate {
metadata: TitleMetadata {
provider: ProviderId::Tmdb,
remote_id: self.id.to_string(),
name,
original_name,
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)),
rating: self.vote_average.filter(|rating| *rating > 0.0),
// `search/multi` carries neither, and a detail request per
// candidate would be a round-trip per *result* rather than per
// title. Not worth it for a genre list.
genres: Vec::new(),
episodes: None,
url: Some(format!(
"https://www.themoviedb.org/{}/{}",
match kind {
TitleKind::Series => "tv",
TitleKind::Film => "movie",
},
self.id
)),
},
aliases,
// Always: `search/multi` results are filtered on `media_type`
// before they get here, so it is never the fallback.
kind_known: true,
}
}
}
fn image(size: &str, path: &str) -> String {
format!("{IMAGE_BASE}/{size}{path}")
}
/// The year out of a TMDB `YYYY-MM-DD`, tolerating the empty string it uses for
/// "unknown" and anything else that is not a date.
fn year_of(date: Option<&str>) -> Option<u32> {
date?.get(..4)?.parse().ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_year_is_taken_from_the_front_of_a_date() {
assert_eq!(year_of(Some("1998-04-03")), Some(1998));
assert_eq!(year_of(Some("")), None);
assert_eq!(year_of(Some("soon")), None);
assert_eq!(year_of(None), None);
}
/// The appended-season response carries the show's own fields beside the
/// seasons, and those must not stop the seasons parsing.
#[test]
fn seasons_are_picked_out_of_a_response_full_of_other_fields() {
let response: SeasonsResponse = serde_json::from_str(
r#"{
"id": 31911,
"name": "Fullmetal Alchemist: Brotherhood",
"number_of_seasons": 1,
"genres": [{"id": 16, "name": "Animation"}],
"season/1": {
"episodes": [
{"episode_number": 1, "name": "Fullmetal Alchemist",
"overview": "Ed and Al…", "still_path": "/still.jpg",
"air_date": "2009-04-05"},
{"episode_number": 2, "name": "The First Day",
"overview": "", "still_path": null, "air_date": null}
]
}
}"#,
)
.expect("parse");
let season = response.season(1).expect("season one");
assert_eq!(season.episodes.len(), 2);
assert_eq!(
season.episodes[0].name.as_deref(),
Some("Fullmetal Alchemist")
);
assert_eq!(season.episodes[0].still_path.as_deref(), Some("/still.jpg"));
assert!(response.season(2).is_none());
}
/// Television and film name the same things differently; both have to land
/// in the same fields.
#[test]
fn television_and_film_results_normalise_to_the_same_shape() {
let series: Result_ = serde_json::from_str(
r#"{"id": 1, "media_type": "tv", "name": "Cowboy Bebop",
"original_name": "カウボーイビバップ", "first_air_date": "1998-04-03",
"poster_path": "/p.jpg", "backdrop_path": "/b.jpg", "vote_average": 8.6}"#,
)
.expect("parse");
let series = series.into_candidate();
assert_eq!(series.metadata.kind, TitleKind::Series);
assert_eq!(series.metadata.year, Some(1998));
assert_eq!(
series.metadata.poster_url.as_deref(),
Some("https://image.tmdb.org/t/p/w500/p.jpg")
);
assert_eq!(
series.metadata.backdrop_url.as_deref(),
Some("https://image.tmdb.org/t/p/w780/b.jpg")
);
let film: Result_ = serde_json::from_str(
r#"{"id": 2, "media_type": "movie", "title": "Akira",
"release_date": "1988-07-16"}"#,
)
.expect("parse");
let film = film.into_candidate();
assert_eq!(film.metadata.kind, TitleKind::Film);
assert_eq!(film.metadata.name, "Akira");
assert_eq!(film.metadata.year, Some(1988));
}
/// 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]
fn an_unrated_title_reports_no_rating() {
let result: Result_ = serde_json::from_str(
r#"{"id": 3, "media_type": "movie", "title": "New", "vote_average": 0.0}"#,
)
.expect("parse");
assert_eq!(result.into_candidate().metadata.rating, None);
}
}