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:
@@ -8,6 +8,7 @@
|
||||
pub mod format;
|
||||
pub mod matching;
|
||||
pub mod rename;
|
||||
pub mod web;
|
||||
|
||||
use rhai::Dynamic;
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -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;
|
||||
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user