diff --git a/README.md b/README.md index 77e7f79..4154fa8 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/crates/renamr-core/src/matching/mod.rs b/crates/renamr-core/src/matching/mod.rs index 72f799d..ec5610e 100644 --- a/crates/renamr-core/src/matching/mod.rs +++ b/crates/renamr-core/src/matching/mod.rs @@ -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 = 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 = 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::>().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 { 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, +} + +/// 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 { + 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()); + } } diff --git a/crates/renamr-core/src/web/tmdb.rs b/crates/renamr-core/src/web/tmdb.rs index f1d6e50..68659a4 100644 --- a/crates/renamr-core/src/web/tmdb.rs +++ b/crates/renamr-core/src/web/tmdb.rs @@ -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, } +#[derive(Debug, Clone, Deserialize)] +struct SearchMovieResponse { + #[serde(default)] + results: Vec, +} + +/// 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, +} + +/// 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, +} + 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, 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 { - 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) -> Result, 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) -> Result { + 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; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a93d338..0c4ef1a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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 { 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 { + 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, diff --git a/src-tauri/src/metadata.rs b/src-tauri/src/metadata.rs index e2f39f5..ca55aae 100644 --- a/src-tauri/src/metadata.rs +++ b/src-tauri/src/metadata.rs @@ -104,10 +104,10 @@ async fn run_fetch(token: String, items: Vec) -> Vec { } 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, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "status", rename_all = "camelCase")] +pub enum MovieFetchOutcome { + Found { + title: String, + release_date: Option, + }, + /// 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) -> Vec { + 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) -> Vec { + 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 +} diff --git a/src/components/RenamePanel.tsx b/src/components/RenamePanel.tsx index 9351c2f..52628c7 100644 --- a/src/components/RenamePanel.tsx +++ b/src/components/RenamePanel.tsx @@ -19,6 +19,10 @@ interface Item { ext: string; bindings: Record | 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 = await Promise.all( + targets.map(async (item): Promise => { try { - const guess = await invoke("guess_episode", { + const episode = await invoke("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("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(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(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 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), - // Human-readable aliases (this is what the default naming scheme uses). - "show name": seriesName, - "season number": String(guess.season), - "episode number": pad2(guess.episode), + // 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,40 +793,95 @@ export default function RenamePanel() { setFetching(true); try { - const query: FetchQuery[] = targets.map((item) => ({ - id: item.id, - seriesQuery: item.bindings!.n, - season: Number(item.bindings!.season), - episode: Number(item.bindings!.episode), - })); - const results = await invoke("fetch_metadata", { items: query }); - const byId = new Map(results.map((r) => [r.id, r] as const)); + const [tvResults, movieResults] = await Promise.all([ + tvTargets.length > 0 + ? invoke("fetch_metadata", { + items: tvTargets.map( + (item): FetchQuery => ({ + id: item.id, + seriesQuery: item.bindings!.n, + season: Number(item.bindings!.season), + episode: Number(item.bindings!.episode), + }) + ), + }) + : Promise.resolve([]), + movieTargets.length > 0 + ? invoke("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([]), + ]); - // 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; - return { - ...item, - bindings: { - ...item.bindings, - // FileBot-style short names, for compatibility. - n: seriesName, - t: episodeTitle, - ...(airDate ? { airdate: airDate } : {}), - // Human-readable aliases. - "show name": seriesName, - "episode name": episodeTitle, - }, - }; + const tvResult = tvById.get(item.id); + if (tvResult && tvResult.outcome.status === "found") { + const { seriesName, episodeTitle, airDate } = tvResult.outcome; + return { + ...item, + bindings: { + ...item.bindings, + // FileBot-style short names, for compatibility. + n: seriesName, + t: episodeTitle, + ...(airDate ? { airdate: airDate } : {}), + // Human-readable aliases. + "show name": seriesName, + "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() {
- After Match (local, offline) + TV, after Match (local, offline) {"{show name}"} {"{season number}"} @@ -897,17 +1035,27 @@ export default function RenamePanel() {
- After Fetch Data (real TMDB metadata) + TV, after Fetch Data (real TMDB metadata) {"{episode name}"} {"{airdate}"}
+
+ + Movies, after Match or Fetch Data + + {"{movie name}"} + {"{year}"} + {"{airdate}"} +

- 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 TMDB Settings first. FileBot's own short field names ({"{n}"},{" "} - {"{s00e00}"}, {"{t}"}, 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 TMDB Settings 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 + ({"{n}"}, {"{s00e00}"}, {"{t}"}, {"{y}"}, etc.) still + work too, side by side with the names above.

Use {"/"} in the scheme to organize files into subfolders as part of renaming, e.g.{" "}