feat: prune orphaned photo rows + retry-pending action

Adds /api/v1/library/maintenance/{missing-stats,prune-missing} backed
by a new cleanup helper that deletes Photo rows whose files no longer
exist on disk under a *mounted* source root. Skips photos under
unmounted roots so a temporarily-disconnected drive doesn't get
silently nuked.

Settings panel surfaces the orphan count with a destructive Prune
button, plus a "Kick pending" action that re-queues photos stuck in
processing_status='pending' (typically left behind when the scanner
created the row but the worker never picked up the thumbnail task).

Common trigger: PHOTO_DIRS in .env was repointed at a different
library root, leaving every old row dangling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-04-09 10:47:06 +02:00
parent 3df8add3b6
commit 697343646a
4 changed files with 205 additions and 5 deletions

View File

@@ -19,6 +19,7 @@ import {
type LibraryStats,
type MediaType,
type WorkerStatus,
type MissingStats,
} from '../../services/api'
import { toast } from '../ToastContainer'
@@ -42,6 +43,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
const [thumbStats, setThumbStats] = useState<ThumbnailStats | null>(null)
const [libStats, setLibStats] = useState<LibraryStats | null>(null)
const [workerStatus, setWorkerStatus] = useState<WorkerStatus | null>(null)
const [missingStats, setMissingStats] = useState<MissingStats | null>(null)
const [loadingStats, setLoadingStats] = useState(false)
const [loadingWorkers, setLoadingWorkers] = useState(false)
const [showAllErrors, setShowAllErrors] = useState(false)
@@ -69,8 +71,12 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
const refreshWorkers = useCallback(async () => {
setLoadingWorkers(true)
try {
const ws = await library.maintenance.workerStatus()
const [ws, ms] = await Promise.all([
library.maintenance.workerStatus(),
library.maintenance.missingStats(),
])
setWorkerStatus(ws)
setMissingStats(ms)
} catch (e) {
console.error('Failed to load worker status', e)
toast.error('Could not load worker status')
@@ -111,7 +117,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
try {
const result = await fn()
toast.success(successTitle, describe?.(result))
await refreshStats()
await Promise.all([refreshStats(), refreshWorkers()])
} catch (e: unknown) {
const message = e instanceof Error ? e.message : String(e)
toast.error(`${successTitle} failed`, message)
@@ -119,11 +125,18 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
setBusy((b) => ({ ...b, [key]: false }))
}
},
[busy, refreshStats]
[busy, refreshStats, refreshWorkers]
)
const regenerate = useCallback(
(key: string, body: { media_types?: MediaType[]; only_failed?: boolean }) =>
(
key: string,
body: {
media_types?: MediaType[]
only_failed?: boolean
only_pending?: boolean
}
) =>
runAction(
key,
() => library.maintenance.regenerateThumbnails(body),
@@ -263,6 +276,18 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
{thumbStats ? ` (${thumbStats.failed})` : ''}
</ActionButton>
<ActionButton
loading={busy['regen-pending']}
onClick={() =>
regenerate('regen-pending', { only_pending: true })
}
disabled={!!thumbStats && thumbStats.pending === 0}
>
<RefreshCw className="h-4 w-4" />
Kick pending
{thumbStats ? ` (${thumbStats.pending})` : ''}
</ActionButton>
<ActionButton
loading={busy['regen-all']}
destructive
@@ -394,6 +419,54 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
</div>
)}
{/* Orphaned rows (files gone from disk) */}
{missingStats && (missingStats.would_delete ?? 0) > 0 && (
<div className="mt-3 rounded border border-star/40 bg-star/10 p-2">
<div className="flex items-center justify-between gap-2">
<div className="text-xs">
<div className="flex items-center gap-1.5 font-medium text-text">
<AlertTriangle className="h-3.5 w-3.5 text-star" />
{missingStats.would_delete} orphaned photo rows
</div>
<div className="mt-0.5 text-[10px] text-text-muted">
Files no longer exist on disk under a mounted source
root. Usually means PHOTO_DIRS was repointed at a
different library.
{missingStats.skipped_unmounted > 0 && (
<>
{' '}
{missingStats.skipped_unmounted} more rows are
under unmounted roots and will not be touched.
</>
)}
</div>
</div>
<ActionButton
loading={busy['prune-missing']}
destructive
onClick={() => {
if (
!confirm(
`Delete ${missingStats.would_delete} photo rows whose files are missing? ` +
'This cannot be undone.'
)
)
return
runAction(
'prune-missing',
() => library.maintenance.pruneMissing(),
'Orphans pruned',
(r) => `${r.deleted ?? 0} rows deleted`
)
}}
>
<AlertTriangle className="h-4 w-4" />
Prune
</ActionButton>
</div>
</div>
)}
{/* Per-worker breakdown */}
{workerStatus && workerStatus.workers.length > 0 && (
<div className="mt-3 space-y-2">