Add TVmaze as a second, user-selectable TV metadata source for Fetch Data
TV episodes can now resolve against either TMDB (default) or TVmaze - picked via a segmented source picker in the renamed "Fetch Data Settings" modal. TVmaze is free and needs no API token at all; movies still always resolve against TMDB regardless of the choice, since TVmaze has no movie catalog. - New TvmazeClient (crates/aurora-curator-core/src/web/tvmaze.rs): search + season/episode lookup against api.tvmaze.com, filtering out unnumbered specials rather than panicking on them (TVmaze returns every season's episodes in one flat list, unlike TMDB's per-season endpoint). - New TvMetadataSource setting (settings.rs), persisted alongside the TMDB token, defaulting to Tmdb for schema-evolution safety. - metadata.rs now branches TV fetches through an AnyTvClient wrapper so the existing per-(series, season) caching loop doesn't care which source produced a result; the movie-fetch path is untouched. - Frontend: TMDB/TVmaze picker in Fetch Data Settings, persisted on change; Fetch Data's token gating now only requires a TMDB token when it's actually needed (movies always, TV only when TMDB is the chosen source) instead of unconditionally blocking on it. cargo test --workspace: 88/88 passing (81 + 7 new TVmaze + 3 new settings). npm run build passing. Playwright verification: 18/18 checks (picker renders and persists across reload, TV Fetch Data succeeds on TVmaze with zero TMDB token configured, movies still correctly demand a token even with TVmaze selected, switching back to TMDB still works end-to-end).
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
//! Online metadata clients ("Fetch Data"). Currently just TMDB; the original FileBot
|
||||
//! supports ~40 sources (`net.filebot.web.*`) - TMDB was picked first since it's the
|
||||
//! most widely useful for both TV and movies and has a simple, well-documented API.
|
||||
//! Online metadata clients ("Fetch Data"). TMDB (TV + movies) and TVmaze (TV only, no
|
||||
//! API key needed) so far; the original FileBot supports ~40 sources
|
||||
//! (`net.filebot.web.*`) - TMDB was picked first since it's the most widely useful for
|
||||
//! both TV and movies, TVmaze second since it's a zero-setup second TV source (see
|
||||
//! `tvmaze`'s module doc for why movies aren't in scope for it).
|
||||
|
||||
pub mod tmdb;
|
||||
pub mod tvmaze;
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
//! A small client for [TVmaze](https://www.tvmaze.com/)'s free REST API: search for a
|
||||
//! TV show by name, then look up a specific episode's real title/air date.
|
||||
//!
|
||||
//! TV-only (TVmaze doesn't have a movie catalog at all, unlike TMDB) and, unlike TMDB,
|
||||
//! needs **no API key** - every endpoint used here is fully public. That makes it a
|
||||
//! zero-setup second TV metadata source: a user who doesn't want to register for a TMDB
|
||||
//! token, or whose show just isn't on TMDB, can pick this instead in Fetch Data
|
||||
//! Settings. See `crate::web::tmdb` for the TMDB client this mirrors the shape of.
|
||||
|
||||
use serde::Deserialize;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::similarity::best_title_match;
|
||||
|
||||
const DEFAULT_BASE_URL: &str = "https://api.tvmaze.com";
|
||||
|
||||
/// Same threshold TMDB's client uses (see `tmdb::NAME_MATCH_THRESHOLD`) - kept
|
||||
/// identical so switching sources doesn't change how forgiving/strict name matching
|
||||
/// feels to the user.
|
||||
const NAME_MATCH_THRESHOLD: f64 = 0.35;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum TvmazeError {
|
||||
#[error("network error talking to TVmaze: {0}")]
|
||||
Network(String),
|
||||
#[error("TVmaze API error (status {status}): {message}")]
|
||||
Api { status: u16, message: String },
|
||||
#[error("no TVmaze match found for \"{0}\"")]
|
||||
NotFound(String),
|
||||
}
|
||||
|
||||
/// One TVmaze search hit's show data. Only the fields Aurora Curator actually uses are
|
||||
/// kept - TVmaze's `/search/shows` wraps each show in a `{score, show: {...}}` envelope
|
||||
/// (the score is TVmaze's own relevance ranking, not used here - `best_title_match`
|
||||
/// re-ranks by name similarity instead, same as the TMDB client does).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct TvResult {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct SearchHit {
|
||||
show: TvResult,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct EpisodeResult {
|
||||
season: i32,
|
||||
number: Option<i32>,
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
airdate: Option<String>,
|
||||
}
|
||||
|
||||
/// Real metadata for one episode, resolved from TVmaze - same shape as
|
||||
/// `tmdb::EpisodeInfo` so the Tauri glue can treat either source's result identically.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EpisodeInfo {
|
||||
pub series_name: String,
|
||||
pub episode_title: String,
|
||||
pub air_date: Option<String>,
|
||||
}
|
||||
|
||||
/// One episode of a season, as returned by `season_episodes()` - mirrors
|
||||
/// `tmdb::SeasonEpisode`'s field names so both clients' results map onto the same local
|
||||
/// shape in the Tauri glue.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SeasonEpisode {
|
||||
pub episode_number: i32,
|
||||
pub title: String,
|
||||
pub air_date: Option<String>,
|
||||
}
|
||||
|
||||
pub struct TvmazeClient {
|
||||
http: reqwest::Client,
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl Default for TvmazeClient {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl TvmazeClient {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
http: reqwest::Client::new(),
|
||||
base_url: DEFAULT_BASE_URL.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Overrides the API base URL - used by tests to point at a local mock server
|
||||
/// instead of the real TVmaze API.
|
||||
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
|
||||
self.base_url = base_url.into();
|
||||
self
|
||||
}
|
||||
|
||||
async fn get_json<T: for<'de> Deserialize<'de>>(
|
||||
&self,
|
||||
path: &str,
|
||||
query: &[(&str, &str)],
|
||||
) -> Result<T, TvmazeError> {
|
||||
let url = format!("{}{path}", self.base_url);
|
||||
let response = self
|
||||
.http
|
||||
.get(&url)
|
||||
.query(query)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| TvmazeError::Network(e.to_string()))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status().as_u16();
|
||||
let message = response.text().await.unwrap_or_default();
|
||||
return Err(TvmazeError::Api { status, message });
|
||||
}
|
||||
|
||||
response
|
||||
.json::<T>()
|
||||
.await
|
||||
.map_err(|e| TvmazeError::Network(e.to_string()))
|
||||
}
|
||||
|
||||
/// Searches for a TV show by name. Results come back TVmaze-ranked by its own
|
||||
/// relevance score; callers wanting "the best guess" should use
|
||||
/// `search_tv_best_match` instead, same convention as the TMDB client.
|
||||
pub async fn search_tv(&self, query: &str) -> Result<Vec<TvResult>, TvmazeError> {
|
||||
let hits: Vec<SearchHit> = self.get_json("/search/shows", &[("q", query)]).await?;
|
||||
Ok(hits.into_iter().map(|h| h.show).collect())
|
||||
}
|
||||
|
||||
/// Fetches EVERY episode of a show (all seasons) in a single request - TVmaze's
|
||||
/// `/shows/{id}/episodes` endpoint doesn't take a season filter, unlike TMDB's
|
||||
/// per-season endpoint, so this fetches once and filters locally. Prefer calling
|
||||
/// this once and reusing the result over calling it per-season/per-episode.
|
||||
async fn all_episodes(&self, series_id: u64) -> Result<Vec<EpisodeResult>, TvmazeError> {
|
||||
let path = format!("/shows/{series_id}/episodes");
|
||||
self.get_json(&path, &[]).await
|
||||
}
|
||||
|
||||
/// Fetches one season's episodes for a known TVmaze show id - mirrors
|
||||
/// `TmdbClient::season_episodes`'s signature/return shape even though the
|
||||
/// underlying TVmaze call fetches (and then filters) every season at once.
|
||||
pub async fn season_episodes(&self, series_id: u64, season: i32) -> Result<Vec<SeasonEpisode>, TvmazeError> {
|
||||
let episodes = self.all_episodes(series_id).await?;
|
||||
Ok(episodes
|
||||
.into_iter()
|
||||
.filter(|e| e.season == season && e.number.is_some())
|
||||
.map(|e| SeasonEpisode {
|
||||
episode_number: e.number.expect("filtered to Some above"),
|
||||
title: e.name,
|
||||
air_date: e.airdate,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Searches for a TV show by name and returns the single best-matching result (see
|
||||
/// `similarity::best_title_match`), or `None` if nothing in the results is a
|
||||
/// plausible match at all. Mirrors `TmdbClient::search_tv_best_match`.
|
||||
pub async fn search_tv_best_match(&self, query: &str) -> Result<Option<TvResult>, TvmazeError> {
|
||||
let results = self.search_tv(query).await?;
|
||||
Ok(best_title_match(query, &results, |r| r.name.as_str(), NAME_MATCH_THRESHOLD).cloned())
|
||||
}
|
||||
|
||||
/// Convenience: search for the best-matching show by name, then fetch a specific
|
||||
/// episode. Same disambiguation approach as `TmdbClient::lookup_episode` - picks
|
||||
/// whichever result's name is textually closest to `series_query` rather than
|
||||
/// trusting TVmaze's own top-ranked result, and reports `NotFound` instead of
|
||||
/// guessing when nothing is a plausible match.
|
||||
pub async fn lookup_episode(
|
||||
&self,
|
||||
series_query: &str,
|
||||
season: i32,
|
||||
episode: i32,
|
||||
) -> Result<EpisodeInfo, TvmazeError> {
|
||||
let best = self
|
||||
.search_tv_best_match(series_query)
|
||||
.await?
|
||||
.ok_or_else(|| TvmazeError::NotFound(series_query.to_string()))?;
|
||||
|
||||
let episodes = self.season_episodes(best.id, season).await?;
|
||||
let found = episodes
|
||||
.into_iter()
|
||||
.find(|e| e.episode_number == episode)
|
||||
.ok_or_else(|| TvmazeError::NotFound(format!("S{season:02}E{episode:02}")))?;
|
||||
|
||||
Ok(EpisodeInfo {
|
||||
series_name: best.name,
|
||||
episode_title: found.title,
|
||||
air_date: found.air_date,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn looks_up_a_real_episode_end_to_end() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let _search_mock = server
|
||||
.mock("GET", "/search/shows")
|
||||
.match_query(mockito::Matcher::UrlEncoded("q".into(), "Girls".into()))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"[{"score":10.5,"show":{"id":139,"name":"Girls"}}]"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
// TVmaze returns every season's episodes in one call - the mock reflects that,
|
||||
// with episodes from two different seasons mixed together to prove filtering
|
||||
// by season actually happens rather than just taking whatever's returned.
|
||||
let _episodes_mock = server
|
||||
.mock("GET", "/shows/139/episodes")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"[
|
||||
{"season":1,"number":1,"name":"Pilot","airdate":"2012-04-15"},
|
||||
{"season":2,"number":1,"name":"It's About Time","airdate":"2013-01-13"},
|
||||
{"season":2,"number":2,"name":"I Get Ideas","airdate":"2013-01-20"}
|
||||
]"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = TvmazeClient::new().with_base_url(server.url());
|
||||
let info = client.lookup_episode("Girls", 2, 2).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
info,
|
||||
EpisodeInfo {
|
||||
series_name: "Girls".to_string(),
|
||||
episode_title: "I Get Ideas".to_string(),
|
||||
air_date: Some("2013-01-20".to_string()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reports_not_found_when_search_has_no_results() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let _mock = server
|
||||
.mock("GET", "/search/shows")
|
||||
.match_query(mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body("[]")
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = TvmazeClient::new().with_base_url(server.url());
|
||||
let result = client.lookup_episode("Some Nonexistent Show", 1, 1).await;
|
||||
|
||||
assert!(matches!(result, Err(TvmazeError::NotFound(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reports_not_found_when_the_season_has_no_such_episode() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let _search_mock = server
|
||||
.mock("GET", "/search/shows")
|
||||
.match_query(mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"[{"score":1.0,"show":{"id":42,"name":"Some Show"}}]"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let _episodes_mock = server
|
||||
.mock("GET", "/shows/42/episodes")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"[{"season":1,"number":1,"name":"Pilot","airdate":"2020-01-01"}]"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = TvmazeClient::new().with_base_url(server.url());
|
||||
// Season 1 only has episode 1 in this mock - asking for episode 99 should miss.
|
||||
let result = client.lookup_episode("Some Show", 1, 99).await;
|
||||
|
||||
assert!(matches!(result, Err(TvmazeError::NotFound(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn picks_the_exact_name_match_over_a_more_popular_but_wrong_result() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let _search_mock = server
|
||||
.mock("GET", "/search/shows")
|
||||
.match_query(mockito::Matcher::UrlEncoded("q".into(), "Chuck".into()))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"[{"score":20.0,"show":{"id":1,"name":"Chucky"}},{"score":5.0,"show":{"id":2,"name":"Chuck"}}]"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let _episodes_mock = server
|
||||
.mock("GET", "/shows/2/episodes")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"[{"season":1,"number":1,"name":"Chuck Versus the Intersect","airdate":"2007-09-24"}]"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = TvmazeClient::new().with_base_url(server.url());
|
||||
let info = client.lookup_episode("Chuck", 1, 1).await.unwrap();
|
||||
|
||||
assert_eq!(info.series_name, "Chuck");
|
||||
assert_eq!(info.episode_title, "Chuck Versus the Intersect");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reports_not_found_when_no_search_result_is_a_plausible_name_match() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let _mock = server
|
||||
.mock("GET", "/search/shows")
|
||||
.match_query(mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"[{"score":1.0,"show":{"id":9,"name":"Totally Unrelated Program"}}]"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = TvmazeClient::new().with_base_url(server.url());
|
||||
let result = client.lookup_episode("Zzyzx Nonexistent Show", 1, 1).await;
|
||||
|
||||
assert!(matches!(result, Err(TvmazeError::NotFound(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn episodes_with_no_episode_number_are_skipped_rather_than_panicking() {
|
||||
// TVmaze occasionally has "special"/non-numbered entries (e.g. season 0
|
||||
// specials, or episodes with number:null) mixed in with regular episodes -
|
||||
// make sure filtering by season doesn't choke on one, since a naive `.unwrap()`
|
||||
// on `number` would panic on real data.
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let _search_mock = server
|
||||
.mock("GET", "/search/shows")
|
||||
.match_query(mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"[{"score":1.0,"show":{"id":7,"name":"Some Show"}}]"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let _episodes_mock = server
|
||||
.mock("GET", "/shows/7/episodes")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"[
|
||||
{"season":1,"number":null,"name":"Uncategorized Special","airdate":null},
|
||||
{"season":1,"number":1,"name":"Pilot","airdate":"2021-01-01"}
|
||||
]"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = TvmazeClient::new().with_base_url(server.url());
|
||||
let info = client.lookup_episode("Some Show", 1, 1).await.unwrap();
|
||||
|
||||
assert_eq!(info.episode_title, "Pilot");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn surfaces_a_server_error_as_an_api_error() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let _mock = server
|
||||
.mock("GET", "/search/shows")
|
||||
.match_query(mockito::Matcher::Any)
|
||||
.with_status(503)
|
||||
.with_header("content-type", "text/plain")
|
||||
.with_body("service unavailable")
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = TvmazeClient::new().with_base_url(server.url());
|
||||
let result = client.search_tv("anything").await;
|
||||
|
||||
match result {
|
||||
Err(TvmazeError::Api { status, .. }) => assert_eq!(status, 503),
|
||||
other => panic!("expected Api error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user