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:
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(¶ms).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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user