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:
@@ -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)}
|
||||
@@ -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
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
383
frontend/src/components/dialogs/SettingsDialog.tsx
Normal file
383
frontend/src/components/dialogs/SettingsDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user