mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
init
This commit is contained in:
commit
a1b345a029
80 files changed
+28033
No files matched your search
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user