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
+217
-69
@@ -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.{" "}
|
||||
|
||||
Reference in New Issue
Block a user