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:
Renamr Scaffold
2026-08-29 04:04:16 +00:00
co-authored by Claude Sonnet 5
parent 93b5ac5e56
commit 45a9f5d523
6 changed files with 616 additions and 127 deletions
+103
View File
@@ -50,6 +50,21 @@ fn sxe_pattern() -> &'static Regex {
RE.get_or_init(|| Regex::new(r"\b(\d{1,2})x(\d{2,3})\b").unwrap())
}
fn year_pattern() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"\b(19\d{2}|20\d{2})\b").unwrap())
}
/// Common release-name quality/source tags. Not exhaustive (there's no bound on what a
/// release group might tack on), just the handful common enough to reliably mark where
/// a movie title ends when no release year is present either.
fn quality_token_pattern() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"(?i)\b(720p|1080p|2160p|480p|bluray|blu-ray|web-?dl|webrip|hdtv|dvdrip|brrip|x264|x265|h264|h265|hevc)\b").unwrap()
})
}
/// Cleans up the raw text preceding a season/episode match into a plausible series
/// name: `.`/`_` used as word separators become spaces, and leading/trailing
/// whitespace/dash/dot clutter is trimmed.
@@ -64,6 +79,15 @@ fn clean_series_name(raw: &str) -> String {
trimmed.split_whitespace().collect::<Vec<_>>().join(" ")
}
/// Like `clean_series_name`, but also trims a trailing unmatched opening bracket - a
/// movie title guess is typically cut right before a `(year)` or `[tag]`, which leaves
/// the bracket character itself dangling at the end (e.g. `"The Matrix ("`).
fn clean_movie_title(raw: &str) -> String {
let base = clean_series_name(raw);
base.trim_end_matches(|c: char| c == '(' || c == '[' || c == '{' || c.is_whitespace())
.to_string()
}
/// Tries each season/episode pattern in priority order (most explicit/specific first)
/// and returns the first match, with the series name guessed from the text before it.
///
@@ -101,6 +125,52 @@ pub fn guess_episode(filename: &str) -> Option<EpisodeGuess> {
None
}
/// A movie title guessed from a filename, plus a release year if the filename's own
/// text happened to carry one (release names conventionally look like
/// `Title.Year.Quality-Group`, e.g. `Inception.2010.1080p.BluRay.x264`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MovieGuess {
pub title: String,
pub year: Option<i32>,
}
/// Guesses a movie title (and, if present, a release year) from a filename. Unlike
/// `guess_episode`, this doesn't look for a specific marker that's either there or
/// not - it just cuts the title off at the earliest release-year or quality/source tag
/// it can find (preferring a year on a tie, since a year almost always follows the
/// title directly in real release names), and falls back to treating the whole
/// (cleaned) filename as the title when neither is present. Only returns `None` when
/// there's nothing left to call a title at all, e.g. a filename that's just a bare
/// year or quality tag.
///
/// Callers are expected to try `guess_episode` first and only fall back to this when
/// it returns `None` - a real release name is either an episode or a movie, not both,
/// and this function has no way to tell the difference on its own.
pub fn guess_movie(filename: &str) -> Option<MovieGuess> {
let normalized: String = filename
.chars()
.map(|c| if c == '_' { ' ' } else { c })
.collect();
let year_match = year_pattern().find(&normalized);
let quality_match = quality_token_pattern().find(&normalized);
let (title_end, year) = match (year_match, quality_match) {
(Some(y), Some(q)) if y.start() <= q.start() => (y.start(), y.as_str().parse().ok()),
(Some(y), None) => (y.start(), y.as_str().parse().ok()),
(_, Some(q)) => (q.start(), None),
(None, None) => (normalized.len(), None),
};
let title = clean_movie_title(&filename[..title_end]);
if title.is_empty() {
return None;
}
Some(MovieGuess { title, year })
}
#[cfg(test)]
mod tests {
use super::*;
@@ -153,4 +223,37 @@ mod tests {
assert_eq!(guess.season, 1);
assert_eq!(guess.episode, 2);
}
#[test]
fn guesses_a_movie_title_and_year_from_a_dotted_release_name() {
let guess = guess_movie("Inception.2010.1080p.BluRay.x264").unwrap();
assert_eq!(guess.title, "Inception");
assert_eq!(guess.year, Some(2010));
}
#[test]
fn guesses_a_movie_title_and_year_with_a_parenthesized_year() {
let guess = guess_movie("The Matrix (1999)").unwrap();
assert_eq!(guess.title, "The Matrix");
assert_eq!(guess.year, Some(1999));
}
#[test]
fn falls_back_to_a_quality_token_boundary_when_theres_no_year() {
let guess = guess_movie("Some.Movie.Title.WEBRip.x264").unwrap();
assert_eq!(guess.title, "Some Movie Title");
assert_eq!(guess.year, None);
}
#[test]
fn an_already_clean_filename_becomes_the_whole_title() {
let guess = guess_movie("The Matrix").unwrap();
assert_eq!(guess.title, "The Matrix");
assert_eq!(guess.year, None);
}
#[test]
fn returns_none_when_theres_nothing_but_a_year_left() {
assert!(guess_movie("2010").is_none());
}
}
+155 -13
View File
@@ -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", &params).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;