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">

View File

@@ -230,6 +230,18 @@ export interface RegenerateResult {
}
}
export interface MissingStats {
would_delete?: number
deleted?: number
skipped_unmounted: number
dry_run: boolean
}
export interface PruneResult extends MissingStats {
status?: string
message?: string
}
export interface WorkerInfo {
name: string
status: 'online' | 'unreachable'
@@ -295,7 +307,11 @@ export const library = {
/** Reset on-disk thumbs and re-queue Celery generation. With no
* filters, every photo in the library is re-queued. */
regenerateThumbnails: async (
body: { media_types?: MediaType[]; only_failed?: boolean } = {}
body: {
media_types?: MediaType[]
only_failed?: boolean
only_pending?: boolean
} = {}
): Promise<RegenerateResult> => {
const response = await api.post(
'/library/maintenance/regenerate-thumbnails',
@@ -312,6 +328,19 @@ export const library = {
return response.data
},
/** Dry-run count of photo rows whose files are no longer on disk
* (under a mounted source root). */
missingStats: async (): Promise<MissingStats> => {
const response = await api.get('/library/maintenance/missing-stats')
return response.data
},
/** Actually delete the orphaned photo rows. */
pruneMissing: async (): Promise<PruneResult> => {
const response = await api.post('/library/maintenance/prune-missing')
return response.data
},
/** Re-run the source-roots / folders / photos integrity cleanup that
* normally runs on backend startup. */
cleanup: async (): Promise<{ status: string; message?: string }> => {