Add movie support to Fetch Data: local title/year guessing plus TMDB movie lookup
- renamr-core::matching: guess_movie() extracts a movie title (and release year, if
present) from a filename using the same year/quality-token boundary heuristics real
release names follow, as a fallback when guess_episode() finds no season/episode
marker.
- renamr-core::web::tmdb: search_movie()/lookup_movie() mirror the TV client, reusing
the same Sorensen-Dice title-disambiguation as lookup_episode(), narrowed further by
TMDB's own primary_release_year filter when a year was locally guessed. Also exposed
search_tv_best_match() and fixed a latent gap where the real Fetch Data command path
(metadata.rs::fetch_season) bypassed the fuzzy show-name disambiguation entirely,
doing its own raw first-result selection instead of reusing it.
- src-tauri: new guess_movie command and fetch_movie_metadata command/types
(MovieFetchQuery/Outcome/Result), registered alongside the existing TV commands.
- Frontend: Item now tracks mediaKind ("tv"/"movie"/null); Match tries guess_episode
then falls back to guess_movie; Fetch Data resolves TV and movie targets via their
own commands concurrently and merges results. A single batch can mix TV and movie
files - each only fills in the fields that apply to it. Movie fields ({movie name},
{year}, reusing {airdate} for release date, matching FileBot's own unified binding)
are documented in the naming-scheme field reference.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
93b5ac5e56
commit
45a9f5d523
@@ -1,12 +1,11 @@
|
||||
//! 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.
|
||||
//! 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).
|
||||
//!
|
||||
//! 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.
|
||||
//! original. 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;
|
||||
@@ -70,6 +69,33 @@ pub struct EpisodeInfo {
|
||||
pub air_date: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct SearchMovieResponse {
|
||||
#[serde(default)]
|
||||
results: Vec<MovieResult>,
|
||||
}
|
||||
|
||||
/// One TMDB movie search hit. Only the fields Renamr actually uses are kept.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct MovieResult {
|
||||
pub id: u64,
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub release_date: Option<String>,
|
||||
}
|
||||
|
||||
/// Real metadata for one movie, resolved from TMDB - contrast with
|
||||
/// `matching::MovieGuess`, which only has whatever a filename pattern implied.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MovieInfo {
|
||||
/// TMDB's canonical title (may differ in punctuation/capitalization from whatever
|
||||
/// the local filename guess produced).
|
||||
pub title: String,
|
||||
/// Full release date (`YYYY-MM-DD`), same shape TMDB returns it in - not just the
|
||||
/// year, since that's what a naming scheme's `{airdate}`-equivalent field wants.
|
||||
pub release_date: Option<String>,
|
||||
}
|
||||
|
||||
pub struct TmdbClient {
|
||||
http: reqwest::Client,
|
||||
token: String,
|
||||
@@ -161,21 +187,33 @@ impl TmdbClient {
|
||||
.ok_or_else(|| TmdbError::NotFound(format!("S{season:02}E{episode:02}")))
|
||||
}
|
||||
|
||||
/// 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. Exposed separately from `lookup_episode` so callers that
|
||||
/// need to resolve a show once and then fetch several episodes from it (e.g. a
|
||||
/// whole loaded season's worth of files) can reuse the resolved id instead of
|
||||
/// re-running disambiguation per episode.
|
||||
pub async fn search_tv_best_match(&self, query: &str) -> Result<Option<TvResult>, TmdbError> {
|
||||
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. Disambiguates a multi-result search by picking whichever result's name
|
||||
/// is textually closest to `series_query` (see `similarity::best_title_match`)
|
||||
/// rather than just trusting TMDB's own top-ranked (relevance/popularity) result -
|
||||
/// so a more popular but differently-named show doesn't get picked over an exact
|
||||
/// match that's merely ranked lower. If nothing in the results is a plausible
|
||||
/// match at all, reports `NotFound` instead of guessing.
|
||||
/// is textually closest to `series_query` (see `search_tv_best_match`) rather than
|
||||
/// just trusting TMDB's own top-ranked (relevance/popularity) result - so a more
|
||||
/// popular but differently-named show doesn't get picked over an exact match that's
|
||||
/// merely ranked lower. If nothing in the results is a plausible match at all,
|
||||
/// reports `NotFound` instead of guessing.
|
||||
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 = best_title_match(series_query, &results, |r| r.name.as_str(), NAME_MATCH_THRESHOLD)
|
||||
let best = self
|
||||
.search_tv_best_match(series_query)
|
||||
.await?
|
||||
.ok_or_else(|| TmdbError::NotFound(series_query.to_string()))?;
|
||||
|
||||
let info = self.episode(best.id, season, episode).await?;
|
||||
@@ -185,6 +223,38 @@ impl TmdbClient {
|
||||
air_date: info.air_date,
|
||||
})
|
||||
}
|
||||
|
||||
/// Searches for a movie by title, optionally narrowed to a specific release year
|
||||
/// (TMDB's own `primary_release_year` filter - helps disambiguate a remake from
|
||||
/// the original before name-similarity scoring even runs). Results come back
|
||||
/// TMDB-ranked, same caveat as `search_tv`.
|
||||
pub async fn search_movie(&self, query: &str, year: Option<i32>) -> Result<Vec<MovieResult>, TmdbError> {
|
||||
let year_str;
|
||||
let mut params: Vec<(&str, &str)> = vec![("query", query)];
|
||||
if let Some(y) = year {
|
||||
year_str = y.to_string();
|
||||
params.push(("primary_release_year", &year_str));
|
||||
}
|
||||
let parsed: SearchMovieResponse = self.get_json("/search/movie", ¶ms).await?;
|
||||
Ok(parsed.results)
|
||||
}
|
||||
|
||||
/// Convenience: search for the best-matching movie by title (and optional release
|
||||
/// year), picking whichever result's title is textually closest to `query` - same
|
||||
/// disambiguation approach as `lookup_episode`, so a more popular but
|
||||
/// differently-titled movie doesn't get picked over an exact match ranked lower.
|
||||
/// Unlike TV, a movie search result already carries everything needed (title,
|
||||
/// release date), so there's no second per-item request the way `episode()` needs.
|
||||
pub async fn lookup_movie(&self, query: &str, year: Option<i32>) -> Result<MovieInfo, TmdbError> {
|
||||
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(|| TmdbError::NotFound(query.to_string()))?;
|
||||
|
||||
Ok(MovieInfo {
|
||||
title: best.title.clone(),
|
||||
release_date: best.release_date.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Just the episode-specific fields, before the series name (known by the caller of
|
||||
@@ -347,6 +417,78 @@ mod tests {
|
||||
assert!(matches!(result, Err(TmdbError::NotFound(_))));
|
||||
}
|
||||
|
||||
#[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", "/search/movie")
|
||||
.match_query(mockito::Matcher::AllOf(vec![
|
||||
mockito::Matcher::UrlEncoded("query".into(), "Inception".into()),
|
||||
mockito::Matcher::UrlEncoded("primary_release_year".into(), "2010".into()),
|
||||
]))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"results":[{"id":27205,"title":"Inception","release_date":"2010-07-15"}]}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = TmdbClient::new("test-token").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-15".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", "/search/movie")
|
||||
.match_query(mockito::Matcher::UrlEncoded("query".into(), "Up".into()))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"results":[
|
||||
{"id":1,"title":"Wake Up","release_date":"2015-01-01"},
|
||||
{"id":2,"title":"Up","release_date":"2009-05-29"}
|
||||
]}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = TmdbClient::new("test-token").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", "/search/movie")
|
||||
.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_movie("Some Nonexistent Movie", None).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;
|
||||
|
||||
Reference in New Issue
Block a user