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

@@ -1,15 +1,32 @@
"""
Library API router for stats and scanning
Library API router for stats, scanning, and maintenance.
The /maintenance/* endpoints are surfaced through the frontend Settings
panel. They're intentionally idempotent and operate by re-queueing the
existing Celery tasks rather than doing any heavy lifting in the
request thread.
"""
import logging
import os
import shutil
from typing import List, Optional
from fastapi import APIRouter, Depends
from sqlalchemy import select, func
from pydantic import BaseModel, Field
from sqlalchemy import select, func, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Photo
logger = logging.getLogger(__name__)
router = APIRouter()
# Media types we accept in the regenerate-thumbnails request body. Mirrors
# the values produced by `app.tasks.scan.get_media_type`.
_VALID_MEDIA_TYPES = {'photo', 'raw', 'heic', 'video'}
@router.get("/stats")
async def get_library_stats(db: AsyncSession = Depends(get_db)):
"""Get library statistics + per-section counts. Each section count
@@ -107,4 +124,143 @@ async def get_scan_status(db: AsyncSession = Depends(get_db)):
"processed_files": processed_files,
"total_files": total_files,
"errors": [e.decode() for e in errors] if errors else []
}
}
# ---------------------------------------------------------------------------
# Maintenance endpoints — surfaced via the Settings panel.
# ---------------------------------------------------------------------------
class RegenerateThumbnailsRequest(BaseModel):
"""Optional filters narrowing which photos get re-queued. With both
fields omitted the request resets every photo in the library."""
media_types: Optional[List[str]] = Field(
default=None,
description="Restrict to these media_type values (photo/raw/heic/video).",
)
only_failed: bool = Field(
default=False,
description="If true, only re-queue photos whose processing_status is 'failed'.",
)
@router.get("/maintenance/thumbnail-stats")
async def get_thumbnail_stats(db: AsyncSession = Depends(get_db)):
"""Counts of photos by processing_status, plus a media-type breakdown
so the Settings panel can show the user what's outstanding."""
status_rows = (
await db.execute(
select(Photo.processing_status, func.count(Photo.id)).group_by(
Photo.processing_status
)
)
).all()
media_rows = (
await db.execute(
select(Photo.media_type, func.count(Photo.id)).group_by(Photo.media_type)
)
).all()
by_status = {status or 'unknown': count for status, count in status_rows}
by_media_type = {media or 'unknown': count for media, count in media_rows}
total = sum(by_status.values())
return {
"total": total,
"pending": by_status.get('pending', 0),
"processing": by_status.get('processing', 0),
"completed": by_status.get('completed', 0),
"failed": by_status.get('failed', 0),
"by_media_type": by_media_type,
}
@router.post("/maintenance/regenerate-thumbnails")
async def regenerate_thumbnails(
body: RegenerateThumbnailsRequest,
db: AsyncSession = Depends(get_db),
):
"""Reset matching photos' on-disk thumbnail directories and re-queue
Celery thumbnail generation. Used by the Settings panel for the
'regenerate video thumbnails' / 'regenerate failed' buttons.
Files on disk are removed under /data/thumbs/<photo_id>/ so the next
request to /photos/{id}/thumb/{size} actually re-generates instead of
serving the stale placeholder.
"""
from app.tasks.thumbs import generate_thumbnails
# Validate media_types early so a typo can't silently match nothing.
media_types = body.media_types
if media_types is not None:
invalid = [m for m in media_types if m not in _VALID_MEDIA_TYPES]
if invalid:
return {
"status": "error",
"message": f"Invalid media_types: {invalid}. "
f"Allowed: {sorted(_VALID_MEDIA_TYPES)}",
}
query = select(Photo)
if media_types:
query = query.where(Photo.media_type.in_(media_types))
if body.only_failed:
query = query.where(Photo.processing_status == 'failed')
photos = (await db.execute(query)).scalars().all()
cleared_dirs = 0
file_errors = 0
for photo in photos:
thumb_dir = f"/data/thumbs/{photo.id}"
if os.path.isdir(thumb_dir):
try:
shutil.rmtree(thumb_dir)
cleared_dirs += 1
except OSError as e:
file_errors += 1
logger.warning(f"Could not clear thumb dir {thumb_dir}: {e}")
photo.processing_status = 'pending'
photo.processing_error = None
photo.thumb_small = None
photo.thumb_medium = None
photo.thumb_large = None
await db.commit()
# Queue celery tasks AFTER the commit so the worker sees the reset
# state when it picks the job up.
queued = 0
for photo in photos:
try:
generate_thumbnails.delay(photo.id)
queued += 1
except Exception as e:
logger.warning(f"Could not queue thumbnail job for {photo.id}: {e}")
return {
"status": "success",
"matched": len(photos),
"queued": queued,
"cleared_dirs": cleared_dirs,
"file_errors": file_errors,
"filters": {
"media_types": media_types,
"only_failed": body.only_failed,
},
}
@router.post("/maintenance/cleanup")
async def run_data_integrity_cleanup():
"""Re-run the source-roots / folders / photos data-integrity cleanup
that normally only runs on backend startup. Idempotent."""
from app.services.cleanup import cleanup_data_integrity
try:
await cleanup_data_integrity()
return {"status": "success"}
except Exception as e:
logger.error(f"Manual cleanup failed: {e}")
return {"status": "error", "message": str(e)}

View File

@@ -322,12 +322,14 @@ async def get_thumbnail(
thumb_path = f"{thumb_dir}/{size}.webp"
if not os.path.exists(thumb_path):
# Generate thumbnail on demand
# Queue background generation (handles RAW/HEIC/video properly)
from app.tasks.thumbs import generate_thumbnails
generate_thumbnails.delay(photo_id)
# For now, return a placeholder or the original with reduced quality
if os.path.exists(photo.filepath):
# Best-effort inline fallback for standard images so the first
# request doesn't have to wait for the worker. RAW/HEIC/video
# and missing source files fall through to a clean 404 below.
if photo.filepath and os.path.exists(photo.filepath):
from PIL import Image
try:
os.makedirs(thumb_dir, exist_ok=True)
@@ -359,10 +361,23 @@ async def get_thumbnail(
# Save as WebP
img.save(thumb_path, 'WEBP', quality=85, optimize=True)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error generating thumbnail: {e}")
raise HTTPException(status_code=404, detail="Could not generate thumbnail")
logger.warning(
f"Inline thumbnail fallback failed for {photo_id} ({size}); "
f"waiting on worker: {e}"
)
# If the inline fallback didn't (or couldn't) produce the file,
# tell the client to retry instead of crashing in FileResponse/nginx.
if not os.path.exists(thumb_path):
raise HTTPException(
status_code=404,
detail="Thumbnail not ready",
headers={"Retry-After": "2"},
)
# Check if we're behind Nginx
if os.environ.get('USE_X_ACCEL_REDIRECT'):
# Use Nginx X-Accel-Redirect for better performance

View File

@@ -108,27 +108,86 @@ def process_heic_image(filepath: str) -> Image.Image:
raise
def process_video_thumbnail(filepath: str) -> Image.Image:
"""Extract thumbnail from video file"""
"""Extract a still frame from a video file as a PIL Image."""
import tempfile
from io import BytesIO
tmp_path: Optional[str] = None
try:
# Get video duration
# Find a usable seek timestamp. Some camera MOVs only expose
# duration at the format level, and stream 0 isn't always the
# video stream — search explicitly and fall back to the format
# duration, then to t=0 if neither is available.
probe = ffmpeg.probe(filepath)
duration = float(probe['streams'][0]['duration'])
# Extract frame at 10% of duration
timestamp = duration * 0.1
# Extract frame using ffmpeg
import tempfile
duration: Optional[float] = None
for stream_info in probe.get('streams', []):
if stream_info.get('codec_type') != 'video':
continue
raw_duration = stream_info.get('duration')
if raw_duration is not None:
try:
duration = float(raw_duration)
break
except (TypeError, ValueError):
pass
if duration is None:
raw_duration = probe.get('format', {}).get('duration')
if raw_duration is not None:
try:
duration = float(raw_duration)
except (TypeError, ValueError):
duration = None
# Seek to 10% in for a representative frame; clamp very short
# clips to t=0 so we don't seek past the end.
timestamp = max(0.0, (duration or 0.0) * 0.1)
# NamedTemporaryFile creates the file on disk, so we MUST tell
# ffmpeg to overwrite it (otherwise it prompts on stdin and the
# call hangs/fails — which is why videos were getting the gray
# placeholder). We close the handle immediately and clean up
# in `finally` ourselves.
with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as tmp:
stream = ffmpeg.input(filepath, ss=timestamp)
stream = ffmpeg.output(stream, tmp.name, vframes=1, format='image2', vcodec='mjpeg')
ffmpeg.run(stream, capture_stdout=True, capture_stderr=True)
return Image.open(tmp.name)
tmp_path = tmp.name
stream = ffmpeg.input(filepath, ss=timestamp)
stream = ffmpeg.output(
stream,
tmp_path,
vframes=1,
format='image2',
vcodec='mjpeg',
)
ffmpeg.run(
stream,
capture_stdout=True,
capture_stderr=True,
overwrite_output=True,
)
# Load the frame fully into memory so we can delete the temp
# file immediately. Pillow's `Image.open` is lazy, which would
# otherwise leave the file dangling.
with open(tmp_path, 'rb') as fh:
data = fh.read()
if not data:
raise RuntimeError("ffmpeg produced an empty frame")
return Image.open(BytesIO(data)).copy()
except ffmpeg.Error as e:
stderr = (e.stderr or b'').decode('utf-8', errors='replace')
logger.error(
f"ffmpeg failed extracting video thumbnail from {filepath}: {stderr}"
)
return create_placeholder_thumbnail('video')
except Exception as e:
logger.error(f"Error extracting video thumbnail from {filepath}: {e}")
# Create a placeholder thumbnail
return create_placeholder_thumbnail('video')
finally:
if tmp_path and os.path.exists(tmp_path):
try:
os.unlink(tmp_path)
except OSError:
pass
def create_placeholder_thumbnail(media_type: str) -> Image.Image:
"""Create a placeholder thumbnail for failed processing"""

View File

@@ -10,6 +10,7 @@ import { AppFooter } from './components/AppFooter'
import { PreviewView } from './components/preview/PreviewView'
import { FilterBar } from './components/filter/FilterBar'
import { DiscardActionBar } from './components/discard/DiscardActionBar'
import { SettingsDialog } from './components/dialogs/SettingsDialog'
import { usePhotoStore } from './store/photoStore'
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
import { useFilterUrlSync } from './hooks/useFilterUrlSync'
@@ -18,6 +19,7 @@ import { usePhotosQuery } from './hooks/usePhotosQuery'
function App() {
const [leftSidebarOpen, setLeftSidebarOpen] = useState(true)
const [rightSidebarOpen, setRightSidebarOpen] = useState(false)
const [settingsOpen, setSettingsOpen] = useState(false)
const selectedPhotos = usePhotoStore((state) => state.selectedPhotos)
const viewMode = usePhotoStore((state) => state.viewMode)
@@ -53,7 +55,7 @@ function App() {
return (
<div className="flex flex-col h-screen bg-bg text-text">
<TopBar />
<TopBar onOpenSettings={() => setSettingsOpen(true)} />
<div className="flex flex-1 overflow-hidden">
{/* Left Sidebar */}
@@ -100,6 +102,12 @@ function App() {
{/* Preview overlay — covers TopBar when active */}
{viewMode === 'preview' && <PreviewView />}
{/* Settings panel — admin/maintenance actions */}
<SettingsDialog
isOpen={settingsOpen}
onClose={() => setSettingsOpen(false)}
/>
</div>
)
}

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>
)
}

View File

@@ -1,18 +1,31 @@
import { Settings } from 'lucide-react'
import muliLogo from '../../assets/muli-logo.png'
interface TopBarProps {
onOpenSettings: () => void
}
/**
* Slim top bar — just the logo. The active heap badge moved into the
* Heaps panel in the left sidebar (where it actually relates to the
* heap rows the user navigates to).
* Slim top bar — logo on the left, settings gear on the right. The
* active heap badge moved into the Heaps panel in the left sidebar
* (where it actually relates to the heap rows the user navigates to).
*/
export function TopBar() {
export function TopBar({ onOpenSettings }: TopBarProps) {
return (
<header className="flex h-12 items-center justify-between border-b border-border bg-surface px-4">
<div className="flex items-center gap-2">
<img src={muliLogo} alt="Mulimago" className="h-7 w-7 object-contain" />
<h1 className="text-lg font-semibold text-text">Mulimago</h1>
</div>
<div className="flex items-center gap-2" />
<div className="flex items-center gap-2">
<button
onClick={onOpenSettings}
className="rounded p-1.5 text-text-muted transition-colors hover:bg-surface-2 hover:text-text"
title="Settings"
>
<Settings className="h-4 w-4" />
</button>
</div>
</header>
)
}

View File

@@ -9,8 +9,10 @@ import { usePhotoStore } from '../../store/photoStore'
export const PHOTO_DRAG_MIME = 'application/x-mulita-photos'
// Auto-retry schedule (ms). Backend generates thumbs on-demand via Celery, so
// first hit often 404s. Try a few times with backoff before giving up.
const AUTO_RETRY_DELAYS = [1500, 3500, 6000]
// the first hit on a freshly-scanned library returns 404 "not ready" until
// the worker catches up. RAW postprocess can take several seconds per file
// when the queue is deep, so the tail of the schedule is generous.
const AUTO_RETRY_DELAYS = [1500, 3500, 7000, 12000, 20000]
interface PhotoThumbnailProps {
photo: Photo

View File

@@ -207,6 +207,29 @@ export const photos = {
}
// Library API
export type MediaType = 'photo' | 'raw' | 'heic' | 'video'
export interface ThumbnailStats {
total: number
pending: number
processing: number
completed: number
failed: number
by_media_type: Record<string, number>
}
export interface RegenerateResult {
status: string
matched: number
queued: number
cleared_dirs: number
file_errors: number
filters: {
media_types: MediaType[] | null
only_failed: boolean
}
}
export const library = {
scan: async () => {
const response = await api.post('/library/scan')
@@ -222,6 +245,33 @@ export const library = {
const response = await api.get('/library/stats')
return response.data
},
/** Maintenance / admin actions surfaced via the Settings panel. */
maintenance: {
thumbnailStats: async (): Promise<ThumbnailStats> => {
const response = await api.get('/library/maintenance/thumbnail-stats')
return response.data
},
/** 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 } = {}
): Promise<RegenerateResult> => {
const response = await api.post(
'/library/maintenance/regenerate-thumbnails',
body
)
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 }> => {
const response = await api.post('/library/maintenance/cleanup')
return response.data
},
},
}
export interface LibraryStats {