Add TMDB metadata client and Fetch Data: real episode titles/air dates via TMDB, with a Settings modal for the API token

This commit is contained in:
Renamr Scaffold
2026-08-29 03:01:19 +00:00
parent bab9bfa1ec
commit d1fc4039a7
12 changed files with 1231 additions and 27 deletions
+8
View File
@@ -20,4 +20,12 @@ serde_json = "1"
# are adapted to rely on \b word boundaries instead).
regex = "1"
# TMDB metadata client ("Fetch Data"). rustls instead of the default openssl-backed TLS
# so this doesn't need system OpenSSL dev headers on every platform we target.
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
tokio = { version = "1", features = ["rt", "macros"] }
[dev-dependencies]
# Local HTTP mock server so the TMDB client's request/response handling can be unit
# tested without live network access or a real API key.
mockito = "1"
+1
View File
@@ -8,6 +8,7 @@
pub mod format;
pub mod matching;
pub mod rename;
pub mod web;
use rhai::Dynamic;
use std::collections::HashMap;
+5
View File
@@ -0,0 +1,5 @@
//! 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.
pub mod tmdb;
+307
View File
@@ -0,0 +1,307 @@
//! A small client for [TMDB](https://www.themoviedb.org/)'s v3 API: search for a TV
//! show by name, then look up a specific episode's real title/air date.
//!
//! Ports the *lookup* half of what `net.filebot.web.TheMovieDBClient` did in the
//! original; this is intentionally scoped to TV episodes only for v1 (movies are a
//! straightforward follow-up using the parallel `/search/movie` + `/movie/{id}`
//! endpoints, not yet added). Authenticates with a TMDB **API Read Access Token**
//! (the Bearer-token method TMDB documents as the current default) rather than the
//! older `api_key` query parameter.
use serde::Deserialize;
use thiserror::Error;
const DEFAULT_BASE_URL: &str = "https://api.themoviedb.org/3";
#[derive(Debug, Error)]
pub enum TmdbError {
#[error("network error talking to TMDB: {0}")]
Network(String),
#[error("TMDB API error (status {status}): {message}")]
Api { status: u16, message: String },
#[error("no TMDB match found for \"{0}\"")]
NotFound(String),
}
#[derive(Debug, Clone, Deserialize)]
struct SearchTvResponse {
#[serde(default)]
results: Vec<TvResult>,
}
/// One TMDB search hit. Only the fields Renamr actually uses are kept.
#[derive(Debug, Clone, Deserialize)]
pub struct TvResult {
pub id: u64,
pub name: String,
}
#[derive(Debug, Clone, Deserialize)]
struct SeasonResponse {
#[serde(default)]
episodes: Vec<EpisodeResult>,
}
#[derive(Debug, Clone, Deserialize)]
struct EpisodeResult {
episode_number: i64,
name: String,
#[serde(default)]
air_date: Option<String>,
}
/// Real metadata for one episode, resolved from TMDB - contrast with
/// `matching::EpisodeGuess`, which only has whatever a filename pattern implied.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EpisodeInfo {
/// TMDB's canonical show name (may differ in punctuation/capitalization from
/// whatever the local filename guess produced).
pub series_name: String,
pub episode_title: String,
pub air_date: Option<String>,
}
pub struct TmdbClient {
http: reqwest::Client,
token: String,
base_url: String,
}
impl TmdbClient {
pub fn new(token: impl Into<String>) -> Self {
Self {
http: reqwest::Client::new(),
token: token.into(),
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 TMDB 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, TmdbError> {
let url = format!("{}{path}", self.base_url);
let response = self
.http
.get(&url)
.bearer_auth(&self.token)
.query(query)
.send()
.await
.map_err(|e| TmdbError::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(TmdbError::Api { status, message });
}
response
.json::<T>()
.await
.map_err(|e| TmdbError::Network(e.to_string()))
}
/// Searches for a TV show by name. Results come back TMDB-ranked (relevance /
/// popularity); callers wanting "the best guess" should just take the first one.
pub async fn search_tv(&self, query: &str) -> Result<Vec<TvResult>, TmdbError> {
let parsed: SearchTvResponse = self.get_json("/search/tv", &[("query", query)]).await?;
Ok(parsed.results)
}
/// Fetches every episode of one season in a single request. Prefer this over
/// calling `episode()` per-episode when looking up several episodes from the same
/// season (a whole loaded folder is usually one season) - the season endpoint
/// already returns the full episode list, so there's no need to re-fetch it per
/// episode; cache this call's result and look episodes up from it locally instead.
pub async fn season_episodes(&self, series_id: u64, season: i32) -> Result<Vec<SeasonEpisode>, TmdbError> {
let path = format!("/tv/{series_id}/season/{season}");
let parsed: SeasonResponse = self.get_json(&path, &[]).await?;
Ok(parsed
.episodes
.into_iter()
.map(|e| SeasonEpisode {
episode_number: e.episode_number as i32,
title: e.name,
air_date: e.air_date,
})
.collect())
}
/// Looks up one episode's real title/air date from a known TMDB series id. Fetches
/// the whole season each call - if you need several episodes from the same season,
/// call `season_episodes()` once instead and look them up locally.
pub async fn episode(&self, series_id: u64, season: i32, episode: i32) -> Result<EpisodeResultInfo, TmdbError> {
let episodes = self.season_episodes(series_id, season).await?;
episodes
.into_iter()
.find(|e| e.episode_number == episode)
.map(|e| EpisodeResultInfo {
episode_title: e.title,
air_date: e.air_date,
})
.ok_or_else(|| TmdbError::NotFound(format!("S{season:02}E{episode:02}")))
}
/// Convenience: search for the best-matching show by name, then fetch a specific
/// episode. Picks the first search result - good enough for v1 since TMDB already
/// ranks by relevance; a proper fuzzy-matching disambiguation pass (porting
/// `EpisodeMetrics`) is still on the roadmap for when a search returns multiple
/// plausible shows.
pub async fn lookup_episode(
&self,
series_query: &str,
season: i32,
episode: i32,
) -> Result<EpisodeInfo, TmdbError> {
let results = self.search_tv(series_query).await?;
let best = results
.into_iter()
.next()
.ok_or_else(|| TmdbError::NotFound(series_query.to_string()))?;
let info = self.episode(best.id, season, episode).await?;
Ok(EpisodeInfo {
series_name: best.name,
episode_title: info.episode_title,
air_date: info.air_date,
})
}
}
/// Just the episode-specific fields, before the series name (known by the caller of
/// `episode()`, which already has a series id) is attached.
pub struct EpisodeResultInfo {
pub episode_title: String,
pub air_date: Option<String>,
}
/// One episode of a season, as returned by `season_episodes()`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SeasonEpisode {
pub episode_number: i32,
pub title: String,
pub air_date: Option<String>,
}
#[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/tv")
.match_query(mockito::Matcher::UrlEncoded("query".into(), "Breaking Bad".into()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"results":[{"id":1396,"name":"Breaking Bad"}]}"#)
.create_async()
.await;
let _season_mock = server
.mock("GET", "/tv/1396/season/5")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"episodes":[
{"episode_number":13,"name":"To'hajiilee","air_date":"2013-09-08"},
{"episode_number":14,"name":"Ozymandias","air_date":"2013-09-15"}
]}"#,
)
.create_async()
.await;
let client = TmdbClient::new("test-token").with_base_url(server.url());
let info = client.lookup_episode("Breaking Bad", 5, 14).await.unwrap();
assert_eq!(
info,
EpisodeInfo {
series_name: "Breaking Bad".to_string(),
episode_title: "Ozymandias".to_string(),
air_date: Some("2013-09-15".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/tv")
.match_query(mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"results":[]}"#)
.create_async()
.await;
let client = TmdbClient::new("test-token").with_base_url(server.url());
let result = client.lookup_episode("Some Nonexistent Show", 1, 1).await;
assert!(matches!(result, Err(TmdbError::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/tv")
.match_query(mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"results":[{"id":42,"name":"Some Show"}]}"#)
.create_async()
.await;
let _season_mock = server
.mock("GET", "/tv/42/season/1")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"episodes":[{"episode_number":1,"name":"Pilot","air_date":"2020-01-01"}]}"#)
.create_async()
.await;
let client = TmdbClient::new("test-token").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(TmdbError::NotFound(_))));
}
#[tokio::test]
async fn surfaces_an_invalid_token_as_an_api_error() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", "/search/tv")
.match_query(mockito::Matcher::Any)
.with_status(401)
.with_header("content-type", "application/json")
.with_body(r#"{"status_message":"Invalid API key: You must be granted a valid key.","status_code":7}"#)
.create_async()
.await;
let client = TmdbClient::new("bad-token").with_base_url(server.url());
let result = client.search_tv("anything").await;
match result {
Err(TmdbError::Api { status, .. }) => assert_eq!(status, 401),
other => panic!("expected Api error, got {other:?}"),
}
}
}