import { useEffect, useMemo, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; import { revealItemInDir } from "@tauri-apps/plugin-opener"; import { useGalleryStore, WorkerKey } from "../store"; const WORKER_FOR_STAGE: Record = { Thumbnails: "thumbnail", Metadata: "metadata", Embeddings: "embedding", Tags: "tagging", }; interface TaskStage { label: string; detail: string; progress: number | null; // 0–100, or null for indeterminate failed: boolean; } interface Task { id: number; name: string; stages: TaskStage[]; isActive: boolean; hasFailedEmbeddings: boolean; hasFailedTagging: boolean; hasFailedCaptions: boolean; pendingMediaWork: number; embeddingProcessed: number; embeddingTotal: number; currentFile: string | null; snapshot: string; } interface FailedWorkerItem { image_id: number; filename: string; path: string; error: string | null; } function FailedWorkerItemRow({ item }: { item: FailedWorkerItem }) { return (

{item.filename}

{item.error && (

{item.error}

)}
); } export function BackgroundTasks() { const folders = useGalleryStore((state) => state.folders); const indexingProgress = useGalleryStore((state) => state.indexingProgress); const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress); const retryFailedEmbeddings = useGalleryStore((state) => state.retryFailedEmbeddings); const queueTaggingJobs = useGalleryStore((state) => state.queueTaggingJobs); const showFailedTagging = useGalleryStore((state) => state.showFailedTagging); const duplicateScanning = useGalleryStore((state) => state.duplicateScanning); const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress); const [expanded, setExpanded] = useState(false); const [dismissed, setDismissed] = useState>({}); const [failedEmbeddingItems, setFailedEmbeddingItems] = useState>({}); const [failedTaggingItems, setFailedTaggingItems] = useState>({}); const workerPaused = useGalleryStore((state) => state.workerPaused); const loadWorkerStates = useGalleryStore((state) => state.loadWorkerStates); const setWorkerPaused = useGalleryStore((state) => state.setWorkerPaused); useEffect(() => { void loadWorkerStates(); }, [folders, loadWorkerStates]); // Fetch failed filenames whenever the expanded panel opens or failure counts change. const failedEmbeddingCounts = useMemo( () => Object.fromEntries( Object.entries(mediaJobProgress).map(([id, p]) => [id, p?.embedding_failed ?? 0]), ), [mediaJobProgress], ); const failedTaggingCounts = useMemo( () => Object.fromEntries( Object.entries(mediaJobProgress).map(([id, p]) => [id, p?.tagging_failed ?? 0]), ), [mediaJobProgress], ); useEffect(() => { if (!expanded) return; for (const [folderId, count] of Object.entries(failedEmbeddingCounts)) { if (count > 0) { invoke("get_failed_embedding_images", { folderId: Number(folderId), }) .then((items) => setFailedEmbeddingItems((prev) => ({ ...prev, [folderId]: items }))) .catch(() => undefined); } } for (const [folderId, count] of Object.entries(failedTaggingCounts)) { if (count > 0) { invoke("get_failed_tagging_images", { folderId: Number(folderId), }) .then((items) => setFailedTaggingItems((prev) => ({ ...prev, [folderId]: items }))) .catch(() => undefined); } } }, [expanded, failedEmbeddingCounts, failedTaggingCounts]); const isWorkerPaused = (folderId: number, worker: WorkerKey) => { return workerPaused[folderId]?.[worker] ?? false; }; const toggleWorker = (folderId: number, worker: WorkerKey) => { setWorkerPaused(folderId, worker, !isWorkerPaused(folderId, worker)); }; const dismissTask = (id: number, snapshot: string) => { if (id < 0) return; // system tasks (duplicate scan) cannot be dismissed setDismissed((prev) => ({ ...prev, [id]: snapshot })); setExpanded(false); }; const tasks = useMemo(() => { return folders .map((folder): Task | null => { const index = indexingProgress[folder.id]; const jobs = mediaJobProgress[folder.id]; const thumbnailPending = jobs?.thumbnail_pending ?? 0; const metadataPending = jobs?.metadata_pending ?? 0; const embeddingPending = jobs?.embedding_pending ?? 0; const embeddingReady = jobs?.embedding_ready ?? 0; const embeddingFailed = jobs?.embedding_failed ?? 0; const taggingPending = jobs?.tagging_pending ?? 0; const taggingReady = jobs?.tagging_ready ?? 0; const taggingFailed = jobs?.tagging_failed ?? 0; const captionPending = jobs?.caption_pending ?? 0; const captionReady = jobs?.caption_ready ?? 0; const captionFailed = jobs?.caption_failed ?? 0; // A folder is "actively processing" when something is genuinely moving: // an in-progress scan, or pending work on a worker that isn't paused. // Captions have no per-folder pause toggle, so any caption work counts. const paused = workerPaused[folder.id]; const isActive = (!!index && !index.done) || (thumbnailPending > 0 && !(paused?.thumbnail ?? false)) || (metadataPending > 0 && !(paused?.metadata ?? false)) || (embeddingPending > 0 && !(paused?.embedding ?? false)) || (taggingPending > 0 && !(paused?.tagging ?? false)) || captionPending > 0; const pendingMediaWork = thumbnailPending + metadataPending + embeddingPending + taggingPending + captionPending; const embeddingProcessed = embeddingReady + embeddingFailed; const embeddingTotal = embeddingProcessed + embeddingPending; const taggingProcessed = taggingReady + taggingFailed; const taggingTotal = taggingProcessed + taggingPending; const captionProcessed = captionReady + captionFailed; const captionTotal = captionProcessed + captionPending; const hasFailedEmbeddings = embeddingFailed > 0; const hasFailedTagging = taggingFailed > 0; const hasFailedCaptions = captionFailed > 0; if (!index && pendingMediaWork === 0 && !hasFailedEmbeddings && !hasFailedTagging && !hasFailedCaptions) return null; const stages: TaskStage[] = []; if (index && !index.done) { const pct = index.total > 0 ? (index.indexed / index.total) * 100 : 0; stages.push({ label: "Scanning", detail: `${index.indexed.toLocaleString()} / ${index.total.toLocaleString()}`, progress: pct, failed: false, }); } if (thumbnailPending > 0) { stages.push({ label: "Thumbnails", detail: thumbnailPending.toLocaleString(), progress: null, failed: false, }); } if (metadataPending > 0) { stages.push({ label: "Metadata", detail: metadataPending.toLocaleString(), progress: null, failed: false, }); } if (embeddingPending > 0) { const pct = embeddingTotal > 0 ? (embeddingProcessed / embeddingTotal) * 100 : 0; stages.push({ label: "Embeddings", detail: `${embeddingProcessed.toLocaleString()} / ${embeddingTotal.toLocaleString()}`, progress: pct, failed: false, }); } if (taggingPending > 0) { const pct = taggingTotal > 0 ? (taggingProcessed / taggingTotal) * 100 : 0; stages.push({ label: "Tags", detail: `${taggingProcessed.toLocaleString()} / ${taggingTotal.toLocaleString()}`, progress: pct, failed: false, }); } if (captionPending > 0) { const pct = captionTotal > 0 ? (captionProcessed / captionTotal) * 100 : 0; stages.push({ label: "Captions", detail: `${captionProcessed.toLocaleString()} / ${captionTotal.toLocaleString()}`, progress: pct, failed: false, }); } if (hasFailedEmbeddings && pendingMediaWork === 0) { stages.push({ label: "Failed", detail: `${embeddingFailed.toLocaleString()} embeddings`, progress: null, failed: true, }); } if (hasFailedTagging && pendingMediaWork === 0) { stages.push({ label: "Failed", detail: `${taggingFailed.toLocaleString()} tags`, progress: null, failed: true, }); } if (hasFailedCaptions && pendingMediaWork === 0) { stages.push({ label: "Failed", detail: `${captionFailed.toLocaleString()} captions`, progress: null, failed: true, }); } const snapshot = `${pendingMediaWork}:${embeddingFailed}:${taggingFailed}:${captionFailed}`; return { id: folder.id, name: folder.name, stages, isActive, hasFailedEmbeddings, hasFailedTagging, hasFailedCaptions, pendingMediaWork, embeddingProcessed, embeddingTotal, currentFile: index && !index.done ? (index.current_file || null) : null, snapshot, }; }) .filter((t): t is Task => t !== null) .filter((t) => dismissed[t.id] !== t.snapshot) // Surface actively-processing folders first so a paused folder never holds // the primary (collapsed) slot while another is genuinely working. Array // sort is stable, so folders within each group keep their existing order. .sort((a, b) => Number(b.isActive) - Number(a.isActive)); }, [folders, indexingProgress, mediaJobProgress, workerPaused, dismissed]); // Synthetic task for duplicate scanning — negative id so dismiss/retry are suppressed const duplicateScanTask: Task | null = duplicateScanning ? { id: -1, name: "Duplicate Scan", stages: [{ label: duplicateScanProgress?.phase === "checking" ? "Checking" : duplicateScanProgress?.phase === "confirming" ? "Confirming" : "Hashing", detail: duplicateScanProgress ? `${duplicateScanProgress.processed.toLocaleString()} / ${duplicateScanProgress.total.toLocaleString()}${duplicateScanProgress.skipped > 0 ? ` · ${duplicateScanProgress.skipped.toLocaleString()} skipped` : ""}` : "Starting…", progress: duplicateScanProgress && duplicateScanProgress.total > 0 ? (duplicateScanProgress.processed / duplicateScanProgress.total) * 100 : null, failed: false, }], isActive: true, hasFailedEmbeddings: false, hasFailedTagging: false, hasFailedCaptions: false, pendingMediaWork: 1, embeddingProcessed: 0, embeddingTotal: 0, currentFile: null, snapshot: "", } : null; const allTasks = duplicateScanTask ? [duplicateScanTask, ...tasks] : tasks; if (allTasks.length === 0) return null; const primary = allTasks[0]; const extraCount = allTasks.length - 1; const hasFailed = tasks.some((t) => (t.hasFailedEmbeddings || t.hasFailedTagging || t.hasFailedCaptions) && t.pendingMediaWork === 0); // Best progress bar value: use embedding progress if available (most informative), // otherwise tagging progress, otherwise fall back to scanning progress, otherwise indeterminate. const embeddingStage = primary.stages.find((s) => s.label === "Embeddings"); const taggingStage = primary.stages.find((s) => s.label === "Tags"); const scanningStage = primary.stages.find((s) => s.label === "Scanning"); const duplicateStage = primary.id === -1 ? primary.stages[0] : null; const barProgress = embeddingStage?.progress ?? taggingStage?.progress ?? scanningStage?.progress ?? duplicateStage?.progress ?? null; return (
{/* Slim bar */}
setExpanded((v) => !v)} > {/* Pulse dot */}
{/* Folder name */} {primary.name} {/* Stage tags — all active stages visible simultaneously */}
{primary.stages.map((stage) => { const workerKey = WORKER_FOR_STAGE[stage.label]; const isPaused = workerKey ? isWorkerPaused(primary.id, workerKey) : false; return ( {stage.label} {stage.detail} {workerKey && ( )} ); })}
{/* Progress bar — embedding or scanning progress, pulsing if indeterminate */}
{/* Extra folders badge */} {extraCount > 0 && ( +{extraCount} )} {primary.pendingMediaWork === 0 && (primary.hasFailedEmbeddings || primary.hasFailedTagging) && (
{primary.hasFailedTagging ? ( ) : null}
)} {/* Expand chevron (only when multiple tasks) */} {allTasks.length > 1 && ( )} {/* Dismiss — hidden for system tasks like duplicate scan */} {primary.id >= 0 && ( )}
{/* Expanded panel — one row per folder */} {expanded && (
{allTasks.map((task) => { const taskEmbeddingStage = task.stages.find((s) => s.label === "Embeddings"); const taskTaggingStage = task.stages.find((s) => s.label === "Tags"); const taskScanningStage = task.stages.find((s) => s.label === "Scanning"); const taskDuplicateStage = task.id === -1 ? task.stages[0] : null; const taskBarProgress = taskEmbeddingStage?.progress ?? taskTaggingStage?.progress ?? taskScanningStage?.progress ?? taskDuplicateStage?.progress ?? null; const taskHasFailed = (task.hasFailedEmbeddings || task.hasFailedTagging || task.hasFailedCaptions) && task.pendingMediaWork === 0; return (
{task.name}
{task.stages.map((stage) => { const workerKey = WORKER_FOR_STAGE[stage.label]; const isPaused = workerKey ? isWorkerPaused(task.id, workerKey) : false; return ( {isPaused && ( )} {stage.label} {stage.detail} {workerKey && ( )} ); })}
{taskHasFailed && (
{task.hasFailedTagging ? ( ) : null}
)} {task.id >= 0 && ( )}
{task.currentFile && (

{task.currentFile}

)} {/* Failed worker file lists */} {taskHasFailed && failedEmbeddingItems[task.id] && failedEmbeddingItems[task.id].length > 0 && (
{failedEmbeddingItems[task.id].map((item) => ( ))}
)} {taskHasFailed && failedTaggingItems[task.id] && failedTaggingItems[task.id].length > 0 && (
{failedTaggingItems[task.id].map((item) => ( ))}
)}
); })}
)}
); }