Add movie support to Fetch Data: local title/year guessing plus TMDB movie lookup
- renamr-core::matching: guess_movie() extracts a movie title (and release year, if
present) from a filename using the same year/quality-token boundary heuristics real
release names follow, as a fallback when guess_episode() finds no season/episode
marker.
- renamr-core::web::tmdb: search_movie()/lookup_movie() mirror the TV client, reusing
the same Sorensen-Dice title-disambiguation as lookup_episode(), narrowed further by
TMDB's own primary_release_year filter when a year was locally guessed. Also exposed
search_tv_best_match() and fixed a latent gap where the real Fetch Data command path
(metadata.rs::fetch_season) bypassed the fuzzy show-name disambiguation entirely,
doing its own raw first-result selection instead of reusing it.
- src-tauri: new guess_movie command and fetch_movie_metadata command/types
(MovieFetchQuery/Outcome/Result), registered alongside the existing TV commands.
- Frontend: Item now tracks mediaKind ("tv"/"movie"/null); Match tries guess_episode
then falls back to guess_movie; Fetch Data resolves TV and movie targets via their
own commands concurrently and merges results. A single batch can mix TV and movie
files - each only fills in the fields that apply to it. Movie fields ({movie name},
{year}, reusing {airdate} for release date, matching FileBot's own unified binding)
are documented in the naming-scheme field reference.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
93b5ac5e56
commit
45a9f5d523
@@ -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
|
**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
|
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
|
native file/folder loading and drag-and-drop, local filename-based season/episode *and
|
||||||
detection ("Match") is working, real online metadata lookups against TMDB ("Fetch
|
movie title/year* detection ("Match") is working, real online metadata lookups against
|
||||||
Data") are working — including fuzzy show-name disambiguation, so a search that
|
TMDB ("Fetch Data") are working for both TV episodes and movies — including fuzzy
|
||||||
returns several similarly-named shows doesn't just take TMDB's top-ranked hit — and the
|
title disambiguation, so a search that returns several similarly-named results doesn't
|
||||||
actual on-disk rename operation is wired up (skip-on-conflict, with a confirmation step
|
just take TMDB's top-ranked hit — and the actual on-disk rename operation is wired up
|
||||||
showing the full old-path/new-path plan before anything touches disk). Rename can also
|
(skip-on-conflict, with a confirmation step showing the full old-path/new-path plan
|
||||||
organize files into subfolders — including ones that don't exist yet — when the naming
|
before anything touches disk). Rename can also organize files into subfolders —
|
||||||
scheme includes a `/`. Every completed rename is logged to a history panel that can
|
including ones that don't exist yet — when the naming scheme includes a `/`. Every
|
||||||
undo it, moving files back to their original paths. Full media-metadata bindings are
|
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.
|
not yet ported. See "Porting roadmap" below.
|
||||||
|
|
||||||
The naming scheme is fully user-editable from the app (the "Naming Scheme" button next
|
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
|
crates/renamr-core/ Pure Rust core: naming-expression engine (ported), local
|
||||||
filename-based season/episode matching (ported, `matching/`),
|
filename-based season/episode *and movie title/year* matching
|
||||||
text-similarity scoring for disambiguating search results
|
(ported, `matching/`), text-similarity scoring for disambiguating
|
||||||
(`similarity/`, partial `EpisodeMetrics` port), a TMDB metadata
|
search results (`similarity/`, partial `EpisodeMetrics` port), a
|
||||||
client (`web/tmdb.rs` — TV search + episode lookup, disambiguated
|
TMDB metadata client (`web/tmdb.rs` — TV search + episode lookup
|
||||||
via `similarity/`, tested against a mocked HTTP server via
|
and movie search + lookup, both disambiguated via `similarity/`,
|
||||||
`mockito`), the on-disk rename engine (`rename/`) —
|
tested against a mocked HTTP server via `mockito`), the on-disk
|
||||||
|
rename engine (`rename/`) —
|
||||||
skip-on-conflict, can also organize into (auto-created)
|
skip-on-conflict, can also organize into (auto-created)
|
||||||
subfolders when the naming scheme includes `/` — and a rename
|
subfolders when the naming scheme includes `/` — and a rename
|
||||||
history/undo log (`history/`, a partial `HistoryDialog` port).
|
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).
|
datasource clients (TVDB/AniDB/etc).
|
||||||
No Tauri dependency, so it builds and unit-tests standalone.
|
No Tauri dependency, so it builds and unit-tests standalone.
|
||||||
src-tauri/ Tauri app shell: exposes renamr-core as commands to the frontend
|
src-tauri/ Tauri app shell: exposes renamr-core as commands to the frontend
|
||||||
(naming-scheme rendering, filename matching, TMDB metadata fetch,
|
(naming-scheme rendering, filename matching for both TV and
|
||||||
executing a rename plan, native file/folder dialogs, drag-and-drop
|
movies, TMDB metadata fetch — `metadata.rs` for TV episodes,
|
||||||
expansion), plus `settings.rs` (TMDB token) and `history.rs`
|
plus a movie-fetch command alongside it — executing a rename
|
||||||
(rename history/undo log) — both stored as JSON in the OS
|
plan, native file/folder dialogs, drag-and-drop expansion), plus
|
||||||
app-config directory — app metadata/icons/window config
|
`settings.rs` (TMDB token) and `history.rs` (rename history/undo
|
||||||
(tauri.conf.json).
|
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
|
src/ React + TypeScript UI (Vite). Faithful recreation of the original
|
||||||
two-pane rename screen (Aurora theme) wired to real file loading,
|
two-pane rename screen (Aurora theme) wired to real file loading,
|
||||||
drag-and-drop, live naming-scheme preview, local matching, TMDB
|
drag-and-drop, live naming-scheme preview, local TV/movie
|
||||||
fetch (behind a Settings modal for the API token), a
|
matching, TMDB fetch for both (behind a Settings modal for the
|
||||||
confirm-before-touching-disk modal for the actual rename, and a
|
API token), a confirm-before-touching-disk modal for the actual
|
||||||
History panel that can undo a past rename.
|
rename, and a History panel that can undo a past rename.
|
||||||
scripts/gen_icons.py Regenerates src-tauri/icons/ (placeholder branding — swap the
|
scripts/gen_icons.py Regenerates src-tauri/icons/ (placeholder branding — swap the
|
||||||
colors/glyph in the script, or replace the icons directly, to
|
colors/glyph in the script, or replace the icons directly, to
|
||||||
change the look).
|
change the look).
|
||||||
@@ -191,25 +195,30 @@ ported from, for anyone picking this up:
|
|||||||
3. **Matching/similarity engine** — `net.filebot.similarity.*`. Local filename-based
|
3. **Matching/similarity engine** — `net.filebot.similarity.*`. Local filename-based
|
||||||
season/episode detection is done (`crates/renamr-core/src/matching`, ported from the
|
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
|
pattern-matching half of `SeasonEpisodeMatcher.java`, adapted to Rust's `regex` crate
|
||||||
which lacks lookahead/lookbehind — wired to the "Match" button). Show-name
|
which lacks lookahead/lookbehind — wired to the "Match" button), and so is a local
|
||||||
disambiguation is also done (`crates/renamr-core/src/similarity`, a partial port of
|
movie title/year heuristic (`guess_movie` in the same module) used as a fallback
|
||||||
the *scoring* idea behind `EpisodeMetrics.java`'s fuzzy title-matching cascade —
|
whenever a filename carries no season/episode marker — a release name is either a TV
|
||||||
Sorensen-Dice similarity over normalized-title character bigrams): "Fetch Data" now
|
episode or a movie, never both. Title disambiguation is also done
|
||||||
picks whichever TMDB search result is textually closest to the local guess rather
|
(`crates/renamr-core/src/similarity`, a partial port of the *scoring* idea behind
|
||||||
than just trusting TMDB's relevance/popularity-ranked first hit, and reports "not
|
`EpisodeMetrics.java`'s fuzzy title-matching cascade — Sorensen-Dice similarity over
|
||||||
found" instead of guessing when nothing in the results is a plausible match at all.
|
normalized-title character bigrams), shared by both the TV and movie TMDB lookups:
|
||||||
`DateMatcher` (numbering/air-date-based identification, the other half of the
|
"Fetch Data" picks whichever TMDB search result is textually closest to the local
|
||||||
original `EpisodeMetrics`) is not ported — Renamr doesn't do date-based episode
|
guess rather than just trusting TMDB's relevance/popularity-ranked first hit, and
|
||||||
identification.
|
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
|
4. **Media detection** — `net.filebot.media.MediaDetection` (1356 lines): ties file-name
|
||||||
parsing + matching together to auto-identify what a file is.
|
parsing + matching together to auto-identify what a file is.
|
||||||
5. **Web datasource clients** — `net.filebot.web.*` (~40 files): TheMovieDB, TheTVDB,
|
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`,
|
AniDB, OMDb, TVmaze, OpenSubtitles, etc. TMDB is done for both TV and movies
|
||||||
`reqwest` + `serde_json`, Bearer-token auth) — search-by-name plus per-season episode
|
(`crates/renamr-core/src/web/tmdb.rs`, `reqwest` + `serde_json`, Bearer-token auth) —
|
||||||
lookup, wired to "Fetch Data", disambiguating multi-result searches via the
|
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
|
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,
|
TMDB's top-ranked hit. All other sources (TheTVDB, AniDB, OMDb, TVmaze,
|
||||||
OMDb, TVmaze, OpenSubtitles) are not yet started.
|
OpenSubtitles) are not yet started.
|
||||||
6. **Rename engine + CLI** — `net.filebot.cli.CmdlineOperations` (1235 lines) and
|
6. **Rename engine + CLI** — `net.filebot.cli.CmdlineOperations` (1235 lines) and
|
||||||
`ArgumentBean` (415 lines, the full flag surface: `-rename`, `--db`, `--format`,
|
`ArgumentBean` (415 lines, the full flag surface: `-rename`, `--db`, `--format`,
|
||||||
`--action`, `--conflict`, `-get-subtitles`, `-script`, etc.). The rename operation is
|
`--action`, `--conflict`, `-get-subtitles`, `-script`, etc.). The rename operation is
|
||||||
|
|||||||
@@ -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())
|
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
|
/// Cleans up the raw text preceding a season/episode match into a plausible series
|
||||||
/// name: `.`/`_` used as word separators become spaces, and leading/trailing
|
/// name: `.`/`_` used as word separators become spaces, and leading/trailing
|
||||||
/// whitespace/dash/dot clutter is trimmed.
|
/// whitespace/dash/dot clutter is trimmed.
|
||||||
@@ -64,6 +79,15 @@ fn clean_series_name(raw: &str) -> String {
|
|||||||
trimmed.split_whitespace().collect::<Vec<_>>().join(" ")
|
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)
|
/// 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.
|
/// 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
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -153,4 +223,37 @@ mod tests {
|
|||||||
assert_eq!(guess.season, 1);
|
assert_eq!(guess.season, 1);
|
||||||
assert_eq!(guess.episode, 2);
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
//! A small client for [TMDB](https://www.themoviedb.org/)'s v3 API: search for a TV
|
//! 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
|
//! 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
|
//! original. Authenticates with a TMDB **API Read Access Token** (the Bearer-token
|
||||||
//! straightforward follow-up using the parallel `/search/movie` + `/movie/{id}`
|
//! method TMDB documents as the current default) rather than the older `api_key` query
|
||||||
//! endpoints, not yet added). Authenticates with a TMDB **API Read Access Token**
|
//! parameter.
|
||||||
//! (the Bearer-token method TMDB documents as the current default) rather than the
|
|
||||||
//! older `api_key` query parameter.
|
|
||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
@@ -70,6 +69,33 @@ pub struct EpisodeInfo {
|
|||||||
pub air_date: Option<String>,
|
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 {
|
pub struct TmdbClient {
|
||||||
http: reqwest::Client,
|
http: reqwest::Client,
|
||||||
token: String,
|
token: String,
|
||||||
@@ -161,21 +187,33 @@ impl TmdbClient {
|
|||||||
.ok_or_else(|| TmdbError::NotFound(format!("S{season:02}E{episode:02}")))
|
.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
|
/// 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
|
/// episode. Disambiguates a multi-result search by picking whichever result's name
|
||||||
/// is textually closest to `series_query` (see `similarity::best_title_match`)
|
/// is textually closest to `series_query` (see `search_tv_best_match`) rather than
|
||||||
/// rather than just trusting TMDB's own top-ranked (relevance/popularity) result -
|
/// just trusting TMDB's own top-ranked (relevance/popularity) result - so a more
|
||||||
/// so a more popular but differently-named show doesn't get picked over an exact
|
/// popular but differently-named show doesn't get picked over an exact match that's
|
||||||
/// match that's merely ranked lower. If nothing in the results is a plausible
|
/// merely ranked lower. If nothing in the results is a plausible match at all,
|
||||||
/// match at all, reports `NotFound` instead of guessing.
|
/// reports `NotFound` instead of guessing.
|
||||||
pub async fn lookup_episode(
|
pub async fn lookup_episode(
|
||||||
&self,
|
&self,
|
||||||
series_query: &str,
|
series_query: &str,
|
||||||
season: i32,
|
season: i32,
|
||||||
episode: i32,
|
episode: i32,
|
||||||
) -> Result<EpisodeInfo, TmdbError> {
|
) -> Result<EpisodeInfo, TmdbError> {
|
||||||
let results = self.search_tv(series_query).await?;
|
let best = self
|
||||||
let best = best_title_match(series_query, &results, |r| r.name.as_str(), NAME_MATCH_THRESHOLD)
|
.search_tv_best_match(series_query)
|
||||||
|
.await?
|
||||||
.ok_or_else(|| TmdbError::NotFound(series_query.to_string()))?;
|
.ok_or_else(|| TmdbError::NotFound(series_query.to_string()))?;
|
||||||
|
|
||||||
let info = self.episode(best.id, season, episode).await?;
|
let info = self.episode(best.id, season, episode).await?;
|
||||||
@@ -185,6 +223,38 @@ impl TmdbClient {
|
|||||||
air_date: info.air_date,
|
air_date: info.air_date,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Searches for a movie by title, optionally narrowed to a specific release year
|
||||||
|
/// (TMDB's own `primary_release_year` filter - helps disambiguate a remake from
|
||||||
|
/// the original before name-similarity scoring even runs). Results come back
|
||||||
|
/// TMDB-ranked, same caveat as `search_tv`.
|
||||||
|
pub async fn search_movie(&self, query: &str, year: Option<i32>) -> Result<Vec<MovieResult>, TmdbError> {
|
||||||
|
let year_str;
|
||||||
|
let mut params: Vec<(&str, &str)> = vec![("query", query)];
|
||||||
|
if let Some(y) = year {
|
||||||
|
year_str = y.to_string();
|
||||||
|
params.push(("primary_release_year", &year_str));
|
||||||
|
}
|
||||||
|
let parsed: SearchMovieResponse = self.get_json("/search/movie", ¶ms).await?;
|
||||||
|
Ok(parsed.results)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience: search for the best-matching movie by title (and optional release
|
||||||
|
/// year), picking whichever result's title is textually closest to `query` - same
|
||||||
|
/// disambiguation approach as `lookup_episode`, so a more popular but
|
||||||
|
/// differently-titled movie doesn't get picked over an exact match ranked lower.
|
||||||
|
/// Unlike TV, a movie search result already carries everything needed (title,
|
||||||
|
/// release date), so there's no second per-item request the way `episode()` needs.
|
||||||
|
pub async fn lookup_movie(&self, query: &str, year: Option<i32>) -> Result<MovieInfo, TmdbError> {
|
||||||
|
let results = self.search_movie(query, year).await?;
|
||||||
|
let best = best_title_match(query, &results, |r| r.title.as_str(), NAME_MATCH_THRESHOLD)
|
||||||
|
.ok_or_else(|| TmdbError::NotFound(query.to_string()))?;
|
||||||
|
|
||||||
|
Ok(MovieInfo {
|
||||||
|
title: best.title.clone(),
|
||||||
|
release_date: best.release_date.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Just the episode-specific fields, before the series name (known by the caller of
|
/// 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(_))));
|
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]
|
#[tokio::test]
|
||||||
async fn surfaces_an_invalid_token_as_an_api_error() {
|
async fn surfaces_an_invalid_token_as_an_api_error() {
|
||||||
let mut server = mockito::Server::new_async().await;
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
|||||||
+12
-1
@@ -4,7 +4,7 @@ mod metadata;
|
|||||||
mod settings;
|
mod settings;
|
||||||
|
|
||||||
use renamr_core::format::ExpressionFormat;
|
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::rename::{self, RenamePlanItem, RenameResult};
|
||||||
use renamr_core::string_bindings;
|
use renamr_core::string_bindings;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -30,6 +30,15 @@ fn guess_episode(filename: String) -> Option<EpisodeGuess> {
|
|||||||
matching::guess_episode(&filename)
|
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
|
/// Executes a batch of planned on-disk renames (optionally moving files into
|
||||||
/// subfolders - see `renamr_core::rename`; conflicting targets are always skipped,
|
/// subfolders - see `renamr_core::rename`; conflicting targets are always skipped,
|
||||||
/// never overwritten). The UI is expected to have already shown the user the full
|
/// 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![
|
.invoke_handler(tauri::generate_handler![
|
||||||
render_expression,
|
render_expression,
|
||||||
guess_episode,
|
guess_episode,
|
||||||
|
guess_movie,
|
||||||
execute_rename,
|
execute_rename,
|
||||||
metadata::fetch_metadata,
|
metadata::fetch_metadata,
|
||||||
|
metadata::fetch_movie_metadata,
|
||||||
settings::get_tmdb_token,
|
settings::get_tmdb_token,
|
||||||
settings::save_tmdb_token,
|
settings::save_tmdb_token,
|
||||||
history::list_rename_history,
|
history::list_rename_history,
|
||||||
|
|||||||
@@ -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 {
|
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 = client
|
||||||
let best = search_results
|
.search_tv_best_match(series_query)
|
||||||
.into_iter()
|
.await
|
||||||
.next()
|
.map_err(|e| e.to_string())?
|
||||||
.ok_or_else(|| format!("no TMDB match for \"{series_query}\""))?;
|
.ok_or_else(|| format!("no TMDB match for \"{series_query}\""))?;
|
||||||
let episodes = client
|
let episodes = client
|
||||||
.season_episodes(best.id, season)
|
.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())?;
|
.map_err(|e| e.to_string())?;
|
||||||
Ok((best.name, episodes))
|
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
@@ -19,6 +19,10 @@ interface Item {
|
|||||||
ext: string;
|
ext: string;
|
||||||
bindings: Record<string, string> | null; // null = no data to preview with (rendered muted)
|
bindings: Record<string, string> | null; // null = no data to preview with (rendered muted)
|
||||||
isSample: boolean;
|
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 {
|
interface FileEntry {
|
||||||
@@ -36,6 +40,18 @@ interface EpisodeGuess {
|
|||||||
episode: number;
|
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 {
|
function pad2(n: number): string {
|
||||||
return n.toString().padStart(2, "0");
|
return n.toString().padStart(2, "0");
|
||||||
}
|
}
|
||||||
@@ -84,6 +100,26 @@ interface FetchResult {
|
|||||||
outcome: FetchOutcome;
|
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
|
// Rename history / undo. Mirrors renamr-core's `history` module (serde-tagged on
|
||||||
// "status" for the outcome, same convention as RenameOutcome/FetchOutcome above).
|
// "status" for the outcome, same convention as RenameOutcome/FetchOutcome above).
|
||||||
interface HistoryEntry {
|
interface HistoryEntry {
|
||||||
@@ -133,6 +169,7 @@ const SAMPLE_ITEMS: Item[] = [
|
|||||||
"episode name": "Ozymandias",
|
"episode name": "Ozymandias",
|
||||||
},
|
},
|
||||||
isSample: true,
|
isSample: true,
|
||||||
|
mediaKind: "tv",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "sample-2",
|
id: "sample-2",
|
||||||
@@ -148,6 +185,7 @@ const SAMPLE_ITEMS: Item[] = [
|
|||||||
"episode name": "Granite State",
|
"episode name": "Granite State",
|
||||||
},
|
},
|
||||||
isSample: true,
|
isSample: true,
|
||||||
|
mediaKind: "tv",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "sample-3",
|
id: "sample-3",
|
||||||
@@ -163,6 +201,7 @@ const SAMPLE_ITEMS: Item[] = [
|
|||||||
"episode name": "Gay Witch Hunt",
|
"episode name": "Gay Witch Hunt",
|
||||||
},
|
},
|
||||||
isSample: true,
|
isSample: true,
|
||||||
|
mediaKind: "tv",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "sample-4",
|
id: "sample-4",
|
||||||
@@ -170,6 +209,7 @@ const SAMPLE_ITEMS: Item[] = [
|
|||||||
ext: "mp4",
|
ext: "mp4",
|
||||||
bindings: null,
|
bindings: null,
|
||||||
isSample: true,
|
isSample: true,
|
||||||
|
mediaKind: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -195,6 +235,7 @@ function realFileToItem(entry: FileEntry): Item {
|
|||||||
ext: entry.ext,
|
ext: entry.ext,
|
||||||
bindings: { name: entry.name, ext: entry.ext },
|
bindings: { name: entry.name, ext: entry.ext },
|
||||||
isSample: false,
|
isSample: false,
|
||||||
|
mediaKind: null, // unknown until Match runs
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,56 +436,90 @@ export default function RenamePanel() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Local, filename-based season/episode detection (renamr-core's `matching` module).
|
// Local, filename-based season/episode/movie detection (renamr-core's `matching`
|
||||||
// Populates `{n}`/`{s00e00}`/`{season}`/`{episode}` bindings from patterns already
|
// module). Tries `guess_episode` first; when a filename carries no season/episode
|
||||||
// present in the filename text - no network calls, no metadata lookup, so it's
|
// marker, falls back to `guess_movie` (a release name is either a TV episode or a
|
||||||
// distinct from (and much cheaper than) "Fetch Data" online matching, which still
|
// movie, never both - see the doc comment on `guess_movie` in renamr-core). Populates
|
||||||
// isn't built.
|
// 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 runMatching = async () => {
|
||||||
const targets = items.filter((i) => !i.isSample);
|
const targets = items.filter((i) => !i.isSample);
|
||||||
if (targets.length === 0) {
|
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);
|
window.clearTimeout((runMatching as any)._t);
|
||||||
(runMatching as any)._t = window.setTimeout(() => setStatus(null), 3200);
|
(runMatching as any)._t = window.setTimeout(() => setStatus(null), 3200);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = await Promise.all(
|
const results: Array<readonly [string, MatchGuess | null]> = await Promise.all(
|
||||||
targets.map(async (item) => {
|
targets.map(async (item): Promise<readonly [string, MatchGuess | null]> => {
|
||||||
try {
|
try {
|
||||||
const guess = await invoke<EpisodeGuess | null>("guess_episode", {
|
const episode = await invoke<EpisodeGuess | null>("guess_episode", {
|
||||||
filename: item.originalName,
|
filename: item.originalName,
|
||||||
});
|
});
|
||||||
return [item.id, guess] as const;
|
if (episode) return [item.id, { kind: "tv", episode }];
|
||||||
} catch {
|
} 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);
|
const guesses = new Map(results);
|
||||||
// Count matches from the results directly, not inside the setItems updater below:
|
// 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
|
// 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.
|
// 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) =>
|
setItems((prev) =>
|
||||||
prev.map((item) => {
|
prev.map((item) => {
|
||||||
const guess = guesses.get(item.id);
|
const guess = guesses.get(item.id);
|
||||||
if (!guess) return item;
|
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 {
|
return {
|
||||||
...item,
|
...item,
|
||||||
|
mediaKind: "tv",
|
||||||
bindings: {
|
bindings: {
|
||||||
...item.bindings,
|
...item.bindings,
|
||||||
// FileBot-style short names, for compatibility with expressions that use them.
|
// FileBot-style short names, for compatibility with expressions that use them.
|
||||||
n: seriesName,
|
n: seriesName,
|
||||||
s00e00: `S${pad2(guess.season)}E${pad2(guess.episode)}`,
|
s00e00: `S${pad2(episode.season)}E${pad2(episode.episode)}`,
|
||||||
season: String(guess.season),
|
season: String(episode.season),
|
||||||
episode: String(guess.episode),
|
episode: String(episode.episode),
|
||||||
// Human-readable aliases (this is what the default naming scheme uses).
|
// Human-readable aliases (this is what the default naming scheme uses).
|
||||||
"show name": seriesName,
|
"show name": seriesName,
|
||||||
"season number": String(guess.season),
|
"season number": String(episode.season),
|
||||||
"episode number": pad2(guess.episode),
|
"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
|
// 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
|
// user hasn't already customized the naming scheme away from the plain-filename
|
||||||
// default.
|
// default. Movies deliberately don't drive this auto-upgrade - a single "best
|
||||||
if (expressionRef.current === REAL_FILE_EXPRESSION && matched > 0) {
|
// expression" heuristic doesn't generalize across a batch mixing TV and movies.
|
||||||
|
if (expressionRef.current === REAL_FILE_EXPRESSION && matchedTv > 0) {
|
||||||
setExpression(MATCHED_EXPRESSION);
|
setExpression(MATCHED_EXPRESSION);
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus(
|
setStatus(
|
||||||
matched > 0
|
matched > 0
|
||||||
? `Matched season/episode on ${matched} of ${targets.length} file${targets.length === 1 ? "" : "s"} (filename-based, local only).`
|
? `Matched ${matched} of ${targets.length} file${targets.length === 1 ? "" : "s"} (${matchedTv} TV, ${matchedMovie} movie${matchedMovie === 1 ? "" : "s"}; filename-based, local only).`
|
||||||
: "No season/episode pattern recognized in the loaded filenames."
|
: "No season/episode or movie title recognized in the loaded filenames."
|
||||||
);
|
);
|
||||||
window.clearTimeout((runMatching as any)._t);
|
window.clearTimeout((runMatching as any)._t);
|
||||||
(runMatching as any)._t = window.setTimeout(() => setStatus(null), 4000);
|
(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
|
// "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
|
// metadata. Requires Match to have already populated either a series name + season/
|
||||||
// (that's the search query) - this is deliberately layered on top of local matching,
|
// episode (TV) or a movie title (movie) - that's the search query - so this is
|
||||||
// not a replacement for it.
|
// 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 () => {
|
const runFetch = async () => {
|
||||||
if (hasTmdbToken === false) {
|
if (hasTmdbToken === false) {
|
||||||
setStatus("Add a free TMDB API token in Settings first, then Fetch Data will work.");
|
setStatus("Add a free TMDB API token in Settings first, then Fetch Data will work.");
|
||||||
@@ -700,9 +778,14 @@ export default function RenamePanel() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const targets = items.filter((i) => !i.isSample && i.bindings?.n && i.bindings?.season && i.bindings?.episode);
|
const tvTargets = items.filter(
|
||||||
if (targets.length === 0) {
|
(i) => !i.isSample && i.mediaKind === "tv" && i.bindings?.n && i.bindings?.season && i.bindings?.episode
|
||||||
setStatus("Run Match first — Fetch Data needs a guessed series name and season/episode to look up.");
|
);
|
||||||
|
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);
|
window.clearTimeout((runFetch as any)._t);
|
||||||
(runFetch as any)._t = window.setTimeout(() => setStatus(null), 4000);
|
(runFetch as any)._t = window.setTimeout(() => setStatus(null), 4000);
|
||||||
return;
|
return;
|
||||||
@@ -710,27 +793,54 @@ export default function RenamePanel() {
|
|||||||
|
|
||||||
setFetching(true);
|
setFetching(true);
|
||||||
try {
|
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,
|
id: item.id,
|
||||||
seriesQuery: item.bindings!.n,
|
seriesQuery: item.bindings!.n,
|
||||||
season: Number(item.bindings!.season),
|
season: Number(item.bindings!.season),
|
||||||
episode: Number(item.bindings!.episode),
|
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 -
|
const tvById = new Map(tvResults.map((r) => [r.id, r] as const));
|
||||||
// same rule as runMatching/confirmRename above (React doesn't run a functional
|
const movieById = new Map(movieResults.map((r) => [r.id, r] as const));
|
||||||
// updater synchronously, so a counter mutated in there wouldn't be ready yet).
|
|
||||||
const found = results.filter((r) => r.outcome.status === "found").length;
|
// Compute counts from the raw results directly, not inside the setItems updater
|
||||||
const notFound = results.filter((r) => r.outcome.status === "notFound").length;
|
// below - same rule as runMatching/confirmRename above (React doesn't run a
|
||||||
const failed = results.filter((r) => r.outcome.status === "failed").length;
|
// 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) =>
|
setItems((prev) =>
|
||||||
prev.map((item) => {
|
prev.map((item) => {
|
||||||
const result = byId.get(item.id);
|
const tvResult = tvById.get(item.id);
|
||||||
if (!result || result.outcome.status !== "found") return item;
|
if (tvResult && tvResult.outcome.status === "found") {
|
||||||
const { seriesName, episodeTitle, airDate } = result.outcome;
|
const { seriesName, episodeTitle, airDate } = tvResult.outcome;
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
bindings: {
|
bindings: {
|
||||||
@@ -744,6 +854,34 @@ export default function RenamePanel() {
|
|||||||
"episode name": episodeTitle,
|
"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);
|
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 (notFound > 0) parts.push(`${notFound} not found on TMDB`);
|
||||||
if (failed > 0) parts.push(`${failed} failed`);
|
if (failed > 0) parts.push(`${failed} failed`);
|
||||||
setStatus(parts.join(", ") + ".");
|
setStatus(parts.join(", ") + ".");
|
||||||
@@ -889,7 +1027,7 @@ export default function RenamePanel() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="format-fields-group">
|
<div className="format-fields-group">
|
||||||
<span className="format-fields-group-title">
|
<span className="format-fields-group-title">
|
||||||
After <strong>Match</strong> (local, offline)
|
TV, after <strong>Match</strong> (local, offline)
|
||||||
</span>
|
</span>
|
||||||
<code>{"{show name}"}</code>
|
<code>{"{show name}"}</code>
|
||||||
<code>{"{season number}"}</code>
|
<code>{"{season number}"}</code>
|
||||||
@@ -897,17 +1035,27 @@ export default function RenamePanel() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="format-fields-group">
|
<div className="format-fields-group">
|
||||||
<span className="format-fields-group-title">
|
<span className="format-fields-group-title">
|
||||||
After <strong>Fetch Data</strong> (real TMDB metadata)
|
TV, after <strong>Fetch Data</strong> (real TMDB metadata)
|
||||||
</span>
|
</span>
|
||||||
<code>{"{episode name}"}</code>
|
<code>{"{episode name}"}</code>
|
||||||
<code>{"{airdate}"}</code>
|
<code>{"{airdate}"}</code>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<p className="format-hint">
|
<p className="format-hint">
|
||||||
Match scans filenames locally for season/episode patterns (no network calls); Fetch Data resolves that
|
Match scans filenames locally for season/episode or movie title/year patterns (no network calls); Fetch
|
||||||
guess against real TMDB metadata for the real episode title and air date — needs a free TMDB API token
|
Data resolves that guess against real TMDB metadata for the real title, episode title, and air/release
|
||||||
set in <strong>TMDB Settings</strong> first. FileBot's own short field names (<code>{"{n}"}</code>,{" "}
|
date — needs a free TMDB API token set in <strong>TMDB Settings</strong> first. A batch can freely mix TV
|
||||||
<code>{"{s00e00}"}</code>, <code>{"{t}"}</code>, etc.) still work too, side by side with the names above.
|
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>
|
||||||
<p className="format-hint">
|
<p className="format-hint">
|
||||||
Use <code>{"/"}</code> in the scheme to organize files into subfolders as part of renaming, e.g.{" "}
|
Use <code>{"/"}</code> in the scheme to organize files into subfolders as part of renaming, e.g.{" "}
|
||||||
|
|||||||
Reference in New Issue
Block a user