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:
Renamr Scaffold
2026-08-29 10:44:12 +00:00
parent 7097b1ede7
commit 5763c6313b
7 changed files with 729 additions and 96 deletions
+6 -3
View File
@@ -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:?}"),
}
}
}