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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user