Add OMDb as a datasource: TV episodes (third option alongside TMDB/TVmaze) and movies (new second option alongside TMDB)

Movies get their own independent metadata-source setting for the first
time (previously hardcoded to TMDB) so the GUI's Fetch Data Settings now
has two separate pickers - TV episode source (TMDB/TVmaze/OMDb) and
movie source (TMDB/OMDb) - and a batch can freely mix sources across its
TV and movie halves. The -rename CLI's --db flag gained omdb alongside
tmdb/tvmaze, with movies now looked up online for both tmdb and omdb.
This commit is contained in:
Renamr Scaffold
2026-08-29 12:25:45 +00:00
parent 22a3da9f6f
commit c158befa83
11 changed files with 1234 additions and 174 deletions
+24 -7
View File
@@ -24,16 +24,18 @@
use crate::rename::{ConflictPolicy, RenameAction};
use std::path::{Path, PathBuf};
/// Which online source `--db` should resolve TV episodes against. Deliberately its own
/// small enum rather than reusing the Tauri crate's `settings::TvMetadataSource` (this
/// crate can't depend on that crate, and that enum represents a *persisted GUI
/// preference* anyway, not a per-invocation flag) - a CLI run always states its source
/// explicitly via `--db` when it wants one; it never falls back to whatever the GUI's
/// Fetch Data Settings last had selected.
/// Which online source `--db` should resolve TV episodes (and, for `Tmdb`/`Omdb`,
/// movies too - see `src-tauri/src/cli.rs`'s `run_rename`) against. Deliberately its
/// own small enum rather than reusing the Tauri crate's `settings::TvMetadataSource`/
/// `MovieMetadataSource` (this crate can't depend on that crate, and those enums
/// represent *persisted GUI preferences* anyway, not a per-invocation flag) - a CLI run
/// always states its source explicitly via `--db` when it wants one; it never falls
/// back to whatever the GUI's Fetch Data Settings last had selected.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TvDbChoice {
Tmdb,
Tvmaze,
Omdb,
}
/// A fully parsed, valid `-rename` invocation.
@@ -117,9 +119,10 @@ pub fn parse_args(args: &[String]) -> Result<CliInvocation, CliError> {
db = Some(match value.to_ascii_lowercase().as_str() {
"tmdb" => TvDbChoice::Tmdb,
"tvmaze" => TvDbChoice::Tvmaze,
"omdb" => TvDbChoice::Omdb,
other => {
return Err(CliError(format!(
"unknown --db source \"{other}\" (expected tmdb or tvmaze)"
"unknown --db source \"{other}\" (expected tmdb, tvmaze, or omdb)"
)))
}
});
@@ -298,6 +301,20 @@ mod tests {
other => panic!("expected Rename, got {other:?}"),
}
let parsed = parse_args(&[
s("-rename"),
s("--format"),
s("{n}"),
s("--db"),
s("OMDB"),
s("file.mkv"),
])
.unwrap();
match parsed {
CliInvocation::Rename(args) => assert_eq!(args.db, Some(TvDbChoice::Omdb)),
other => panic!("expected Rename, got {other:?}"),
}
let err = parse_args(&[
s("-rename"),
s("--format"),
+8 -5
View File
@@ -1,8 +1,11 @@
//! 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).
//! Online metadata clients ("Fetch Data"). TMDB (TV + movies), TVmaze (TV only, no API
//! key needed), and OMDb (TV + movies, needs a free API key) 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), OMDb third since it's the first *movie*-capable alternative to TMDB
//! (see `omdb`'s module doc).
pub mod omdb;
pub mod tmdb;
pub mod tvmaze;
+625
View File
@@ -0,0 +1,625 @@
//! A client for [OMDb](https://www.omdbapi.com/)'s REST API: search for a TV show or
//! movie by name, then look up real metadata (a specific episode's title/air date, or a
//! movie's canonical title/release date).
//!
//! A third metadata source alongside TMDB and TVmaze - and, unlike TVmaze, one that
//! covers **both** movies and TV, same as TMDB, so it slots into both of Fetch Data's
//! source pickers rather than just the TV one. Needs a free OMDb API key
//! (<https://www.omdbapi.com/apikey.aspx>), sent as a plain `apikey` query parameter on
//! every request - OMDb has no Bearer-token scheme like TMDB, just the one flat query
//! param, and (unlike TMDB's REST-path-per-resource shape) every OMDb request hits the
//! same root URL with different query parameters (`s=` search, `i=`/`t=` detail lookup,
//! `i=`+`Season=` episode listing).
//!
//! OMDb's own date fields (`Released`, both on movie details and on episodes) come back
//! as human text like `"16 Jul 2010"`, or the literal string `"N/A"` when unknown - see
//! [`parse_release_date`], which normalizes that into the same `YYYY-MM-DD` shape
//! TMDB/TVmaze already use, so the Tauri glue and the naming-scheme `{airdate}` binding
//! don't need to know which source produced a result.
//!
//! Known limitation, deliberately not engineered around: OMDb returns HTTP 200 (not an
//! error status) for both "no results" and "invalid API key" - `{"Response":"False",
//! "Error":"..."}` either way - so this client can't reliably distinguish "nothing
//! matched" from "your key is wrong" the way TMDB's real 401 lets `TmdbError::Api`
//! surface a bad token distinctly. Both cases fall through to [`OmdbError::NotFound`]
//! here, same as an empty TMDB/TVmaze result set; a user with a broken key just sees "no
//! match found" repeatedly rather than a specific "invalid key" message.
use serde::Deserialize;
use thiserror::Error;
use crate::similarity::best_title_match;
const DEFAULT_BASE_URL: &str = "https://www.omdbapi.com";
/// Same threshold TMDB/TVmaze use (see `tmdb::NAME_MATCH_THRESHOLD`) - kept identical
/// so switching sources doesn't change how forgiving/strict name matching feels.
const NAME_MATCH_THRESHOLD: f64 = 0.35;
#[derive(Debug, Error)]
pub enum OmdbError {
#[error("network error talking to OMDb: {0}")]
Network(String),
#[error("OMDb API error (status {status}): {message}")]
Api { status: u16, message: String },
#[error("no OMDb match found for \"{0}\"")]
NotFound(String),
}
/// Converts OMDb's `"16 Jul 2010"`-style date text into `YYYY-MM-DD` (matching what
/// TMDB/TVmaze already return), or `None` for `"N/A"`/empty/anything unparseable.
fn parse_release_date(raw: &str) -> Option<String> {
if raw.trim().is_empty() || raw.eq_ignore_ascii_case("n/a") {
return None;
}
let parts: Vec<&str> = raw.split_whitespace().collect();
let [day, month, year] = parts[..] else { return None };
let day: u32 = day.parse().ok()?;
let month = month_number(month)?;
let year: u32 = year.parse().ok()?;
Some(format!("{year:04}-{month:02}-{day:02}"))
}
fn month_number(name: &str) -> Option<u32> {
Some(match name.to_ascii_lowercase().as_str() {
"jan" => 1,
"feb" => 2,
"mar" => 3,
"apr" => 4,
"may" => 5,
"jun" => 6,
"jul" => 7,
"aug" => 8,
"sep" => 9,
"oct" => 10,
"nov" => 11,
"dec" => 12,
_ => return None,
})
}
#[derive(Debug, Clone, Deserialize)]
struct SearchResponse {
#[serde(default, rename = "Search")]
search: Vec<SearchHit>,
}
#[derive(Debug, Clone, Deserialize)]
struct SearchHit {
#[serde(rename = "Title")]
title: String,
#[serde(rename = "imdbID")]
imdb_id: String,
}
/// One OMDb TV-show search hit. Only the fields Aurora Curator actually uses are kept.
#[derive(Debug, Clone)]
pub struct TvResult {
pub imdb_id: String,
pub name: String,
}
/// One OMDb movie search hit.
#[derive(Debug, Clone)]
pub struct MovieResult {
pub imdb_id: String,
pub title: String,
}
#[derive(Debug, Clone, Deserialize)]
struct SeasonResponse {
#[serde(default, rename = "Episodes")]
episodes: Vec<EpisodeHit>,
}
#[derive(Debug, Clone, Deserialize)]
struct EpisodeHit {
#[serde(rename = "Title")]
title: String,
/// A numeric string (`"1"`), but occasionally `"N/A"` for an uncategorized special
/// - parsed defensively in `season_episodes`, same "skip rather than panic"
/// convention TVmaze's null-`number` episodes use.
#[serde(rename = "Episode")]
episode: String,
#[serde(rename = "Released", default)]
released: String,
}
#[derive(Debug, Clone, Deserialize)]
struct DetailResponse {
#[serde(rename = "Released", default)]
released: String,
}
/// Real metadata for one episode, resolved from OMDb - same shape as
/// `tmdb::EpisodeInfo`/`tvmaze::EpisodeInfo` so the Tauri glue can treat any 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`/`tvmaze::SeasonEpisode`'s field names.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SeasonEpisode {
pub episode_number: i32,
pub title: String,
pub air_date: Option<String>,
}
/// Real metadata for one movie, resolved from OMDb - mirrors `tmdb::MovieInfo`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MovieInfo {
pub title: String,
pub release_date: Option<String>,
}
pub struct OmdbClient {
http: reqwest::Client,
api_key: String,
base_url: String,
}
impl OmdbClient {
pub fn new(api_key: impl Into<String>) -> Self {
Self {
http: reqwest::Client::new(),
api_key: api_key.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 OMDb API.
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = base_url.into();
self
}
/// Every OMDb request hits the same root URL with different query parameters (no
/// per-resource paths like TMDB's `/search/movie`) - `apikey` is appended here so
/// every call site gets it for free rather than repeating it.
async fn get_json<T: for<'de> Deserialize<'de>>(&self, query: &[(&str, &str)]) -> Result<T, OmdbError> {
let mut full_query: Vec<(&str, &str)> = query.to_vec();
full_query.push(("apikey", self.api_key.as_str()));
let response = self
.http
.get(&self.base_url)
.query(&full_query)
.send()
.await
.map_err(|e| OmdbError::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(OmdbError::Api { status, message });
}
response
.json::<T>()
.await
.map_err(|e| OmdbError::Network(e.to_string()))
}
/// Searches for a TV show by name. Results come back OMDb-ranked; callers wanting
/// "the best guess" should use `search_tv_best_match` instead, same convention as
/// the TMDB/TVmaze clients.
pub async fn search_tv(&self, query: &str) -> Result<Vec<TvResult>, OmdbError> {
let parsed: SearchResponse = self.get_json(&[("s", query), ("type", "series")]).await?;
Ok(parsed
.search
.into_iter()
.map(|h| TvResult { imdb_id: h.imdb_id, name: h.title })
.collect())
}
/// Searches for a TV show by name and returns the single best-matching result (see
/// `similarity::best_title_match`), or `None` if nothing is a plausible match at
/// all. Mirrors `TmdbClient::search_tv_best_match`/`TvmazeClient::search_tv_best_match`.
pub async fn search_tv_best_match(&self, query: &str) -> Result<Option<TvResult>, OmdbError> {
let results = self.search_tv(query).await?;
Ok(best_title_match(query, &results, |r| r.name.as_str(), NAME_MATCH_THRESHOLD).cloned())
}
/// Fetches one season's episodes for a known OMDb (IMDb) series id.
pub async fn season_episodes(&self, series_imdb_id: &str, season: i32) -> Result<Vec<SeasonEpisode>, OmdbError> {
let season_str = season.to_string();
let parsed: SeasonResponse = self.get_json(&[("i", series_imdb_id), ("Season", &season_str)]).await?;
Ok(parsed
.episodes
.into_iter()
.filter_map(|e| {
let episode_number = e.episode.parse::<i32>().ok()?;
Some(SeasonEpisode {
episode_number,
title: e.title,
air_date: parse_release_date(&e.released),
})
})
.collect())
}
/// Convenience: search for the best-matching show by name, then fetch a specific
/// episode. Same disambiguation approach as the TMDB/TVmaze clients - picks
/// whichever result's name is textually closest to `series_query`, 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, OmdbError> {
let best = self
.search_tv_best_match(series_query)
.await?
.ok_or_else(|| OmdbError::NotFound(series_query.to_string()))?;
let episodes = self.season_episodes(&best.imdb_id, season).await?;
let found = episodes
.into_iter()
.find(|e| e.episode_number == episode)
.ok_or_else(|| OmdbError::NotFound(format!("S{season:02}E{episode:02}")))?;
Ok(EpisodeInfo {
series_name: best.name,
episode_title: found.title,
air_date: found.air_date,
})
}
/// Searches for a movie by title. OMDb's search endpoint doesn't take a release-year
/// filter the way TMDB's does, so `year` is accepted for signature parity with
/// `TmdbClient::search_movie` (name-similarity disambiguation alone is what actually
/// picks the best result) - OMDb does support a `y=` search parameter, so it's used
/// here to narrow the search server-side when a year is known, same intent as TMDB's
/// `primary_release_year`.
pub async fn search_movie(&self, query: &str, year: Option<i32>) -> Result<Vec<MovieResult>, OmdbError> {
let year_str;
let mut params: Vec<(&str, &str)> = vec![("s", query), ("type", "movie")];
if let Some(y) = year {
year_str = y.to_string();
params.push(("y", &year_str));
}
let parsed: SearchResponse = self.get_json(&params).await?;
Ok(parsed
.search
.into_iter()
.map(|h| MovieResult { imdb_id: h.imdb_id, title: h.title })
.collect())
}
/// Convenience: search for the best-matching movie by title (and optional release
/// year), then fetch its full detail record for a real release date - OMDb's search
/// results only carry a bare `Year`, not a full date, unlike TMDB's search results.
pub async fn lookup_movie(&self, query: &str, year: Option<i32>) -> Result<MovieInfo, OmdbError> {
let results = self.search_movie(query, year).await?;
let best = best_title_match(query, &results, |r| r.title.as_str(), NAME_MATCH_THRESHOLD)
.ok_or_else(|| OmdbError::NotFound(query.to_string()))?;
let detail: DetailResponse = self.get_json(&[("i", best.imdb_id.as_str())]).await?;
Ok(MovieInfo {
title: best.title.clone(),
release_date: parse_release_date(&detail.released),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_omdb_style_dates_into_iso_form() {
assert_eq!(parse_release_date("16 Jul 2010"), Some("2010-07-16".to_string()));
assert_eq!(parse_release_date("01 Jan 2008"), Some("2008-01-01".to_string()));
}
#[test]
fn treats_na_and_empty_dates_as_unknown() {
assert_eq!(parse_release_date("N/A"), None);
assert_eq!(parse_release_date("n/a"), None);
assert_eq!(parse_release_date(""), None);
assert_eq!(parse_release_date("not a date"), None);
}
#[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", "/")
.match_query(mockito::Matcher::AllOf(vec![
mockito::Matcher::UrlEncoded("s".into(), "Girls".into()),
mockito::Matcher::UrlEncoded("type".into(), "series".into()),
]))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"Search":[{"Title":"Girls","imdbID":"tt1723816"}],"Response":"True"}"#)
.create_async()
.await;
let _season_mock = server
.mock("GET", "/")
.match_query(mockito::Matcher::AllOf(vec![
mockito::Matcher::UrlEncoded("i".into(), "tt1723816".into()),
mockito::Matcher::UrlEncoded("Season".into(), "2".into()),
]))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"Episodes":[
{"Title":"It's About Time","Episode":"1","Released":"13 Jan 2013"},
{"Title":"I Get Ideas","Episode":"2","Released":"20 Jan 2013"}
],"Response":"True"}"#,
)
.create_async()
.await;
let client = OmdbClient::new("test-key").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", "/")
.match_query(mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"Response":"False","Error":"Series not found!"}"#)
.create_async()
.await;
let client = OmdbClient::new("test-key").with_base_url(server.url());
let result = client.lookup_episode("Some Nonexistent Show", 1, 1).await;
assert!(matches!(result, Err(OmdbError::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", "/")
.match_query(mockito::Matcher::UrlEncoded("s".into(), "Some Show".into()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"Search":[{"Title":"Some Show","imdbID":"tt0000042"}],"Response":"True"}"#)
.create_async()
.await;
let _season_mock = server
.mock("GET", "/")
.match_query(mockito::Matcher::UrlEncoded("i".into(), "tt0000042".into()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"Episodes":[{"Title":"Pilot","Episode":"1","Released":"01 Jan 2020"}],"Response":"True"}"#)
.create_async()
.await;
let client = OmdbClient::new("test-key").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(OmdbError::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", "/")
.match_query(mockito::Matcher::UrlEncoded("s".into(), "Chuck".into()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"Search":[
{"Title":"Chucky","imdbID":"tt1"},
{"Title":"Chuck","imdbID":"tt2"}
],"Response":"True"}"#,
)
.create_async()
.await;
let _season_mock = server
.mock("GET", "/")
.match_query(mockito::Matcher::UrlEncoded("i".into(), "tt2".into()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"Episodes":[{"Title":"Chuck Versus the Intersect","Episode":"1","Released":"24 Sep 2007"}],"Response":"True"}"#,
)
.create_async()
.await;
let client = OmdbClient::new("test-key").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", "/")
.match_query(mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"Search":[{"Title":"Totally Unrelated Program","imdbID":"tt9"}],"Response":"True"}"#)
.create_async()
.await;
let client = OmdbClient::new("test-key").with_base_url(server.url());
let result = client.lookup_episode("Zzyzx Nonexistent Show", 1, 1).await;
assert!(matches!(result, Err(OmdbError::NotFound(_))));
}
#[tokio::test]
async fn episodes_with_an_unparseable_episode_number_are_skipped_rather_than_panicking() {
let mut server = mockito::Server::new_async().await;
let _search_mock = server
.mock("GET", "/")
.match_query(mockito::Matcher::UrlEncoded("s".into(), "Some Show".into()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"Search":[{"Title":"Some Show","imdbID":"tt7"}],"Response":"True"}"#)
.create_async()
.await;
let _season_mock = server
.mock("GET", "/")
.match_query(mockito::Matcher::UrlEncoded("i".into(), "tt7".into()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"Episodes":[
{"Title":"Uncategorized Special","Episode":"N/A","Released":"N/A"},
{"Title":"Pilot","Episode":"1","Released":"01 Jan 2021"}
],"Response":"True"}"#,
)
.create_async()
.await;
let client = OmdbClient::new("test-key").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 looks_up_a_real_movie_end_to_end() {
let mut server = mockito::Server::new_async().await;
let _search_mock = server
.mock("GET", "/")
.match_query(mockito::Matcher::AllOf(vec![
mockito::Matcher::UrlEncoded("s".into(), "Inception".into()),
mockito::Matcher::UrlEncoded("type".into(), "movie".into()),
mockito::Matcher::UrlEncoded("y".into(), "2010".into()),
]))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"Search":[{"Title":"Inception","imdbID":"tt1375666"}],"Response":"True"}"#)
.create_async()
.await;
let _detail_mock = server
.mock("GET", "/")
.match_query(mockito::Matcher::UrlEncoded("i".into(), "tt1375666".into()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"Title":"Inception","Released":"16 Jul 2010","Response":"True"}"#)
.create_async()
.await;
let client = OmdbClient::new("test-key").with_base_url(server.url());
let info = client.lookup_movie("Inception", Some(2010)).await.unwrap();
assert_eq!(
info,
MovieInfo {
title: "Inception".to_string(),
release_date: Some("2010-07-16".to_string()),
}
);
}
#[tokio::test]
async fn picks_the_exact_movie_title_match_over_a_more_popular_but_wrong_result() {
let mut server = mockito::Server::new_async().await;
let _search_mock = server
.mock("GET", "/")
.match_query(mockito::Matcher::UrlEncoded("s".into(), "Up".into()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"Search":[
{"Title":"Wake Up","imdbID":"tt1"},
{"Title":"Up","imdbID":"tt2"}
],"Response":"True"}"#,
)
.create_async()
.await;
let _detail_mock = server
.mock("GET", "/")
.match_query(mockito::Matcher::UrlEncoded("i".into(), "tt2".into()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"Title":"Up","Released":"29 May 2009","Response":"True"}"#)
.create_async()
.await;
let client = OmdbClient::new("test-key").with_base_url(server.url());
let info = client.lookup_movie("Up", None).await.unwrap();
assert_eq!(info.title, "Up");
assert_eq!(info.release_date, Some("2009-05-29".to_string()));
}
#[tokio::test]
async fn reports_not_found_when_movie_search_has_no_results() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", "/")
.match_query(mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"Response":"False","Error":"Movie not found!"}"#)
.create_async()
.await;
let client = OmdbClient::new("test-key").with_base_url(server.url());
let result = client.lookup_movie("Some Nonexistent Movie", None).await;
assert!(matches!(result, Err(OmdbError::NotFound(_))));
}
#[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", "/")
.match_query(mockito::Matcher::Any)
.with_status(503)
.with_header("content-type", "text/plain")
.with_body("service unavailable")
.create_async()
.await;
let client = OmdbClient::new("test-key").with_base_url(server.url());
let result = client.search_tv("anything").await;
match result {
Err(OmdbError::Api { status, .. }) => assert_eq!(status, 503),
other => panic!("expected Api error, got {other:?}"),
}
}
}