Add movie support to Fetch Data: local title/year guessing plus TMDB movie lookup

- renamr-core::matching: guess_movie() extracts a movie title (and release year, if
  present) from a filename using the same year/quality-token boundary heuristics real
  release names follow, as a fallback when guess_episode() finds no season/episode
  marker.
- renamr-core::web::tmdb: search_movie()/lookup_movie() mirror the TV client, reusing
  the same Sorensen-Dice title-disambiguation as lookup_episode(), narrowed further by
  TMDB's own primary_release_year filter when a year was locally guessed. Also exposed
  search_tv_best_match() and fixed a latent gap where the real Fetch Data command path
  (metadata.rs::fetch_season) bypassed the fuzzy show-name disambiguation entirely,
  doing its own raw first-result selection instead of reusing it.
- src-tauri: new guess_movie command and fetch_movie_metadata command/types
  (MovieFetchQuery/Outcome/Result), registered alongside the existing TV commands.
- Frontend: Item now tracks mediaKind ("tv"/"movie"/null); Match tries guess_episode
  then falls back to guess_movie; Fetch Data resolves TV and movie targets via their
  own commands concurrently and merges results. A single batch can mix TV and movie
  files - each only fills in the fields that apply to it. Movie fields ({movie name},
  {year}, reusing {airdate} for release date, matching FileBot's own unified binding)
  are documented in the naming-scheme field reference.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Renamr Scaffold
2026-08-29 04:04:16 +00:00
co-authored by Claude Sonnet 5
parent 93b5ac5e56
commit 45a9f5d523
6 changed files with 616 additions and 127 deletions
+217 -69
View File
@@ -19,6 +19,10 @@ interface Item {
ext: string;
bindings: Record<string, string> | null; // null = no data to preview with (rendered muted)
isSample: boolean;
// What kind of media Match recognized this file as - "tv"/"movie" once Match (or
// Fetch Data) has run, null before that or if nothing was recognized. Kept outside
// `bindings` so it doesn't leak into the naming-scheme field-reference list.
mediaKind: "tv" | "movie" | null;
}
interface FileEntry {
@@ -36,6 +40,18 @@ interface EpisodeGuess {
episode: number;
}
// Result of the local, filename-only movie title/year matcher (renamr-core's
// `matching::guess_movie`, exposed via the `guess_movie` Tauri command). Tried as a
// fallback whenever `guess_episode` finds no season/episode pattern - a release name
// is either a TV episode or a movie, never both.
interface MovieGuess {
title: string;
year: number | null;
}
// What runMatching resolved a file to, before it's written into the item's bindings.
type MatchGuess = { kind: "tv"; episode: EpisodeGuess } | { kind: "movie"; movie: MovieGuess };
function pad2(n: number): string {
return n.toString().padStart(2, "0");
}
@@ -84,6 +100,26 @@ interface FetchResult {
outcome: FetchOutcome;
}
// Sent to `fetch_movie_metadata`: one file's local movie-title guess (from Match) to
// resolve against real TMDB data. Mirrors FetchQuery, but for movies - a year is
// optional since not every release name carries one.
interface MovieFetchQuery {
id: string;
titleQuery: string;
year: number | null;
}
// Mirrors renamr-core's `MovieFetchOutcome` (serde-tagged on "status").
type MovieFetchOutcome =
| { status: "found"; title: string; releaseDate: string | null }
| { status: "notFound" }
| { status: "failed"; error: string };
interface MovieFetchResult {
id: string;
outcome: MovieFetchOutcome;
}
// Rename history / undo. Mirrors renamr-core's `history` module (serde-tagged on
// "status" for the outcome, same convention as RenameOutcome/FetchOutcome above).
interface HistoryEntry {
@@ -133,6 +169,7 @@ const SAMPLE_ITEMS: Item[] = [
"episode name": "Ozymandias",
},
isSample: true,
mediaKind: "tv",
},
{
id: "sample-2",
@@ -148,6 +185,7 @@ const SAMPLE_ITEMS: Item[] = [
"episode name": "Granite State",
},
isSample: true,
mediaKind: "tv",
},
{
id: "sample-3",
@@ -163,6 +201,7 @@ const SAMPLE_ITEMS: Item[] = [
"episode name": "Gay Witch Hunt",
},
isSample: true,
mediaKind: "tv",
},
{
id: "sample-4",
@@ -170,6 +209,7 @@ const SAMPLE_ITEMS: Item[] = [
ext: "mp4",
bindings: null,
isSample: true,
mediaKind: null,
},
];
@@ -195,6 +235,7 @@ function realFileToItem(entry: FileEntry): Item {
ext: entry.ext,
bindings: { name: entry.name, ext: entry.ext },
isSample: false,
mediaKind: null, // unknown until Match runs
};
}
@@ -395,56 +436,90 @@ export default function RenamePanel() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Local, filename-based season/episode detection (renamr-core's `matching` module).
// Populates `{n}`/`{s00e00}`/`{season}`/`{episode}` bindings from patterns already
// present in the filename text - no network calls, no metadata lookup, so it's
// distinct from (and much cheaper than) "Fetch Data" online matching, which still
// isn't built.
// Local, filename-based season/episode/movie detection (renamr-core's `matching`
// module). Tries `guess_episode` first; when a filename carries no season/episode
// marker, falls back to `guess_movie` (a release name is either a TV episode or a
// movie, never both - see the doc comment on `guess_movie` in renamr-core). Populates
// TV bindings (`{n}`/`{s00e00}`/`{season}`/`{episode}`/etc.) or movie bindings
// (`{n}`/`{y}`/etc.) accordingly - no network calls, no metadata lookup, so it's
// distinct from (and much cheaper than) "Fetch Data" online matching.
const runMatching = async () => {
const targets = items.filter((i) => !i.isSample);
if (targets.length === 0) {
setStatus("Load some files first, then Match will scan their names for season/episode info.");
setStatus("Load some files first, then Match will scan their names for season/episode or movie info.");
window.clearTimeout((runMatching as any)._t);
(runMatching as any)._t = window.setTimeout(() => setStatus(null), 3200);
return;
}
const results = await Promise.all(
targets.map(async (item) => {
const results: Array<readonly [string, MatchGuess | null]> = await Promise.all(
targets.map(async (item): Promise<readonly [string, MatchGuess | null]> => {
try {
const guess = await invoke<EpisodeGuess | null>("guess_episode", {
const episode = await invoke<EpisodeGuess | null>("guess_episode", {
filename: item.originalName,
});
return [item.id, guess] as const;
if (episode) return [item.id, { kind: "tv", episode }];
} catch {
return [item.id, null] as const;
// Fall through and try a movie guess instead.
}
try {
const movie = await invoke<MovieGuess | null>("guess_movie", {
filename: item.originalName,
});
if (movie) return [item.id, { kind: "movie", movie }];
} catch {
// Neither guess worked - leave this item unmatched.
}
return [item.id, null];
})
);
const guesses = new Map(results);
// Count matches from the results directly, not inside the setItems updater below:
// React doesn't run a functional updater synchronously, so a counter mutated in
// there wouldn't be ready yet for the status message/auto-upgrade check right after.
const matched = results.filter(([, guess]) => guess !== null).length;
const matchedTv = results.filter(([, guess]) => guess?.kind === "tv").length;
const matchedMovie = results.filter(([, guess]) => guess?.kind === "movie").length;
const matched = matchedTv + matchedMovie;
setItems((prev) =>
prev.map((item) => {
const guess = guesses.get(item.id);
if (!guess) return item;
const seriesName = guess.seriesName || item.bindings?.name || item.originalName;
if (guess.kind === "tv") {
const { episode } = guess;
const seriesName = episode.seriesName || item.bindings?.name || item.originalName;
return {
...item,
mediaKind: "tv",
bindings: {
...item.bindings,
// FileBot-style short names, for compatibility with expressions that use them.
n: seriesName,
s00e00: `S${pad2(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<FetchResult[]>("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<FetchResult[]>("fetch_metadata", {
items: tvTargets.map(
(item): FetchQuery => ({
id: item.id,
seriesQuery: item.bindings!.n,
season: Number(item.bindings!.season),
episode: Number(item.bindings!.episode),
})
),
})
: Promise.resolve<FetchResult[]>([]),
movieTargets.length > 0
? invoke<MovieFetchResult[]>("fetch_movie_metadata", {
items: movieTargets.map(
(item): MovieFetchQuery => ({
id: item.id,
titleQuery: item.bindings!.n,
year: item.bindings!.y ? Number(item.bindings!.y) : null,
})
),
})
: Promise.resolve<MovieFetchResult[]>([]),
]);
// Compute counts from `results` directly, not inside the setItems updater below -
// same rule as runMatching/confirmRename above (React doesn't run a functional
// updater synchronously, so a counter mutated in there wouldn't be ready yet).
const found = results.filter((r) => r.outcome.status === "found").length;
const notFound = results.filter((r) => r.outcome.status === "notFound").length;
const failed = results.filter((r) => r.outcome.status === "failed").length;
const tvById = new Map(tvResults.map((r) => [r.id, r] as const));
const movieById = new Map(movieResults.map((r) => [r.id, r] as const));
// Compute counts from the raw results directly, not inside the setItems updater
// below - same rule as runMatching/confirmRename above (React doesn't run a
// functional updater synchronously, so a counter mutated in there wouldn't be
// ready yet).
const found =
tvResults.filter((r) => r.outcome.status === "found").length +
movieResults.filter((r) => r.outcome.status === "found").length;
const notFound =
tvResults.filter((r) => r.outcome.status === "notFound").length +
movieResults.filter((r) => r.outcome.status === "notFound").length;
const failed =
tvResults.filter((r) => r.outcome.status === "failed").length +
movieResults.filter((r) => r.outcome.status === "failed").length;
setItems((prev) =>
prev.map((item) => {
const result = byId.get(item.id);
if (!result || result.outcome.status !== "found") return item;
const { seriesName, episodeTitle, airDate } = result.outcome;
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() {
</div>
<div className="format-fields-group">
<span className="format-fields-group-title">
After <strong>Match</strong> (local, offline)
TV, after <strong>Match</strong> (local, offline)
</span>
<code>{"{show name}"}</code>
<code>{"{season number}"}</code>
@@ -897,17 +1035,27 @@ export default function RenamePanel() {
</div>
<div className="format-fields-group">
<span className="format-fields-group-title">
After <strong>Fetch Data</strong> (real TMDB metadata)
TV, after <strong>Fetch Data</strong> (real TMDB metadata)
</span>
<code>{"{episode name}"}</code>
<code>{"{airdate}"}</code>
</div>
<div className="format-fields-group">
<span className="format-fields-group-title">
Movies, after <strong>Match</strong> or <strong>Fetch Data</strong>
</span>
<code>{"{movie name}"}</code>
<code>{"{year}"}</code>
<code>{"{airdate}"}</code>
</div>
</div>
<p className="format-hint">
Match scans filenames locally for season/episode patterns (no network calls); Fetch Data resolves that
guess against real TMDB metadata for the real episode title and air date — needs a free TMDB API token
set in <strong>TMDB Settings</strong> first. FileBot's own short field names (<code>{"{n}"}</code>,{" "}
<code>{"{s00e00}"}</code>, <code>{"{t}"}</code>, etc.) still work too, side by side with the names above.
Match scans filenames locally for season/episode or movie title/year patterns (no network calls); Fetch
Data resolves that guess against real TMDB metadata for the real title, episode title, and air/release
date — needs a free TMDB API token set in <strong>TMDB Settings</strong> first. A batch can freely mix TV
and movie files — each file only fills in the fields that apply to it. FileBot's own short field names
(<code>{"{n}"}</code>, <code>{"{s00e00}"}</code>, <code>{"{t}"}</code>, <code>{"{y}"}</code>, etc.) still
work too, side by side with the names above.
</p>
<p className="format-hint">
Use <code>{"/"}</code> in the scheme to organize files into subfolders as part of renaming, e.g.{" "}