feat: auto-chain vision backfill + face recluster after scan, show progress in UI

Scan now automatically queues backfill_vision (+90s) and recluster_faces
(+300s) after dispatching folder scans. Face extraction also schedules a
debounced recluster via Redis so incremental file-watcher imports get
clustered without manual intervention.

The ScanProgress widget now tracks worker queue activity beyond the scan
phase, showing a "Processing Photos" indicator with vision queue counts
while background tasks (embeddings, faces, tags, OCR) are running.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-04-12 18:51:38 +02:00
parent 6457dc9da5
commit d933ea3842
3 changed files with 202 additions and 80 deletions

View File

@@ -409,6 +409,7 @@ async def _scan_all_source_roots_async():
still hit Settings → Re-detect duplicates to force a fresh pass.
"""
from app.tasks.thumbs import regroup_duplicates_task
from app.tasks.vision import backfill_vision, recluster_faces
async with AsyncSessionLocal() as session:
result = await session.execute(
@@ -433,6 +434,21 @@ async def _scan_all_source_roots_async():
except Exception as e:
logger.warning(f"Could not queue post-scan regroup: {e}")
# 90s lets thumbnails finish so photos reach processing_status
# 'completed', which backfill_vision uses as its filter.
try:
backfill_vision.apply_async(countdown=90)
except Exception as e:
logger.warning(f"Could not queue post-scan vision backfill: {e}")
# 300s gives face extraction time to run before reclustering.
# Fires even if some faces are still in-flight — the task is
# idempotent and the user can re-trigger from Settings.
try:
recluster_faces.apply_async(countdown=300)
except Exception as e:
logger.warning(f"Could not queue post-scan face recluster: {e}")
@shared_task(name='watch_folders')
def watch_folders():

View File

@@ -348,13 +348,40 @@ def _save_faces(photo_id: str, faces) -> dict:
if faces:
logger.info("Extracted %d verified face(s) from photo %s", len(faces), photo_id)
_schedule_recluster_debounced()
return {'status': 'success', 'photo_id': photo_id, 'faces': len(faces)}
RECLUSTER_DEBOUNCE_KEY = "mule:recluster_faces:pending"
RECLUSTER_DELAY = 120 # seconds after last face extraction
def _schedule_recluster_debounced():
"""Schedule a recluster_faces run, debounced so rapid-fire face
extractions don't spawn hundreds of redundant cluster jobs."""
try:
import redis as _redis
r = _redis.from_url(settings.redis_url)
already_pending = r.set(RECLUSTER_DEBOUNCE_KEY, "1",
ex=RECLUSTER_DELAY, nx=True)
if already_pending:
recluster_faces.apply_async(countdown=RECLUSTER_DELAY)
logger.info("Scheduled debounced recluster_faces in %ds", RECLUSTER_DELAY)
except Exception as e:
logger.debug("recluster debounce check failed: %s", e)
@shared_task(name='recluster_faces', queue='vision')
def recluster_faces():
"""Run DBSCAN clustering over all face embeddings and assign/create
Tag(kind=face_cluster) entries."""
# Clear debounce key so new face extractions can schedule another round.
try:
import redis as _redis
_redis.from_url(settings.redis_url).delete(RECLUSTER_DEBOUNCE_KEY)
except Exception:
pass
if not settings.vision.enabled or not settings.vision.faces.enabled:
return {'status': 'skipped', 'reason': 'faces disabled'}

View File

@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { FolderOpen, Loader2, Check, AlertCircle, X } from 'lucide-react'
import { FolderOpen, Loader2, Check, AlertCircle, X, Brain, Sparkles } from 'lucide-react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { library } from '../services/api'
import { library, WorkerStatus } from '../services/api'
import clsx from 'clsx'
interface ScanStatus {
@@ -12,65 +12,101 @@ interface ScanStatus {
errors: string[]
}
type Phase = 'idle' | 'scanning' | 'processing' | 'done'
export function ScanProgress() {
const [isVisible, setIsVisible] = useState(false)
const [isMinimized, setIsMinimized] = useState(false)
const queryClient = useQueryClient()
const wasScanningRef = useRef(false)
const wasProcessingRef = useRef(false)
// Poll scan status every 2 seconds when scanning
const { data: scanStatus } = useQuery<ScanStatus>({
queryKey: ['scan-status'],
queryFn: async () => {
const response = await library.scanStatus()
return response
},
queryFn: () => library.scanStatus(),
refetchInterval: (query) =>
query.state.data?.is_scanning ? 2000 : 10000,
enabled: true,
})
const isScanning = scanStatus?.is_scanning ?? false
// Poll worker status to track vision queue activity.
// Fast polling (3s) while processing, slow (15s) otherwise.
const { data: workerStatus } = useQuery<WorkerStatus>({
queryKey: ['worker-status-progress'],
queryFn: () => library.maintenance.workerStatus(),
refetchInterval: (query) => {
// Poll every 2 seconds if scanning, otherwise every 10 seconds
return query.state.data?.is_scanning ? 2000 : 10000
const q = totalQueued(query.state.data)
return q > 0 ? 3000 : 15000
},
enabled: true,
})
useEffect(() => {
const isScanning = scanStatus?.is_scanning ?? false
const visionActive = visionQueued(workerStatus)
const totalActive = totalQueued(workerStatus)
if (isScanning) {
const phase: Phase = isScanning
? 'scanning'
: totalActive > 0
? 'processing'
: 'idle'
useEffect(() => {
if (phase === 'scanning') {
setIsVisible(true)
setIsMinimized(false)
wasScanningRef.current = true
} else if (wasScanningRef.current) {
// Just transitioned from scanning → done. THIS is the right moment
// to invalidate caches that might have new data: the photos query
// (new files indexed), the folder tree (new folders walked), the
// heap counts (in case a heap photo got reattached).
wasScanningRef.current = false
queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: ['folders'] })
queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] })
queryClient.invalidateQueries({ queryKey: ['heaps'] })
queryClient.invalidateQueries({ queryKey: ['tags'] })
queryClient.invalidateQueries({ queryKey: ['library', 'stats'] })
wasProcessingRef.current = false
} else if (phase === 'processing') {
// Show widget when processing starts (even without a prior scan,
// e.g. backfill triggered from Settings).
if (!isVisible) setIsVisible(true)
wasProcessingRef.current = true
if (isVisible && (scanStatus?.processed_files ?? 0) > 0) {
// Keep showing for 3 seconds after scan completes
setTimeout(() => {
if (!scanStatus?.is_scanning) {
setIsVisible(false)
}
}, 3000)
if (wasScanningRef.current) {
// Scan just finished — invalidate data caches.
wasScanningRef.current = false
queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: ['folders'] })
queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] })
queryClient.invalidateQueries({ queryKey: ['heaps'] })
queryClient.invalidateQueries({ queryKey: ['tags'] })
queryClient.invalidateQueries({ queryKey: ['library', 'stats'] })
}
} else if (phase === 'idle') {
if (wasScanningRef.current) {
// Scan finished with no queued processing (small import).
wasScanningRef.current = false
queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: ['folders'] })
queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] })
queryClient.invalidateQueries({ queryKey: ['heaps'] })
queryClient.invalidateQueries({ queryKey: ['tags'] })
queryClient.invalidateQueries({ queryKey: ['library', 'stats'] })
}
if (wasProcessingRef.current) {
// Processing just drained — refresh tags (new clusters/objects).
wasProcessingRef.current = false
queryClient.invalidateQueries({ queryKey: ['tags'] })
queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: ['library', 'stats'] })
}
if (isVisible) {
setTimeout(() => setIsVisible(false), 3000)
}
}
}, [scanStatus?.is_scanning, scanStatus?.processed_files, isVisible, queryClient])
}, [phase, isVisible, queryClient])
if (!isVisible || !scanStatus) return null
if (!isVisible) return null
const progress = scanStatus.total_files > 0
// Scan progress percentage
const scanProgress = scanStatus && scanStatus.total_files > 0
? (scanStatus.processed_files / scanStatus.total_files) * 100
: 0
const isComplete = !scanStatus.is_scanning && scanStatus.processed_files > 0
const hasErrors = scanStatus.errors && scanStatus.errors.length > 0
const isComplete = phase === 'idle' && (scanStatus?.processed_files ?? 0) > 0
const hasErrors = scanStatus?.errors && scanStatus.errors.length > 0
return (
<div
@@ -85,8 +121,10 @@ export function ScanProgress() {
onClick={() => setIsMinimized(!isMinimized)}
>
<div className="flex items-center gap-2">
{scanStatus.is_scanning ? (
{phase === 'scanning' ? (
<Loader2 className="h-4 w-4 animate-spin text-primary" />
) : phase === 'processing' ? (
<Sparkles className="h-4 w-4 animate-pulse text-amber-400" />
) : isComplete && !hasErrors ? (
<Check className="h-4 w-4 text-pick" />
) : hasErrors ? (
@@ -96,11 +134,13 @@ export function ScanProgress() {
)}
{!isMinimized && (
<span className="text-sm font-medium text-text">
{scanStatus.is_scanning
{phase === 'scanning'
? 'Scanning Folders'
: phase === 'processing'
? 'Processing Photos'
: isComplete
? 'Scan Complete'
: 'Scan Status'}
? 'Complete'
: 'Status'}
</span>
)}
</div>
@@ -120,59 +160,80 @@ export function ScanProgress() {
{/* Content */}
{!isMinimized && (
<div className="p-3">
{/* Current folder */}
{scanStatus.current_folder && (
<div className="mb-2 text-xs text-text-muted">
<span className="font-mono">{scanStatus.current_folder}</span>
</div>
{/* Scan phase */}
{phase === 'scanning' && scanStatus && (
<>
{scanStatus.current_folder && (
<div className="mb-2 text-xs text-text-muted">
<span className="font-mono">{scanStatus.current_folder}</span>
</div>
)}
<div className="mb-2">
<div className="h-1.5 overflow-hidden rounded-full bg-surface-offset">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${scanProgress}%` }}
/>
</div>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-text-muted">
{scanStatus.processed_files} / {scanStatus.total_files || '?'} files
</span>
<span className="font-medium text-primary">
{Math.round(scanProgress)}%
</span>
</div>
</>
)}
{/* Progress bar */}
<div className="mb-2">
<div className="h-1.5 overflow-hidden rounded-full bg-surface-offset">
<div
className={clsx(
'h-full transition-all duration-300',
scanStatus.is_scanning
? 'bg-primary'
: hasErrors
? 'bg-reject'
: 'bg-pick'
{/* Processing phase */}
{phase === 'processing' && workerStatus && (
<>
<div className="mb-2 flex items-center gap-2 text-xs text-text-muted">
<Brain className="h-3.5 w-3.5 text-amber-400" />
<span>Analyzing photos&hellip;</span>
</div>
<div className="space-y-1 text-xs">
{visionActive > 0 && (
<div className="flex items-center justify-between">
<span className="text-text-muted">Vision pipeline</span>
<span className="font-mono text-amber-400">{visionActive} queued</span>
</div>
)}
style={{ width: `${progress}%` }}
/>
</div>
</div>
{(totalActive - visionActive) > 0 && (
<div className="flex items-center justify-between">
<span className="text-text-muted">Other tasks</span>
<span className="font-mono text-text-muted">{totalActive - visionActive} queued</span>
</div>
)}
</div>
<div className="mt-2 text-[11px] text-text-muted/60">
Thumbnails, embeddings, faces, tags &mdash; runs in background
</div>
</>
)}
{/* Stats */}
<div className="flex items-center justify-between text-xs">
<span className="text-text-muted">
{scanStatus.processed_files} / {scanStatus.total_files || '?'} files
</span>
<span className={clsx(
'font-medium',
scanStatus.is_scanning ? 'text-primary' : hasErrors ? 'text-reject' : 'text-pick'
)}>
{scanStatus.is_scanning
? `${Math.round(progress)}%`
: isComplete
? 'Done'
: 'Idle'}
</span>
</div>
{/* Done phase */}
{phase === 'idle' && isComplete && (
<div className="flex items-center gap-2 text-xs text-pick">
<Check className="h-3.5 w-3.5" />
<span>All processing complete</span>
</div>
)}
{/* Errors */}
{hasErrors && (
<div className="mt-2 max-h-20 overflow-y-auto rounded bg-reject/10 p-2">
<div className="text-xs text-reject">
{scanStatus.errors.slice(0, 3).map((error, i) => (
{scanStatus!.errors.slice(0, 3).map((error, i) => (
<div key={i} className="truncate">
{error}
</div>
))}
{scanStatus.errors.length > 3 && (
{scanStatus!.errors.length > 3 && (
<div className="mt-1 text-text-muted">
+{scanStatus.errors.length - 3} more errors
+{scanStatus!.errors.length - 3} more errors
</div>
)}
</div>
@@ -182,4 +243,22 @@ export function ScanProgress() {
)}
</div>
)
}
}
function visionQueued(ws: WorkerStatus | undefined): number {
if (!ws) return 0
const queued = ws.queues?.['vision'] ?? 0
const active = ws.workers
?.filter(w => w.queues?.includes('vision'))
.reduce((sum, w) => sum + (w.active ?? 0) + (w.reserved ?? 0), 0) ?? 0
return queued + active
}
function totalQueued(ws: WorkerStatus | undefined): number {
if (!ws) return 0
const queued = Object.values(ws.queues ?? {}).reduce((a, b) => a + b, 0)
const active = ws.workers?.reduce(
(sum, w) => sum + (w.active ?? 0) + (w.reserved ?? 0), 0
) ?? 0
return queued + active
}