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
+49 -40
View File
@@ -5,15 +5,17 @@ fork), built from scratch as its own project: new stack, new architecture, new b
**Status: early scaffold, but the core loop works end to end.** The naming-expression
engine is ported and tested, the faithfully-recreated two-pane UI is wired to real
native file/folder loading and drag-and-drop, local filename-based season/episode
detection ("Match") is working, real online metadata lookups against TMDB ("Fetch
Data") are working — including fuzzy show-name disambiguation, so a search that
returns several similarly-named shows doesn't just take TMDB's top-ranked hit — and the
actual on-disk rename operation is wired up (skip-on-conflict, with a confirmation step
showing the full old-path/new-path plan before anything touches disk). Rename can also
organize files into subfolders — including ones that don't exist yet — when the naming
scheme includes a `/`. Every completed rename is logged to a history panel that can
undo it, moving files back to their original paths. Full media-metadata bindings are
native file/folder loading and drag-and-drop, local filename-based season/episode *and
movie title/year* detection ("Match") is working, real online metadata lookups against
TMDB ("Fetch Data") are working for both TV episodes and movies — including fuzzy
title disambiguation, so a search that returns several similarly-named results doesn't
just take TMDB's top-ranked hit — and the actual on-disk rename operation is wired up
(skip-on-conflict, with a confirmation step showing the full old-path/new-path plan
before anything touches disk). Rename can also organize files into subfolders —
including ones that don't exist yet — when the naming scheme includes a `/`. Every
completed rename is logged to a history panel that can undo it, moving files back to
their original paths. A single loaded batch can freely mix TV episodes and movies —
each file only fills in the fields that apply to it. Full media-metadata bindings are
not yet ported. See "Porting roadmap" below.
The naming scheme is fully user-editable from the app (the "Naming Scheme" button next
@@ -106,12 +108,13 @@ that item is skipped rather than overwritten.
```
crates/renamr-core/ Pure Rust core: naming-expression engine (ported), local
filename-based season/episode matching (ported, `matching/`),
text-similarity scoring for disambiguating search results
(`similarity/`, partial `EpisodeMetrics` port), a TMDB metadata
client (`web/tmdb.rs` — TV search + episode lookup, disambiguated
via `similarity/`, tested against a mocked HTTP server via
`mockito`), the on-disk rename engine (`rename/`) —
filename-based season/episode *and movie title/year* matching
(ported, `matching/`), text-similarity scoring for disambiguating
search results (`similarity/`, partial `EpisodeMetrics` port), a
TMDB metadata client (`web/tmdb.rs` — TV search + episode lookup
and movie search + lookup, both disambiguated via `similarity/`,
tested against a mocked HTTP server via `mockito`), the on-disk
rename engine (`rename/`) —
skip-on-conflict, can also organize into (auto-created)
subfolders when the naming scheme includes `/` — and a rename
history/undo log (`history/`, a partial `HistoryDialog` port).
@@ -119,18 +122,19 @@ crates/renamr-core/ Pure Rust core: naming-expression engine (ported), local
datasource clients (TVDB/AniDB/etc).
No Tauri dependency, so it builds and unit-tests standalone.
src-tauri/ Tauri app shell: exposes renamr-core as commands to the frontend
(naming-scheme rendering, filename matching, TMDB metadata fetch,
executing a rename plan, native file/folder dialogs, drag-and-drop
expansion), plus `settings.rs` (TMDB token) and `history.rs`
(rename history/undo log) — both stored as JSON in the OS
app-config directory — app metadata/icons/window config
(tauri.conf.json).
(naming-scheme rendering, filename matching for both TV and
movies, TMDB metadata fetch — `metadata.rs` for TV episodes,
plus a movie-fetch command alongside it — executing a rename
plan, native file/folder dialogs, drag-and-drop expansion), plus
`settings.rs` (TMDB token) and `history.rs` (rename history/undo
log) — both stored as JSON in the OS app-config directory — app
metadata/icons/window config (tauri.conf.json).
src/ React + TypeScript UI (Vite). Faithful recreation of the original
two-pane rename screen (Aurora theme) wired to real file loading,
drag-and-drop, live naming-scheme preview, local matching, TMDB
fetch (behind a Settings modal for the API token), a
confirm-before-touching-disk modal for the actual rename, and a
History panel that can undo a past rename.
drag-and-drop, live naming-scheme preview, local TV/movie
matching, TMDB fetch for both (behind a Settings modal for the
API token), a confirm-before-touching-disk modal for the actual
rename, and a History panel that can undo a past rename.
scripts/gen_icons.py Regenerates src-tauri/icons/ (placeholder branding — swap the
colors/glyph in the script, or replace the icons directly, to
change the look).
@@ -191,25 +195,30 @@ ported from, for anyone picking this up:
3. **Matching/similarity engine** — `net.filebot.similarity.*`. Local filename-based
season/episode detection is done (`crates/renamr-core/src/matching`, ported from the
pattern-matching half of `SeasonEpisodeMatcher.java`, adapted to Rust's `regex` crate
which lacks lookahead/lookbehind — wired to the "Match" button). Show-name
disambiguation is also done (`crates/renamr-core/src/similarity`, a partial port of
the *scoring* idea behind `EpisodeMetrics.java`'s fuzzy title-matching cascade —
Sorensen-Dice similarity over normalized-title character bigrams): "Fetch Data" now
picks whichever TMDB search result is textually closest to the local guess rather
than just trusting TMDB's relevance/popularity-ranked first hit, and reports "not
found" instead of guessing when nothing in the results is a plausible match at all.
`DateMatcher` (numbering/air-date-based identification, the other half of the
original `EpisodeMetrics`) is not ported — Renamr doesn't do date-based episode
identification.
which lacks lookahead/lookbehind — wired to the "Match" button), and so is a local
movie title/year heuristic (`guess_movie` in the same module) used as a fallback
whenever a filename carries no season/episode marker — a release name is either a TV
episode or a movie, never both. Title disambiguation is also done
(`crates/renamr-core/src/similarity`, a partial port of the *scoring* idea behind
`EpisodeMetrics.java`'s fuzzy title-matching cascade — Sorensen-Dice similarity over
normalized-title character bigrams), shared by both the TV and movie TMDB lookups:
"Fetch Data" picks whichever TMDB search result is textually closest to the local
guess rather than just trusting TMDB's relevance/popularity-ranked first hit, and
reports "not found" instead of guessing when nothing in the results is a plausible
match at all. `DateMatcher` (numbering/air-date-based identification, the other half
of the original `EpisodeMetrics`) is not ported — Renamr doesn't do date-based
episode identification.
4. **Media detection** — `net.filebot.media.MediaDetection` (1356 lines): ties file-name
parsing + matching together to auto-identify what a file is.
5. **Web datasource clients** — `net.filebot.web.*` (~40 files): TheMovieDB, TheTVDB,
AniDB, OMDb, TVmaze, OpenSubtitles, etc. TMDB is done for TV (`crates/renamr-core/src/web/tmdb.rs`,
`reqwest` + `serde_json`, Bearer-token auth) — search-by-name plus per-season episode
lookup, wired to "Fetch Data", disambiguating multi-result searches via the
AniDB, OMDb, TVmaze, OpenSubtitles, etc. TMDB is done for both TV and movies
(`crates/renamr-core/src/web/tmdb.rs`, `reqwest` + `serde_json`, Bearer-token auth) —
search-by-name plus per-season episode lookup for TV, search-by-title (optionally
narrowed by release year via TMDB's own `primary_release_year` filter) for movies —
both wired to "Fetch Data", disambiguating multi-result searches via the
similarity-scoring pass described in item 3 above rather than blindly trusting
TMDB's top-ranked hit. TMDB movie lookup and all other sources (TheTVDB, AniDB,
OMDb, TVmaze, OpenSubtitles) are not yet started.
TMDB's top-ranked hit. All other sources (TheTVDB, AniDB, OMDb, TVmaze,
OpenSubtitles) are not yet started.
6. **Rename engine + CLI** — `net.filebot.cli.CmdlineOperations` (1235 lines) and
`ArgumentBean` (415 lines, the full flag surface: `-rename`, `--db`, `--format`,
`--action`, `--conflict`, `-get-subtitles`, `-script`, etc.). The rename operation is
+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;
+12 -1
View File
@@ -4,7 +4,7 @@ mod metadata;
mod settings;
use renamr_core::format::ExpressionFormat;
use renamr_core::matching::{self, EpisodeGuess};
use renamr_core::matching::{self, EpisodeGuess, MovieGuess};
use renamr_core::rename::{self, RenamePlanItem, RenameResult};
use renamr_core::string_bindings;
use std::collections::HashMap;
@@ -30,6 +30,15 @@ fn guess_episode(filename: String) -> Option<EpisodeGuess> {
matching::guess_episode(&filename)
}
/// Guesses a movie title (and release year, if present) from a filename using local
/// regex/heuristic patterns only - no network calls. Callers should try
/// `guess_episode` first and only fall back to this when it returns `None`; see
/// `matching::guess_movie` for why.
#[tauri::command]
fn guess_movie(filename: String) -> Option<MovieGuess> {
matching::guess_movie(&filename)
}
/// Executes a batch of planned on-disk renames (optionally moving files into
/// subfolders - see `renamr_core::rename`; conflicting targets are always skipped,
/// never overwritten). The UI is expected to have already shown the user the full
@@ -54,8 +63,10 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![
render_expression,
guess_episode,
guess_movie,
execute_rename,
metadata::fetch_metadata,
metadata::fetch_movie_metadata,
settings::get_tmdb_token,
settings::save_tmdb_token,
history::list_rename_history,
+80 -4
View File
@@ -104,10 +104,10 @@ async fn run_fetch(token: String, items: Vec<FetchQuery>) -> Vec<FetchResult> {
}
async fn fetch_season(client: &TmdbClient, series_query: &str, season: i32) -> SeasonLookup {
let search_results = client.search_tv(series_query).await.map_err(|e| e.to_string())?;
let best = search_results
.into_iter()
.next()
let best = client
.search_tv_best_match(series_query)
.await
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("no TMDB match for \"{series_query}\""))?;
let episodes = client
.season_episodes(best.id, season)
@@ -115,3 +115,79 @@ async fn fetch_season(client: &TmdbClient, series_query: &str, season: i32) -> S
.map_err(|e| e.to_string())?;
Ok((best.name, episodes))
}
/// One file's local movie-title guess, sent up from the UI to be resolved against
/// TMDB. Mirrors `FetchQuery`, but for the movie side of "Fetch Data".
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MovieFetchQuery {
/// Echoed back in the result so the frontend can map it back to the right item.
pub id: String,
pub title_query: String,
/// A release year, if the local filename guess happened to carry one - used to
/// narrow the TMDB search server-side before fuzzy title matching even runs.
pub year: Option<i32>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "status", rename_all = "camelCase")]
pub enum MovieFetchOutcome {
Found {
title: String,
release_date: Option<String>,
},
/// Nothing on TMDB was a plausible match for the title (and year, if given).
NotFound,
Failed {
error: String,
},
}
#[derive(Debug, Clone, Serialize)]
pub struct MovieFetchResult {
pub id: String,
pub outcome: MovieFetchOutcome,
}
/// Resolves a batch of local movie-title guesses against TMDB. Mirrors
/// `fetch_metadata`, but each query is independent (no per-season caching the way TV
/// episodes benefit from) since a movie search result already carries everything
/// needed in one request.
#[tauri::command]
pub async fn fetch_movie_metadata(app: AppHandle, items: Vec<MovieFetchQuery>) -> Vec<MovieFetchResult> {
let Some(token) = settings::tmdb_token(&app) else {
return items
.into_iter()
.map(|item| MovieFetchResult {
id: item.id,
outcome: MovieFetchOutcome::Failed {
error: "No TMDB API token configured — open Settings to add one.".to_string(),
},
})
.collect();
};
tauri::async_runtime::spawn(run_movie_fetch(token, items))
.await
.unwrap_or_default()
}
async fn run_movie_fetch(token: String, items: Vec<MovieFetchQuery>) -> Vec<MovieFetchResult> {
let client = TmdbClient::new(token);
let mut results = Vec::with_capacity(items.len());
for item in items {
let outcome = match client.lookup_movie(&item.title_query, item.year).await {
Ok(info) => MovieFetchOutcome::Found {
title: info.title,
release_date: info.release_date,
},
Err(renamr_core::web::tmdb::TmdbError::NotFound(_)) => MovieFetchOutcome::NotFound,
Err(error) => MovieFetchOutcome::Failed { error: error.to_string() },
};
results.push(MovieFetchResult { id: item.id, outcome });
}
results
}
+196 -48
View File
@@ -19,6 +19,10 @@ interface Item {
ext: string;
bindings: Record<string, string> | null; // null = no data to preview with (rendered muted)
isSample: boolean;
// What kind of media Match recognized this file as - "tv"/"movie" once Match (or
// Fetch Data) has run, null before that or if nothing was recognized. Kept outside
// `bindings` so it doesn't leak into the naming-scheme field-reference list.
mediaKind: "tv" | "movie" | null;
}
interface FileEntry {
@@ -36,6 +40,18 @@ interface EpisodeGuess {
episode: number;
}
// Result of the local, filename-only movie title/year matcher (renamr-core's
// `matching::guess_movie`, exposed via the `guess_movie` Tauri command). Tried as a
// fallback whenever `guess_episode` finds no season/episode pattern - a release name
// is either a TV episode or a movie, never both.
interface MovieGuess {
title: string;
year: number | null;
}
// What runMatching resolved a file to, before it's written into the item's bindings.
type MatchGuess = { kind: "tv"; episode: EpisodeGuess } | { kind: "movie"; movie: MovieGuess };
function pad2(n: number): string {
return n.toString().padStart(2, "0");
}
@@ -84,6 +100,26 @@ interface FetchResult {
outcome: FetchOutcome;
}
// Sent to `fetch_movie_metadata`: one file's local movie-title guess (from Match) to
// resolve against real TMDB data. Mirrors FetchQuery, but for movies - a year is
// optional since not every release name carries one.
interface MovieFetchQuery {
id: string;
titleQuery: string;
year: number | null;
}
// Mirrors renamr-core's `MovieFetchOutcome` (serde-tagged on "status").
type MovieFetchOutcome =
| { status: "found"; title: string; releaseDate: string | null }
| { status: "notFound" }
| { status: "failed"; error: string };
interface MovieFetchResult {
id: string;
outcome: MovieFetchOutcome;
}
// Rename history / undo. Mirrors renamr-core's `history` module (serde-tagged on
// "status" for the outcome, same convention as RenameOutcome/FetchOutcome above).
interface HistoryEntry {
@@ -133,6 +169,7 @@ const SAMPLE_ITEMS: Item[] = [
"episode name": "Ozymandias",
},
isSample: true,
mediaKind: "tv",
},
{
id: "sample-2",
@@ -148,6 +185,7 @@ const SAMPLE_ITEMS: Item[] = [
"episode name": "Granite State",
},
isSample: true,
mediaKind: "tv",
},
{
id: "sample-3",
@@ -163,6 +201,7 @@ const SAMPLE_ITEMS: Item[] = [
"episode name": "Gay Witch Hunt",
},
isSample: true,
mediaKind: "tv",
},
{
id: "sample-4",
@@ -170,6 +209,7 @@ const SAMPLE_ITEMS: Item[] = [
ext: "mp4",
bindings: null,
isSample: true,
mediaKind: null,
},
];
@@ -195,6 +235,7 @@ function realFileToItem(entry: FileEntry): Item {
ext: entry.ext,
bindings: { name: entry.name, ext: entry.ext },
isSample: false,
mediaKind: null, // unknown until Match runs
};
}
@@ -395,56 +436,90 @@ export default function RenamePanel() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Local, filename-based season/episode detection (renamr-core's `matching` module).
// Populates `{n}`/`{s00e00}`/`{season}`/`{episode}` bindings from patterns already
// present in the filename text - no network calls, no metadata lookup, so it's
// distinct from (and much cheaper than) "Fetch Data" online matching, which still
// isn't built.
// Local, filename-based season/episode/movie detection (renamr-core's `matching`
// module). Tries `guess_episode` first; when a filename carries no season/episode
// marker, falls back to `guess_movie` (a release name is either a TV episode or a
// movie, never both - see the doc comment on `guess_movie` in renamr-core). Populates
// TV bindings (`{n}`/`{s00e00}`/`{season}`/`{episode}`/etc.) or movie bindings
// (`{n}`/`{y}`/etc.) accordingly - no network calls, no metadata lookup, so it's
// distinct from (and much cheaper than) "Fetch Data" online matching.
const runMatching = async () => {
const targets = items.filter((i) => !i.isSample);
if (targets.length === 0) {
setStatus("Load some files first, then Match will scan their names for season/episode info.");
setStatus("Load some files first, then Match will scan their names for season/episode or movie info.");
window.clearTimeout((runMatching as any)._t);
(runMatching as any)._t = window.setTimeout(() => setStatus(null), 3200);
return;
}
const results = await Promise.all(
targets.map(async (item) => {
const results: Array<readonly [string, MatchGuess | null]> = await Promise.all(
targets.map(async (item): Promise<readonly [string, MatchGuess | null]> => {
try {
const guess = await invoke<EpisodeGuess | null>("guess_episode", {
const episode = await invoke<EpisodeGuess | null>("guess_episode", {
filename: item.originalName,
});
return [item.id, guess] as const;
if (episode) return [item.id, { kind: "tv", episode }];
} catch {
return [item.id, null] as const;
// Fall through and try a movie guess instead.
}
try {
const movie = await invoke<MovieGuess | null>("guess_movie", {
filename: item.originalName,
});
if (movie) return [item.id, { kind: "movie", movie }];
} catch {
// Neither guess worked - leave this item unmatched.
}
return [item.id, null];
})
);
const guesses = new Map(results);
// Count matches from the results directly, not inside the setItems updater below:
// React doesn't run a functional updater synchronously, so a counter mutated in
// there wouldn't be ready yet for the status message/auto-upgrade check right after.
const matched = results.filter(([, guess]) => guess !== null).length;
const matchedTv = results.filter(([, guess]) => guess?.kind === "tv").length;
const matchedMovie = results.filter(([, guess]) => guess?.kind === "movie").length;
const matched = matchedTv + matchedMovie;
setItems((prev) =>
prev.map((item) => {
const guess = guesses.get(item.id);
if (!guess) return item;
const seriesName = guess.seriesName || item.bindings?.name || item.originalName;
if (guess.kind === "tv") {
const { episode } = guess;
const seriesName = episode.seriesName || item.bindings?.name || item.originalName;
return {
...item,
mediaKind: "tv",
bindings: {
...item.bindings,
// FileBot-style short names, for compatibility with expressions that use them.
n: seriesName,
s00e00: `S${pad2(guess.season)}E${pad2(guess.episode)}`,
season: String(guess.season),
episode: String(guess.episode),
s00e00: `S${pad2(episode.season)}E${pad2(episode.episode)}`,
season: String(episode.season),
episode: String(episode.episode),
// Human-readable aliases (this is what the default naming scheme uses).
"show name": seriesName,
"season number": String(guess.season),
"episode number": pad2(guess.episode),
"season number": String(episode.season),
"episode number": pad2(episode.episode),
},
};
}
const { movie } = guess;
const title = movie.title || item.bindings?.name || item.originalName;
return {
...item,
mediaKind: "movie",
bindings: {
...item.bindings,
// FileBot-style short name, for compatibility (same `{n}` field TV uses).
n: title,
...(movie.year ? { y: String(movie.year) } : {}),
// Human-readable aliases.
"movie name": title,
...(movie.year ? { year: String(movie.year) } : {}),
},
};
})
@@ -452,15 +527,16 @@ export default function RenamePanel() {
// Showcase the newly-populated fields, but only the first time and only if the
// user hasn't already customized the naming scheme away from the plain-filename
// default.
if (expressionRef.current === REAL_FILE_EXPRESSION && matched > 0) {
// default. Movies deliberately don't drive this auto-upgrade - a single "best
// expression" heuristic doesn't generalize across a batch mixing TV and movies.
if (expressionRef.current === REAL_FILE_EXPRESSION && matchedTv > 0) {
setExpression(MATCHED_EXPRESSION);
}
setStatus(
matched > 0
? `Matched season/episode on ${matched} of ${targets.length} file${targets.length === 1 ? "" : "s"} (filename-based, local only).`
: "No season/episode pattern recognized in the loaded filenames."
? `Matched ${matched} of ${targets.length} file${targets.length === 1 ? "" : "s"} (${matchedTv} TV, ${matchedMovie} movie${matchedMovie === 1 ? "" : "s"}; filename-based, local only).`
: "No season/episode or movie title recognized in the loaded filenames."
);
window.clearTimeout((runMatching as any)._t);
(runMatching as any)._t = window.setTimeout(() => setStatus(null), 4000);
@@ -688,9 +764,11 @@ export default function RenamePanel() {
};
// "Fetch Data": resolve each already-Matched file's local guess against real TMDB
// metadata. Requires Match to have already populated a series name + season/episode
// (that's the search query) - this is deliberately layered on top of local matching,
// not a replacement for it.
// metadata. Requires Match to have already populated either a series name + season/
// episode (TV) or a movie title (movie) - that's the search query - so this is
// deliberately layered on top of local matching, not a replacement for it. TV and
// movie items are resolved via separate Tauri commands, run concurrently, and merged
// back into a single set of counts/status message.
const runFetch = async () => {
if (hasTmdbToken === false) {
setStatus("Add a free TMDB API token in Settings first, then Fetch Data will work.");
@@ -700,9 +778,14 @@ export default function RenamePanel() {
return;
}
const targets = items.filter((i) => !i.isSample && i.bindings?.n && i.bindings?.season && i.bindings?.episode);
if (targets.length === 0) {
setStatus("Run Match first — Fetch Data needs a guessed series name and season/episode to look up.");
const tvTargets = items.filter(
(i) => !i.isSample && i.mediaKind === "tv" && i.bindings?.n && i.bindings?.season && i.bindings?.episode
);
const movieTargets = items.filter((i) => !i.isSample && i.mediaKind === "movie" && i.bindings?.n);
const targetCount = tvTargets.length + movieTargets.length;
if (targetCount === 0) {
setStatus("Run Match first — Fetch Data needs a guessed series/episode or movie title to look up.");
window.clearTimeout((runFetch as any)._t);
(runFetch as any)._t = window.setTimeout(() => setStatus(null), 4000);
return;
@@ -710,27 +793,54 @@ export default function RenamePanel() {
setFetching(true);
try {
const query: FetchQuery[] = targets.map((item) => ({
const [tvResults, movieResults] = await Promise.all([
tvTargets.length > 0
? invoke<FetchResult[]>("fetch_metadata", {
items: tvTargets.map(
(item): FetchQuery => ({
id: item.id,
seriesQuery: item.bindings!.n,
season: Number(item.bindings!.season),
episode: Number(item.bindings!.episode),
}));
const results = await invoke<FetchResult[]>("fetch_metadata", { items: query });
const byId = new Map(results.map((r) => [r.id, r] as const));
})
),
})
: Promise.resolve<FetchResult[]>([]),
movieTargets.length > 0
? invoke<MovieFetchResult[]>("fetch_movie_metadata", {
items: movieTargets.map(
(item): MovieFetchQuery => ({
id: item.id,
titleQuery: item.bindings!.n,
year: item.bindings!.y ? Number(item.bindings!.y) : null,
})
),
})
: Promise.resolve<MovieFetchResult[]>([]),
]);
// Compute counts from `results` directly, not inside the setItems updater below -
// same rule as runMatching/confirmRename above (React doesn't run a functional
// updater synchronously, so a counter mutated in there wouldn't be ready yet).
const found = results.filter((r) => r.outcome.status === "found").length;
const notFound = results.filter((r) => r.outcome.status === "notFound").length;
const failed = results.filter((r) => r.outcome.status === "failed").length;
const tvById = new Map(tvResults.map((r) => [r.id, r] as const));
const movieById = new Map(movieResults.map((r) => [r.id, r] as const));
// Compute counts from the raw results directly, not inside the setItems updater
// below - same rule as runMatching/confirmRename above (React doesn't run a
// functional updater synchronously, so a counter mutated in there wouldn't be
// ready yet).
const found =
tvResults.filter((r) => r.outcome.status === "found").length +
movieResults.filter((r) => r.outcome.status === "found").length;
const notFound =
tvResults.filter((r) => r.outcome.status === "notFound").length +
movieResults.filter((r) => r.outcome.status === "notFound").length;
const failed =
tvResults.filter((r) => r.outcome.status === "failed").length +
movieResults.filter((r) => r.outcome.status === "failed").length;
setItems((prev) =>
prev.map((item) => {
const result = byId.get(item.id);
if (!result || result.outcome.status !== "found") return item;
const { seriesName, episodeTitle, airDate } = result.outcome;
const tvResult = tvById.get(item.id);
if (tvResult && tvResult.outcome.status === "found") {
const { seriesName, episodeTitle, airDate } = tvResult.outcome;
return {
...item,
bindings: {
@@ -744,6 +854,34 @@ export default function RenamePanel() {
"episode name": episodeTitle,
},
};
}
const movieResult = movieById.get(item.id);
if (movieResult && movieResult.outcome.status === "found") {
const { title, releaseDate } = movieResult.outcome;
// Only overwrite the locally-guessed year if Match didn't already find one
// in the filename itself - a filename-carried year is usually more precise
// than TMDB's release date for disambiguating remakes.
const guessedYear = item.bindings?.y;
const releaseYear = releaseDate?.slice(0, 4);
return {
...item,
bindings: {
...item.bindings,
// FileBot-style short name, for compatibility (same `{n}` field TV uses).
n: title,
// Reuses the same `{airdate}` field TV uses - matches FileBot's own
// unified binding, which covers both episode air dates and movie
// release dates.
...(releaseDate ? { airdate: releaseDate } : {}),
...(!guessedYear && releaseYear ? { y: releaseYear, year: releaseYear } : {}),
// Human-readable alias.
"movie name": title,
},
};
}
return item;
})
);
@@ -753,7 +891,7 @@ export default function RenamePanel() {
setExpression(FETCHED_EXPRESSION);
}
const parts = [`Fetched real metadata for ${found} of ${targets.length} file${targets.length === 1 ? "" : "s"}`];
const parts = [`Fetched real metadata for ${found} of ${targetCount} file${targetCount === 1 ? "" : "s"}`];
if (notFound > 0) parts.push(`${notFound} not found on TMDB`);
if (failed > 0) parts.push(`${failed} failed`);
setStatus(parts.join(", ") + ".");
@@ -889,7 +1027,7 @@ export default function RenamePanel() {
</div>
<div className="format-fields-group">
<span className="format-fields-group-title">
After <strong>Match</strong> (local, offline)
TV, after <strong>Match</strong> (local, offline)
</span>
<code>{"{show name}"}</code>
<code>{"{season number}"}</code>
@@ -897,17 +1035,27 @@ export default function RenamePanel() {
</div>
<div className="format-fields-group">
<span className="format-fields-group-title">
After <strong>Fetch Data</strong> (real TMDB metadata)
TV, after <strong>Fetch Data</strong> (real TMDB metadata)
</span>
<code>{"{episode name}"}</code>
<code>{"{airdate}"}</code>
</div>
<div className="format-fields-group">
<span className="format-fields-group-title">
Movies, after <strong>Match</strong> or <strong>Fetch Data</strong>
</span>
<code>{"{movie name}"}</code>
<code>{"{year}"}</code>
<code>{"{airdate}"}</code>
</div>
</div>
<p className="format-hint">
Match scans filenames locally for season/episode patterns (no network calls); Fetch Data resolves that
guess against real TMDB metadata for the real episode title and air date — needs a free TMDB API token
set in <strong>TMDB Settings</strong> first. FileBot's own short field names (<code>{"{n}"}</code>,{" "}
<code>{"{s00e00}"}</code>, <code>{"{t}"}</code>, etc.) still work too, side by side with the names above.
Match scans filenames locally for season/episode or movie title/year patterns (no network calls); Fetch
Data resolves that guess against real TMDB metadata for the real title, episode title, and air/release
date — needs a free TMDB API token set in <strong>TMDB Settings</strong> first. A batch can freely mix TV
and movie files — each file only fills in the fields that apply to it. FileBot's own short field names
(<code>{"{n}"}</code>, <code>{"{s00e00}"}</code>, <code>{"{t}"}</code>, <code>{"{y}"}</code>, etc.) still
work too, side by side with the names above.
</p>
<p className="format-hint">
Use <code>{"/"}</code> in the scheme to organize files into subfolders as part of renaming, e.g.{" "}