feat: settings panel + thumbnail pipeline fixes

- photos.py: stop crashing in FileResponse when a thumb hasn't been
  generated; return a clean 404 with Retry-After so the frontend can
  back off.
- thumbs.py: fix process_video_thumbnail (overwrite_output, robust
  duration probe across stream/format, eager frame load + temp cleanup)
  so videos stop ending up as the gray placeholder.
- library.py: new /maintenance/* endpoints — thumbnail-stats,
  regenerate-thumbnails (with media_type / only_failed filters), and a
  manual data-integrity cleanup trigger.
- Frontend Settings panel (gear in TopBar) surfacing those endpoints
  plus a re-scan button and live thumbnail status counts.
- PhotoThumbnail: stretch the auto-retry schedule for slow RAW jobs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-09 09:24:08 +02:00
parent 6fa00f4b37
commit 9ed577f40c
8 changed files with 719 additions and 33 deletions

View File

@@ -0,0 +1,383 @@
import { useEffect, useState, useCallback } from 'react'
import {
X,
RefreshCw,
Wrench,
Film,
Image as ImageIcon,
AlertTriangle,
Database,
Loader2,
} from 'lucide-react'
import clsx from 'clsx'
import {
library,
type ThumbnailStats,
type LibraryStats,
type MediaType,
} from '../../services/api'
import { toast } from '../ToastContainer'
interface SettingsDialogProps {
isOpen: boolean
onClose: () => void
}
/**
* Catch-all "settings + admin" panel. Currently exposes the maintenance
* endpoints exposed by /api/v1/library/maintenance/* — regenerate
* thumbnails (with filters), run the data-integrity cleanup, and trigger
* a full library re-scan. The thumbnail stats block is the entry point
* users will look at to understand what's going on after a scan.
*
* Each action is gated by an in-flight flag so double-clicks don't
* stack background jobs, and the stats block re-fetches whenever the
* dialog opens or after any action completes.
*/
export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
const [thumbStats, setThumbStats] = useState<ThumbnailStats | null>(null)
const [libStats, setLibStats] = useState<LibraryStats | null>(null)
const [loadingStats, setLoadingStats] = useState(false)
// One key per action so each button has its own spinner without
// blocking the others.
const [busy, setBusy] = useState<Record<string, boolean>>({})
const refreshStats = useCallback(async () => {
setLoadingStats(true)
try {
const [thumbs, lib] = await Promise.all([
library.maintenance.thumbnailStats(),
library.stats(),
])
setThumbStats(thumbs)
setLibStats(lib)
} catch (e) {
console.error('Failed to load settings stats', e)
toast.error('Could not load library stats')
} finally {
setLoadingStats(false)
}
}, [])
// Esc closes; load stats when opened.
useEffect(() => {
if (!isOpen) return
refreshStats()
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [isOpen, onClose, refreshStats])
const runAction = useCallback(
async <T,>(
key: string,
fn: () => Promise<T>,
successTitle: string,
describe?: (result: T) => string | undefined
) => {
if (busy[key]) return
setBusy((b) => ({ ...b, [key]: true }))
try {
const result = await fn()
toast.success(successTitle, describe?.(result))
await refreshStats()
} catch (e: unknown) {
const message = e instanceof Error ? e.message : String(e)
toast.error(`${successTitle} failed`, message)
} finally {
setBusy((b) => ({ ...b, [key]: false }))
}
},
[busy, refreshStats]
)
const regenerate = useCallback(
(key: string, body: { media_types?: MediaType[]; only_failed?: boolean }) =>
runAction(
key,
() => library.maintenance.regenerateThumbnails(body),
'Regeneration queued',
(r) => `${r.queued} photos queued, ${r.cleared_dirs} thumb dirs cleared`
),
[runAction]
)
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<div className="relative z-10 flex max-h-[85vh] w-[640px] flex-col rounded-lg border border-border bg-surface shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-5 py-3">
<h2 className="text-base font-semibold text-text">Settings</h2>
<button
onClick={onClose}
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
title="Close (Esc)"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-5">
{/* ----------------------------------------------------- */}
{/* Library overview */}
{/* ----------------------------------------------------- */}
<Section
icon={<Database className="h-4 w-4" />}
title="Library"
right={
<button
onClick={refreshStats}
disabled={loadingStats}
className="flex items-center gap-1 rounded border border-border px-2 py-1 text-xs text-text-muted hover:bg-surface-2 disabled:opacity-50"
>
{loadingStats ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<RefreshCw className="h-3 w-3" />
)}
Refresh
</button>
}
>
<div className="grid grid-cols-3 gap-2 text-xs">
<Stat label="Photos" value={libStats?.total_photos} />
<Stat label="Videos" value={libStats?.total_videos} />
<Stat
label="On disk"
value={
libStats ? `${libStats.total_size_gb.toFixed(1)} GB` : undefined
}
/>
</div>
<div className="mt-3">
<ActionButton
loading={busy.scan}
onClick={() =>
runAction(
'scan',
() => library.scan(),
'Library scan started'
)
}
>
<RefreshCw className="h-4 w-4" />
Re-scan source folders
</ActionButton>
</div>
</Section>
{/* ----------------------------------------------------- */}
{/* Thumbnail maintenance */}
{/* ----------------------------------------------------- */}
<Section
icon={<ImageIcon className="h-4 w-4" />}
title="Thumbnails"
>
<div className="grid grid-cols-4 gap-2 text-xs">
<Stat
label="Completed"
value={thumbStats?.completed}
tone="ok"
/>
<Stat
label="Pending"
value={thumbStats?.pending}
tone="muted"
/>
<Stat
label="Processing"
value={thumbStats?.processing}
tone="muted"
/>
<Stat
label="Failed"
value={thumbStats?.failed}
tone={thumbStats && thumbStats.failed > 0 ? 'warn' : 'muted'}
/>
</div>
<p className="mt-3 text-xs text-text-muted">
Reset on-disk thumbnails and re-queue generation. Use after
upgrading the worker or to fix the gray placeholders left
behind by an earlier failure.
</p>
<div className="mt-2 flex flex-wrap gap-2">
<ActionButton
loading={busy['regen-videos']}
onClick={() =>
regenerate('regen-videos', { media_types: ['video'] })
}
>
<Film className="h-4 w-4" />
Regenerate video thumbnails
</ActionButton>
<ActionButton
loading={busy['regen-failed']}
onClick={() =>
regenerate('regen-failed', { only_failed: true })
}
disabled={!!thumbStats && thumbStats.failed === 0}
>
<AlertTriangle className="h-4 w-4" />
Retry failed
{thumbStats ? ` (${thumbStats.failed})` : ''}
</ActionButton>
<ActionButton
loading={busy['regen-all']}
destructive
onClick={() => {
if (
!confirm(
'Regenerate thumbnails for the entire library? ' +
'This will queue every photo and may take a while.'
)
)
return
regenerate('regen-all', {})
}}
>
<RefreshCw className="h-4 w-4" />
Regenerate all
</ActionButton>
</div>
</Section>
{/* ----------------------------------------------------- */}
{/* Data integrity */}
{/* ----------------------------------------------------- */}
<Section
icon={<Wrench className="h-4 w-4" />}
title="Maintenance"
>
<p className="text-xs text-text-muted">
Re-runs the source-roots / folders / photos integrity
cleanup that normally only runs on backend startup. Safe
to run any time.
</p>
<div className="mt-2">
<ActionButton
loading={busy.cleanup}
onClick={() =>
runAction(
'cleanup',
() => library.maintenance.cleanup(),
'Data integrity cleanup complete'
)
}
>
<Wrench className="h-4 w-4" />
Run data integrity cleanup
</ActionButton>
</div>
</Section>
</div>
</div>
</div>
</div>
)
}
// ---------------------------------------------------------------------------
// Local presentational helpers — kept private to this file.
// ---------------------------------------------------------------------------
function Section({
icon,
title,
right,
children,
}: {
icon: React.ReactNode
title: string
right?: React.ReactNode
children: React.ReactNode
}) {
return (
<section className="mb-5 last:mb-0">
<div className="mb-2 flex items-center justify-between">
<h3 className="flex items-center gap-2 text-sm font-medium text-text">
{icon}
{title}
</h3>
{right}
</div>
<div className="rounded border border-border bg-bg p-3">{children}</div>
</section>
)
}
function Stat({
label,
value,
tone = 'muted',
}: {
label: string
value: number | string | undefined
tone?: 'ok' | 'warn' | 'muted'
}) {
const toneClass =
tone === 'ok'
? 'text-pick'
: tone === 'warn'
? 'text-reject'
: 'text-text'
return (
<div className="rounded bg-surface p-2">
<div className="text-[10px] uppercase tracking-wide text-text-muted">
{label}
</div>
<div className={clsx('text-base font-semibold', toneClass)}>
{value ?? '—'}
</div>
</div>
)
}
function ActionButton({
loading,
disabled,
destructive,
onClick,
children,
}: {
loading?: boolean
disabled?: boolean
destructive?: boolean
onClick: () => void
children: React.ReactNode
}) {
return (
<button
onClick={onClick}
disabled={disabled || loading}
className={clsx(
'flex items-center gap-2 rounded border px-3 py-1.5 text-xs font-medium transition-colors',
destructive
? 'border-reject/40 text-reject hover:bg-reject/10'
: 'border-border text-text hover:bg-surface-2',
(disabled || loading) && 'cursor-not-allowed opacity-50'
)}
>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
{!loading && children}
{loading && (
// Re-render only the textual children when loading by stripping
// the icon (children[0]) — we keep just the label so the spinner
// takes the icon slot.
Array.isArray(children) ? children.slice(1) : children
)}
</button>
)
}