From b076903ecdb36d7ee2417e17b1154c8007a4dcae Mon Sep 17 00:00:00 2001 From: JakeBreath Date: Fri, 18 Sep 2026 23:09:42 -0500 Subject: [PATCH] Show live progress while the bulk rating tool moves files A 200-file bulk move is one long server-side copy+index chain per file, so the old single request sat on a spinner the whole time (and got uncomfortably close to the 120s proxy timeout). The modal now resolves the selection in chunks of 8: - footer switches to a progress bar with 'n moved / done / total / percent' while running, and the header explains that files are being indexed; - the rating pills, selection actions, grid and close button are locked while it runs so progress can't be lost by accident; - failures are collected with their filenames, the modal stays open for a summary, and 'Retry failed' re-selects only the files that are still pending; a clean run still auto-closes with a toast. --- .../src/features/upload/BulkRateModal.tsx | 247 ++++++++++++++---- 1 file changed, 191 insertions(+), 56 deletions(-) diff --git a/frontend/src/features/upload/BulkRateModal.tsx b/frontend/src/features/upload/BulkRateModal.tsx index 00ea59e..aef7aba 100644 --- a/frontend/src/features/upload/BulkRateModal.tsx +++ b/frontend/src/features/upload/BulkRateModal.tsx @@ -1,4 +1,4 @@ -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useQueryClient } from "@tanstack/react-query"; import { Check, Video, X } from "lucide-react"; import { useState } from "react"; @@ -26,15 +26,31 @@ const RATING_OPTIONS = [ }, ]; +/** + * How many files one request resolves. Copying and hashing is serial per + * file on the server, so a 200-file batch in one request risks the proxy + * timeout and gives no feedback; small chunks keep every request short and + * let the bar advance as they land. + */ +const CHUNK_SIZE = 8; + interface BulkResult { resolved: string[]; errors: { temp_id: string; error: string }[]; } +interface Failure { + id: string; + name: string; + error: string; +} + +type Phase = "select" | "running" | "done"; + /** * Bulk-rating tool for the Pending & Unmatched column: pick a rating, tick - * every thumbnail that belongs to it, and they all move into the library as - * custom entries in one request. + * every thumbnail that belongs to it, and they all move into the library, + * reporting progress chunk by chunk. */ export function BulkRateModal({ uploads, @@ -46,28 +62,73 @@ export function BulkRateModal({ const queryClient = useQueryClient(); const [rating, setRating] = useState(null); const [selected, setSelected] = useState>(new Set()); + const [phase, setPhase] = useState("select"); + const [progress, setProgress] = useState({ done: 0, total: 0, moved: 0 }); + const [failures, setFailures] = useState([]); - const mutation = useMutation({ - mutationFn: (payload: { temp_ids: string[]; rating: string }) => - api("/api/uploads/resolve-bulk/", { - method: "POST", - json: payload, - }), - onSuccess: (result) => { - void queryClient.invalidateQueries({ queryKey: ["uploads"] }); - void queryClient.invalidateQueries({ queryKey: ["files"] }); - if (result.resolved.length > 0) { - toast.ok(`Moved ${result.resolved.length} upload(s) into the library.`); - } - if (result.errors.length > 0) { - toast.error( - `${result.errors.length} could not be resolved: ${result.errors[0].error}`, - ); + const names = new Map(uploads.map((temp) => [temp.temp_id, temp.original_filename])); + const busy = phase === "running"; + const percent = + progress.total > 0 + ? Math.min(100, Math.round((progress.done / progress.total) * 100)) + : 0; + const retryable = failures.filter((failure) => names.has(failure.id)); + + async function run() { + if (!rating || selected.size === 0 || busy) return; + const ids = [...selected]; + + setPhase("running"); + setFailures([]); + setProgress({ done: 0, total: ids.length, moved: 0 }); + + let moved = 0; + const problems: Failure[] = []; + for (let index = 0; index < ids.length; index += CHUNK_SIZE) { + const chunk = ids.slice(index, index + CHUNK_SIZE); + try { + const result = await api("/api/uploads/resolve-bulk/", { + method: "POST", + json: { temp_ids: chunk, rating }, + }); + moved += result.resolved.length; + for (const problem of result.errors) { + problems.push({ + id: problem.temp_id, + name: names.get(problem.temp_id) ?? problem.temp_id, + error: problem.error, + }); + } + } catch (error) { + for (const id of chunk) { + problems.push({ + id, + name: names.get(id) ?? id, + error: errorMessage(error), + }); + } } + setProgress({ + done: Math.min(index + CHUNK_SIZE, ids.length), + total: ids.length, + moved, + }); + } + + void queryClient.invalidateQueries({ queryKey: ["uploads"] }); + void queryClient.invalidateQueries({ queryKey: ["files"] }); + setFailures(problems); + setPhase("done"); + + if (problems.length === 0) { + toast.ok(`Moved ${moved} upload(s) into the library.`); onClose(); - }, - onError: (error) => toast.error(errorMessage(error)), - }); + } else if (moved > 0) { + toast.error(`Moved ${moved}, ${problems.length} failed.`); + } else { + toast.error(`Nothing could be moved (${problems.length} failed).`); + } + } function toggle(id: string) { setSelected((current) => { @@ -78,10 +139,20 @@ export function BulkRateModal({ }); } + function requestClose() { + if (!busy) onClose(); + } + + function retryFailed() { + setSelected(new Set(retryable.map((failure) => failure.id))); + setFailures([]); + setPhase("select"); + } + return (

- Pick a rating, tick every file that belongs to it, and they move - into the library in one go ({uploads.length} pending). + {busy + ? "Moving files into the library — this can take a moment, keep this open." + : "Pick a rating, tick every file that belongs to it, and they move into the library in one go (" + + uploads.length + + " pending)."}

@@ -112,9 +187,10 @@ export function BulkRateModal({