diff --git a/src/components/RenamePanel.tsx b/src/components/RenamePanel.tsx index 7d0cb3a..0d5277c 100644 --- a/src/components/RenamePanel.tsx +++ b/src/components/RenamePanel.tsx @@ -493,6 +493,11 @@ export default function RenamePanel() { const [omdbKeyInput, setOmdbKeyInput] = useState(""); const [savingSettings, setSavingSettings] = 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 // loading state the first time the History panel is opened). const [historyOpen, setHistoryOpen] = useState(false); @@ -686,13 +691,23 @@ export default function RenamePanel() { // 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 // 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 => { 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 or movie info."); window.clearTimeout((runMatching as any)._t); (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 @@ -744,9 +759,13 @@ export default function RenamePanel() { const tagged = results.filter(([, , release]) => release !== null).length; const probed = [...probes.values()].filter((probe) => probe.container !== null).length; - setItems((prev) => - prev.map((item) => { - const guess = guesses.get(item.id); + // A plain `items.map(...)` rather than the usual `setItems((prev) => prev.map(...))` + // 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 release = releaseGuesses.get(item.id); const probe = probes.get(item.id); @@ -833,8 +852,8 @@ export default function RenamePanel() { ...releaseBindings, }, }; - }) - ); + }); + setItems(nextItems); // 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 @@ -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).` : "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); + if (!opts?.chained) { + window.clearTimeout((runMatching as any)._t); + (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 @@ -1155,18 +1177,33 @@ export default function RenamePanel() { // 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, // 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 => { + const source = opts?.itemsOverride ?? items; + const tvTargets = source.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 movieTargets = source.filter((i) => !i.isSample && i.mediaKind === "movie" && i.bindings?.n); const targetCount = tvTargets.length + movieTargets.length; 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."); window.clearTimeout((runFetch as any)._t); (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 @@ -1182,10 +1219,10 @@ export default function RenamePanel() { needsOmdbKey && hasOmdbKey === false ? "a free OMDb API key" : null, ].filter((m): m is string => m !== null); 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); (runFetch as any)._t = window.setTimeout(() => setStatus(null), 4000); - return; + return true; } setFetching(true); @@ -1299,6 +1336,36 @@ export default function RenamePanel() { window.clearTimeout((runFetch as any)._t); (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 = () => { @@ -1501,8 +1568,13 @@ export default function RenamePanel() { />
-