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 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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import Photo
|
from app.models import Photo
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
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")
|
@router.get("/stats")
|
||||||
async def get_library_stats(db: AsyncSession = Depends(get_db)):
|
async def get_library_stats(db: AsyncSession = Depends(get_db)):
|
||||||
"""Get library statistics + per-section counts. Each section count
|
"""Get library statistics + per-section counts. Each section count
|
||||||
@@ -108,3 +125,142 @@ async def get_scan_status(db: AsyncSession = Depends(get_db)):
|
|||||||
"total_files": total_files,
|
"total_files": total_files,
|
||||||
"errors": [e.decode() for e in errors] if errors else []
|
"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"
|
thumb_path = f"{thumb_dir}/{size}.webp"
|
||||||
|
|
||||||
if not os.path.exists(thumb_path):
|
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
|
from app.tasks.thumbs import generate_thumbnails
|
||||||
generate_thumbnails.delay(photo_id)
|
generate_thumbnails.delay(photo_id)
|
||||||
|
|
||||||
# For now, return a placeholder or the original with reduced quality
|
# Best-effort inline fallback for standard images so the first
|
||||||
if os.path.exists(photo.filepath):
|
# 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
|
from PIL import Image
|
||||||
try:
|
try:
|
||||||
os.makedirs(thumb_dir, exist_ok=True)
|
os.makedirs(thumb_dir, exist_ok=True)
|
||||||
@@ -359,9 +361,22 @@ async def get_thumbnail(
|
|||||||
# Save as WebP
|
# Save as WebP
|
||||||
img.save(thumb_path, 'WEBP', quality=85, optimize=True)
|
img.save(thumb_path, 'WEBP', quality=85, optimize=True)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error generating thumbnail: {e}")
|
logger.warning(
|
||||||
raise HTTPException(status_code=404, detail="Could not generate thumbnail")
|
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
|
# Check if we're behind Nginx
|
||||||
if os.environ.get('USE_X_ACCEL_REDIRECT'):
|
if os.environ.get('USE_X_ACCEL_REDIRECT'):
|
||||||
|
|||||||
@@ -108,27 +108,86 @@ def process_heic_image(filepath: str) -> Image.Image:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
def process_video_thumbnail(filepath: str) -> Image.Image:
|
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:
|
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)
|
probe = ffmpeg.probe(filepath)
|
||||||
duration = float(probe['streams'][0]['duration'])
|
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
|
||||||
|
|
||||||
# Extract frame at 10% of duration
|
# Seek to 10% in for a representative frame; clamp very short
|
||||||
timestamp = duration * 0.1
|
# clips to t=0 so we don't seek past the end.
|
||||||
|
timestamp = max(0.0, (duration or 0.0) * 0.1)
|
||||||
|
|
||||||
# Extract frame using ffmpeg
|
# NamedTemporaryFile creates the file on disk, so we MUST tell
|
||||||
import tempfile
|
# 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:
|
with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as tmp:
|
||||||
stream = ffmpeg.input(filepath, ss=timestamp)
|
tmp_path = tmp.name
|
||||||
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)
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error extracting video thumbnail from {filepath}: {e}")
|
logger.error(f"Error extracting video thumbnail from {filepath}: {e}")
|
||||||
# Create a placeholder thumbnail
|
|
||||||
return create_placeholder_thumbnail('video')
|
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:
|
def create_placeholder_thumbnail(media_type: str) -> Image.Image:
|
||||||
"""Create a placeholder thumbnail for failed processing"""
|
"""Create a placeholder thumbnail for failed processing"""
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { AppFooter } from './components/AppFooter'
|
|||||||
import { PreviewView } from './components/preview/PreviewView'
|
import { PreviewView } from './components/preview/PreviewView'
|
||||||
import { FilterBar } from './components/filter/FilterBar'
|
import { FilterBar } from './components/filter/FilterBar'
|
||||||
import { DiscardActionBar } from './components/discard/DiscardActionBar'
|
import { DiscardActionBar } from './components/discard/DiscardActionBar'
|
||||||
|
import { SettingsDialog } from './components/dialogs/SettingsDialog'
|
||||||
import { usePhotoStore } from './store/photoStore'
|
import { usePhotoStore } from './store/photoStore'
|
||||||
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
||||||
import { useFilterUrlSync } from './hooks/useFilterUrlSync'
|
import { useFilterUrlSync } from './hooks/useFilterUrlSync'
|
||||||
@@ -18,6 +19,7 @@ import { usePhotosQuery } from './hooks/usePhotosQuery'
|
|||||||
function App() {
|
function App() {
|
||||||
const [leftSidebarOpen, setLeftSidebarOpen] = useState(true)
|
const [leftSidebarOpen, setLeftSidebarOpen] = useState(true)
|
||||||
const [rightSidebarOpen, setRightSidebarOpen] = useState(false)
|
const [rightSidebarOpen, setRightSidebarOpen] = useState(false)
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
const selectedPhotos = usePhotoStore((state) => state.selectedPhotos)
|
const selectedPhotos = usePhotoStore((state) => state.selectedPhotos)
|
||||||
const viewMode = usePhotoStore((state) => state.viewMode)
|
const viewMode = usePhotoStore((state) => state.viewMode)
|
||||||
|
|
||||||
@@ -53,7 +55,7 @@ function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen bg-bg text-text">
|
<div className="flex flex-col h-screen bg-bg text-text">
|
||||||
<TopBar />
|
<TopBar onOpenSettings={() => setSettingsOpen(true)} />
|
||||||
|
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{/* Left Sidebar */}
|
{/* Left Sidebar */}
|
||||||
@@ -100,6 +102,12 @@ function App() {
|
|||||||
|
|
||||||
{/* Preview overlay — covers TopBar when active */}
|
{/* Preview overlay — covers TopBar when active */}
|
||||||
{viewMode === 'preview' && <PreviewView />}
|
{viewMode === 'preview' && <PreviewView />}
|
||||||
|
|
||||||
|
{/* Settings panel — admin/maintenance actions */}
|
||||||
|
<SettingsDialog
|
||||||
|
isOpen={settingsOpen}
|
||||||
|
onClose={() => setSettingsOpen(false)}
|
||||||
|
/>
|
||||||
</div>
|
</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'
|
import muliLogo from '../../assets/muli-logo.png'
|
||||||
|
|
||||||
|
interface TopBarProps {
|
||||||
|
onOpenSettings: () => void
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Slim top bar — just the logo. The active heap badge moved into the
|
* Slim top bar — logo on the left, settings gear on the right. The
|
||||||
* Heaps panel in the left sidebar (where it actually relates to the
|
* active heap badge moved into the Heaps panel in the left sidebar
|
||||||
* heap rows the user navigates to).
|
* (where it actually relates to the heap rows the user navigates to).
|
||||||
*/
|
*/
|
||||||
export function TopBar() {
|
export function TopBar({ onOpenSettings }: TopBarProps) {
|
||||||
return (
|
return (
|
||||||
<header className="flex h-12 items-center justify-between border-b border-border bg-surface px-4">
|
<header className="flex h-12 items-center justify-between border-b border-border bg-surface px-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<img src={muliLogo} alt="Mulimago" className="h-7 w-7 object-contain" />
|
<img src={muliLogo} alt="Mulimago" className="h-7 w-7 object-contain" />
|
||||||
<h1 className="text-lg font-semibold text-text">Mulimago</h1>
|
<h1 className="text-lg font-semibold text-text">Mulimago</h1>
|
||||||
</div>
|
</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>
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import { usePhotoStore } from '../../store/photoStore'
|
|||||||
export const PHOTO_DRAG_MIME = 'application/x-mulita-photos'
|
export const PHOTO_DRAG_MIME = 'application/x-mulita-photos'
|
||||||
|
|
||||||
// Auto-retry schedule (ms). Backend generates thumbs on-demand via Celery, so
|
// 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.
|
// the first hit on a freshly-scanned library returns 404 "not ready" until
|
||||||
const AUTO_RETRY_DELAYS = [1500, 3500, 6000]
|
// 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 {
|
interface PhotoThumbnailProps {
|
||||||
photo: Photo
|
photo: Photo
|
||||||
|
|||||||
@@ -207,6 +207,29 @@ export const photos = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Library API
|
// 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 = {
|
export const library = {
|
||||||
scan: async () => {
|
scan: async () => {
|
||||||
const response = await api.post('/library/scan')
|
const response = await api.post('/library/scan')
|
||||||
@@ -222,6 +245,33 @@ export const library = {
|
|||||||
const response = await api.get('/library/stats')
|
const response = await api.get('/library/stats')
|
||||||
return response.data
|
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 {
|
export interface LibraryStats {
|
||||||
|
|||||||
Reference in New Issue
Block a user