Match now automatically runs Fetch Data right after - one click, not two
Requested directly: "when you click Match, I would prefer it to do what the lookup does. pulls the info, renames properly. just one less step needed." Match's local filename scan and Fetch Data's online lookup were two separate button clicks with no relationship between them in code - Fetch Data just happened to require Match to have already run. New runMatchAndFetch() wraps both: runs the same local match as before, then - if it found anything recognizable and either a credential-free source (TVmaze) is selected or the needed credential is already saved - immediately fetches real online metadata for it too, exactly as if the user had clicked both buttons in sequence. The standalone "Fetch Data" button next to New Names is untouched and still works on its own, for re-fetching after changing the metadata source or fixing a guessed name by hand. Two supporting changes made this safe to chain: - runMatching() now computes its updated items as a plain array (`items.map(...)`) instead of the usual `setItems((prev) => prev.map(...))` functional-update form, and returns that array. Chaining straight into runFetch() needed the *actual* just-matched items, not `items` React state - which wouldn't reflect the match yet, since setItems() only queues a re-render rather than updating what this function's own closure sees. - runFetch() gained itemsOverride/silentIfEmpty/chained options: read targets from the array just handed to it rather than state; skip the "Run Match first" message when Match simply found nothing to look up (its own message already covers that); and skip auto-opening the Settings modal on a missing credential when chained, so someone who hasn't set up Fetch Data (or doesn't want to) isn't interrupted by a popup on every single Match click - the status line still explains why fields are staying blank either way. Also fixed a status-message race: both functions clear their own status after a timeout, and chaining them naively could let Match's leftover timer blank out Fetch Data's result message early (observed while testing: a fast TVmaze lookup's "Fetched..." message got wiped a few seconds later by Match's own stale timer). Match now skips scheduling its success-path timer when chained, so whichever step actually finishes last owns the status line's timer. Verified via npm run build (clean, no Rust changes needed) + an 11-check Playwright script against npm run dev (mocking window.__TAURI_INTERNALS__.invoke): confirmed Match triggers guess_episode then automatically fetch_metadata with no separate click, in that order; the fetched episode name reaches the live preview; a missing TMDB token still runs the local match and explains the gap in the status line without forcing the Settings modal open; and clicking Match with nothing loaded is handled gracefully. Updated the naming-scheme editor's inline help text and the Match button's tooltip to describe the new combined behavior.
This commit is contained in:
+101
-21
@@ -493,6 +493,11 @@ export default function RenamePanel() {
|
|||||||
const [omdbKeyInput, setOmdbKeyInput] = useState("");
|
const [omdbKeyInput, setOmdbKeyInput] = useState("");
|
||||||
const [savingSettings, setSavingSettings] = useState(false);
|
const [savingSettings, setSavingSettings] = useState(false);
|
||||||
const [fetching, setFetching] = useState(false);
|
const [fetching, setFetching] = useState(false);
|
||||||
|
// Busy state for the combined Match step (see `runMatchAndFetch`) - covers both the
|
||||||
|
// local scan and, when it runs, the automatic Fetch Data step right after, so the
|
||||||
|
// Match button stays disabled for the whole combined operation rather than just the
|
||||||
|
// first half of it.
|
||||||
|
const [matching, setMatching] = useState(false);
|
||||||
// Rename history / undo. `null` batches = not loaded yet (also used to show a
|
// Rename history / undo. `null` batches = not loaded yet (also used to show a
|
||||||
// loading state the first time the History panel is opened).
|
// loading state the first time the History panel is opened).
|
||||||
const [historyOpen, setHistoryOpen] = useState(false);
|
const [historyOpen, setHistoryOpen] = useState(false);
|
||||||
@@ -686,13 +691,23 @@ export default function RenamePanel() {
|
|||||||
// batched `probe_media_files` command) for resolution/duration/codecs read straight
|
// batched `probe_media_files` command) for resolution/duration/codecs read straight
|
||||||
// from its container header - see the bindings merge below for how a probed value
|
// from its container header - see the bindings merge below for how a probed value
|
||||||
// and a filename-tag value combine.
|
// and a filename-tag value combine.
|
||||||
const runMatching = async () => {
|
// Returns the freshly-matched items (so a caller chaining straight into `runFetch`
|
||||||
|
// - see `runMatchAndFetch` below - can hand it the real, just-computed array instead
|
||||||
|
// of reading `items` state, which wouldn't reflect this match yet: `setItems` here
|
||||||
|
// queues a re-render, it doesn't update the `items` this closure sees immediately).
|
||||||
|
// Returns `undefined` when there was nothing to match (nothing loaded yet).
|
||||||
|
// `chained`: set by `runMatchAndFetch` to skip scheduling this function's own
|
||||||
|
// status-clearing timeout on the success path - when chained, `runFetch` runs
|
||||||
|
// immediately after and takes over the status line with its own timer, and letting
|
||||||
|
// both timers race risks the earlier one blanking the later one's message first
|
||||||
|
// (see `runMatchAndFetch`'s doc comment for the concrete scenario).
|
||||||
|
const runMatching = async (opts?: { chained?: boolean }): Promise<Item[] | undefined> => {
|
||||||
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 or movie 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 undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// One batched round trip for real probing of every target, run alongside the
|
// One batched round trip for real probing of every target, run alongside the
|
||||||
@@ -744,8 +759,12 @@ export default function RenamePanel() {
|
|||||||
const tagged = results.filter(([, , release]) => release !== null).length;
|
const tagged = results.filter(([, , release]) => release !== null).length;
|
||||||
const probed = [...probes.values()].filter((probe) => probe.container !== null).length;
|
const probed = [...probes.values()].filter((probe) => probe.container !== null).length;
|
||||||
|
|
||||||
setItems((prev) =>
|
// A plain `items.map(...)` rather than the usual `setItems((prev) => prev.map(...))`
|
||||||
prev.map((item) => {
|
// functional form - deliberately, so the resulting array is available synchronously
|
||||||
|
// right here to both `setItems` and, for the chained-from-Match case, `return` to
|
||||||
|
// the caller (see this function's doc comment above). Safe because nothing else in
|
||||||
|
// this function queues a competing `items` update in between.
|
||||||
|
const nextItems = items.map((item): Item => {
|
||||||
const guess = guesses.get(item.id);
|
const guess = guesses.get(item.id);
|
||||||
const release = releaseGuesses.get(item.id);
|
const release = releaseGuesses.get(item.id);
|
||||||
const probe = probes.get(item.id);
|
const probe = probes.get(item.id);
|
||||||
@@ -833,8 +852,8 @@ export default function RenamePanel() {
|
|||||||
...releaseBindings,
|
...releaseBindings,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
})
|
});
|
||||||
);
|
setItems(nextItems);
|
||||||
|
|
||||||
// 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
|
||||||
@@ -849,8 +868,11 @@ export default function RenamePanel() {
|
|||||||
? `Matched ${matched} of ${targets.length} file${targets.length === 1 ? "" : "s"} (${matchedTv} TV, ${matchedMovie} movie${matchedMovie === 1 ? "" : "s"}${tagged > 0 ? `, ${tagged} with release tags` : ""}${probed > 0 ? `, ${probed} probed from file` : ""}; local only, no network calls).`
|
? `Matched ${matched} of ${targets.length} file${targets.length === 1 ? "" : "s"} (${matchedTv} TV, ${matchedMovie} movie${matchedMovie === 1 ? "" : "s"}${tagged > 0 ? `, ${tagged} with release tags` : ""}${probed > 0 ? `, ${probed} probed from file` : ""}; local only, no network calls).`
|
||||||
: "No season/episode or movie title recognized in the loaded filenames."
|
: "No season/episode or movie title recognized in the loaded filenames."
|
||||||
);
|
);
|
||||||
|
if (!opts?.chained) {
|
||||||
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);
|
||||||
|
}
|
||||||
|
return nextItems;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Step 1 of Rename: build the plan (old path -> new filename) from the live preview
|
// Step 1 of Rename: build the plan (old path -> new filename) from the live preview
|
||||||
@@ -1155,18 +1177,33 @@ export default function RenamePanel() {
|
|||||||
// OMDb) - the two settings are independent, so a batch mixing TV and movies can even
|
// OMDb) - the two settings are independent, so a batch mixing TV and movies can even
|
||||||
// use different sources for each half. Both run via separate Tauri commands,
|
// use different sources for each half. Both run via separate Tauri commands,
|
||||||
// concurrently, and merge back into a single set of counts/status message.
|
// concurrently, and merge back into a single set of counts/status message.
|
||||||
const runFetch = async () => {
|
//
|
||||||
const tvTargets = items.filter(
|
// `itemsOverride`: read targets from this array instead of `items` state - used by
|
||||||
|
// `runMatchAndFetch` to hand this the just-computed match results directly, since
|
||||||
|
// `items` state wouldn't reflect them yet (see `runMatching`'s doc comment).
|
||||||
|
// `silentIfEmpty`: skip the "Run Match first" message when there's nothing to fetch,
|
||||||
|
// instead of complaining - used the same way, for a chained call right after a Match
|
||||||
|
// that simply found nothing recognizable; `runMatching`'s own message already covers
|
||||||
|
// that. `chained`: skip auto-opening the Settings modal on a missing credential -
|
||||||
|
// still explains why in the status line, but doesn't interrupt every single Match
|
||||||
|
// click with a popup for someone who hasn't set up Fetch Data (or doesn't want to).
|
||||||
|
// Returns whether this call took ownership of the status line/its clear timer (false
|
||||||
|
// only for the `silentIfEmpty` bypass) - `runMatchAndFetch` uses that to know whether
|
||||||
|
// it still needs to schedule a clear itself.
|
||||||
|
const runFetch = async (opts?: { itemsOverride?: Item[]; silentIfEmpty?: boolean; chained?: boolean }): Promise<boolean> => {
|
||||||
|
const source = opts?.itemsOverride ?? items;
|
||||||
|
const tvTargets = source.filter(
|
||||||
(i) => !i.isSample && i.mediaKind === "tv" && i.bindings?.n && i.bindings?.season && i.bindings?.episode
|
(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 movieTargets = source.filter((i) => !i.isSample && i.mediaKind === "movie" && i.bindings?.n);
|
||||||
const targetCount = tvTargets.length + movieTargets.length;
|
const targetCount = tvTargets.length + movieTargets.length;
|
||||||
|
|
||||||
if (targetCount === 0) {
|
if (targetCount === 0) {
|
||||||
|
if (opts?.silentIfEmpty) return false;
|
||||||
setStatus("Run Match first — Fetch Data needs a guessed series/episode or movie title to look up.");
|
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 true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TVmaze needs no credential at all, so TMDB/OMDb are only actually required when
|
// TVmaze needs no credential at all, so TMDB/OMDb are only actually required when
|
||||||
@@ -1182,10 +1219,10 @@ export default function RenamePanel() {
|
|||||||
needsOmdbKey && hasOmdbKey === false ? "a free OMDb API key" : null,
|
needsOmdbKey && hasOmdbKey === false ? "a free OMDb API key" : null,
|
||||||
].filter((m): m is string => m !== null);
|
].filter((m): m is string => m !== null);
|
||||||
setStatus(`Add ${missing.join(" and ")} in Settings first, then Fetch Data will work.`);
|
setStatus(`Add ${missing.join(" and ")} in Settings first, then Fetch Data will work.`);
|
||||||
setSettingsOpen(true);
|
if (!opts?.chained) setSettingsOpen(true);
|
||||||
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 true;
|
||||||
}
|
}
|
||||||
|
|
||||||
setFetching(true);
|
setFetching(true);
|
||||||
@@ -1299,6 +1336,36 @@ export default function RenamePanel() {
|
|||||||
window.clearTimeout((runFetch as any)._t);
|
window.clearTimeout((runFetch as any)._t);
|
||||||
(runFetch as any)._t = window.setTimeout(() => setStatus(null), 5000);
|
(runFetch as any)._t = window.setTimeout(() => setStatus(null), 5000);
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// "Match" now does the whole job in one click: scan filenames locally (as before),
|
||||||
|
// then - if that found anything recognizable and either credential-free source
|
||||||
|
// (TVmaze) is in use or the needed credential is already on file - immediately fetch
|
||||||
|
// real online metadata for it too, exactly as if the user had clicked Match and then
|
||||||
|
// Fetch Data separately. Requested directly: "when you click Match, I would prefer
|
||||||
|
// it to do what the lookup does... just one less step needed." The standalone
|
||||||
|
// "Fetch Data" button alongside the New Names list is untouched and still works on
|
||||||
|
// its own - useful for re-fetching after changing the metadata source, fixing a
|
||||||
|
// guessed name by hand, or adding a credential after the fact.
|
||||||
|
const runMatchAndFetch = async () => {
|
||||||
|
setMatching(true);
|
||||||
|
try {
|
||||||
|
const nextItems = await runMatching({ chained: true });
|
||||||
|
if (!nextItems) return; // nothing loaded - runMatching already showed its own message
|
||||||
|
|
||||||
|
const fetchHandledStatus = await runFetch({ itemsOverride: nextItems, silentIfEmpty: true, chained: true });
|
||||||
|
if (!fetchHandledStatus) {
|
||||||
|
// Match found nothing to look up online (no TV/movie recognized at all) - its
|
||||||
|
// own message is still on screen, and since it ran chained it skipped
|
||||||
|
// scheduling its own clear timer (that's normally whichever step runs last).
|
||||||
|
// This was the last step here, so it takes over that job.
|
||||||
|
window.clearTimeout((runMatchAndFetch as any)._t);
|
||||||
|
(runMatchAndFetch as any)._t = window.setTimeout(() => setStatus(null), 4000);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setMatching(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeSelected = () => {
|
const removeSelected = () => {
|
||||||
@@ -1501,8 +1568,13 @@ export default function RenamePanel() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="rename-actions">
|
<div className="rename-actions">
|
||||||
<button className="action-button" onClick={runMatching}>
|
<button
|
||||||
Match
|
className="action-button"
|
||||||
|
onClick={() => void runMatchAndFetch()}
|
||||||
|
disabled={matching || fetching}
|
||||||
|
title="Scans filenames locally, then automatically fetches real online metadata for anything recognized - no separate Fetch Data click needed."
|
||||||
|
>
|
||||||
|
{matching ? "Matching…" : "Match"}
|
||||||
</button>
|
</button>
|
||||||
<button className="action-button primary" onClick={prepareRename}>
|
<button className="action-button primary" onClick={prepareRename}>
|
||||||
Rename
|
Rename
|
||||||
@@ -1520,7 +1592,12 @@ export default function RenamePanel() {
|
|||||||
<ListButton icon={<UpIcon />} label="Align Up" onClick={() => moveSelected("up")} />
|
<ListButton icon={<UpIcon />} label="Align Up" onClick={() => moveSelected("up")} />
|
||||||
<LoadMenuButton onFiles={loadFiles} onFolder={loadFolder} />
|
<LoadMenuButton onFiles={loadFiles} onFolder={loadFolder} />
|
||||||
<span className="list-button-gap" />
|
<span className="list-button-gap" />
|
||||||
<ListButton icon={<FetchIcon />} label="Fetch Data" onClick={runFetch} disabled={fetching} />
|
<ListButton
|
||||||
|
icon={<FetchIcon />}
|
||||||
|
label="Fetch Data (Match already runs this automatically - use this to re-fetch)"
|
||||||
|
onClick={() => void runFetch()}
|
||||||
|
disabled={fetching || matching}
|
||||||
|
/>
|
||||||
<ListButton icon={<KeyIcon />} label="Fetch Data Settings" onClick={openSettings} />
|
<ListButton icon={<KeyIcon />} label="Fetch Data Settings" onClick={openSettings} />
|
||||||
<ListButton icon={<GearIcon />} label="Naming Scheme" onClick={() => setEditingFormat((v) => !v)} />
|
<ListButton icon={<GearIcon />} label="Naming Scheme" onClick={() => setEditingFormat((v) => !v)} />
|
||||||
</>
|
</>
|
||||||
@@ -1610,12 +1687,15 @@ export default function RenamePanel() {
|
|||||||
<code>{"{video codec}"}</code>, and <code>{"{audio codec}"}</code> still fill in from the real file when
|
<code>{"{video codec}"}</code>, and <code>{"{audio codec}"}</code> still fill in from the real file when
|
||||||
the filename doesn't carry that tag, and <code>{"{width}"}</code>/<code>{"{height}"}</code>/
|
the filename doesn't carry that tag, and <code>{"{width}"}</code>/<code>{"{height}"}</code>/
|
||||||
<code>{"{fps}"}</code>/<code>{"{duration}"}</code>/<code>{"{file size}"}</code> are only ever available
|
<code>{"{fps}"}</code>/<code>{"{duration}"}</code>/<code>{"{file size}"}</code> are only ever available
|
||||||
this way. Fetch Data resolves the
|
this way. Clicking <strong>Match</strong> then automatically runs Fetch Data right after, for anything it
|
||||||
season/episode or movie guess against real online metadata for the real title, episode title, and
|
recognized — no separate click needed. Fetch Data itself resolves the season/episode or movie guess
|
||||||
air/release date. TV episodes can come from TMDB, TVmaze, or OMDb, and movies from TMDB or OMDb — pick
|
against real online metadata for the real title, episode title, and air/release date. TV episodes can
|
||||||
each independently in <strong>Fetch Data Settings</strong> (TVmaze is free and needs no credential at
|
come from TMDB, TVmaze, or OMDb, and movies from TMDB or OMDb — pick each independently in{" "}
|
||||||
all; TMDB and OMDb each need their own free API credential set there first). A batch can
|
<strong>Fetch Data Settings</strong> (TVmaze is free and needs no credential at all; TMDB and OMDb each
|
||||||
freely mix TV and movie files, even against different sources — each file only fills in the fields that apply to it. FileBot's own short
|
need their own free API credential set there first). The separate Fetch Data button next to New Names
|
||||||
|
still works on its own too, for re-fetching after changing the source or fixing a guessed name by hand. A
|
||||||
|
batch can freely mix TV and movie files, even against different sources — 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>,
|
field names (<code>{"{n}"}</code>, <code>{"{s00e00}"}</code>, <code>{"{t}"}</code>, <code>{"{y}"}</code>,
|
||||||
<code>{"{vf}"}</code>, <code>{"{vc}"}</code>, <code>{"{ac}"}</code>, <code>{"{group}"}</code>, etc.) still
|
<code>{"{vf}"}</code>, <code>{"{vc}"}</code>, <code>{"{ac}"}</code>, <code>{"{group}"}</code>, etc.) still
|
||||||
work too, side by side with the names above.
|
work too, side by side with the names above.
|
||||||
|
|||||||
Reference in New Issue
Block a user