diff --git a/backend/app/routers/library.py b/backend/app/routers/library.py index 4d74176..3c3f52d 100644 --- a/backend/app/routers/library.py +++ b/backend/app/routers/library.py @@ -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 [] - } \ No newline at end of file + } + + +# --------------------------------------------------------------------------- +# 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// 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)} \ No newline at end of file diff --git a/backend/app/routers/photos.py b/backend/app/routers/photos.py index bfafe93..2ba964f 100644 --- a/backend/app/routers/photos.py +++ b/backend/app/routers/photos.py @@ -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 diff --git a/backend/app/tasks/thumbs.py b/backend/app/tasks/thumbs.py index 533ddb2..5242354 100644 --- a/backend/app/tasks/thumbs.py +++ b/backend/app/tasks/thumbs.py @@ -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""" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1f0dffd..e51c5da 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 (
- + setSettingsOpen(true)} />
{/* Left Sidebar */} @@ -100,6 +102,12 @@ function App() { {/* Preview overlay — covers TopBar when active */} {viewMode === 'preview' && } + + {/* Settings panel — admin/maintenance actions */} + setSettingsOpen(false)} + />
) } diff --git a/frontend/src/components/dialogs/SettingsDialog.tsx b/frontend/src/components/dialogs/SettingsDialog.tsx new file mode 100644 index 0000000..77560cb --- /dev/null +++ b/frontend/src/components/dialogs/SettingsDialog.tsx @@ -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(null) + const [libStats, setLibStats] = useState(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>({}) + + 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 ( + key: string, + fn: () => Promise, + 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 ( +
+
+
+
+ {/* Header */} +
+

Settings

+ +
+ +
+ {/* ----------------------------------------------------- */} + {/* Library overview */} + {/* ----------------------------------------------------- */} +
} + title="Library" + right={ + + } + > +
+ + + +
+
+ + runAction( + 'scan', + () => library.scan(), + 'Library scan started' + ) + } + > + + Re-scan source folders + +
+
+ + {/* ----------------------------------------------------- */} + {/* Thumbnail maintenance */} + {/* ----------------------------------------------------- */} +
} + title="Thumbnails" + > +
+ + + + 0 ? 'warn' : '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. +

+ +
+ + regenerate('regen-videos', { media_types: ['video'] }) + } + > + + Regenerate video thumbnails + + + + regenerate('regen-failed', { only_failed: true }) + } + disabled={!!thumbStats && thumbStats.failed === 0} + > + + Retry failed + {thumbStats ? ` (${thumbStats.failed})` : ''} + + + { + if ( + !confirm( + 'Regenerate thumbnails for the entire library? ' + + 'This will queue every photo and may take a while.' + ) + ) + return + regenerate('regen-all', {}) + }} + > + + Regenerate all + +
+
+ + {/* ----------------------------------------------------- */} + {/* Data integrity */} + {/* ----------------------------------------------------- */} +
} + title="Maintenance" + > +

+ Re-runs the source-roots / folders / photos integrity + cleanup that normally only runs on backend startup. Safe + to run any time. +

+
+ + runAction( + 'cleanup', + () => library.maintenance.cleanup(), + 'Data integrity cleanup complete' + ) + } + > + + Run data integrity cleanup + +
+
+
+
+
+
+ ) +} + +// --------------------------------------------------------------------------- +// 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 ( +
+
+

+ {icon} + {title} +

+ {right} +
+
{children}
+
+ ) +} + +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 ( +
+
+ {label} +
+
+ {value ?? '—'} +
+
+ ) +} + +function ActionButton({ + loading, + disabled, + destructive, + onClick, + children, +}: { + loading?: boolean + disabled?: boolean + destructive?: boolean + onClick: () => void + children: React.ReactNode +}) { + return ( + + ) +} diff --git a/frontend/src/components/layout/TopBar.tsx b/frontend/src/components/layout/TopBar.tsx index 7c7ebad..eeaf1c5 100644 --- a/frontend/src/components/layout/TopBar.tsx +++ b/frontend/src/components/layout/TopBar.tsx @@ -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 (
Mulimago

Mulimago

-
+
+ +
) } diff --git a/frontend/src/components/timeline/PhotoThumbnail.tsx b/frontend/src/components/timeline/PhotoThumbnail.tsx index 0583f93..22f669d 100644 --- a/frontend/src/components/timeline/PhotoThumbnail.tsx +++ b/frontend/src/components/timeline/PhotoThumbnail.tsx @@ -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 diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index bcfa7bf..68e4c85 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -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 +} + +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 => { + 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 => { + 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 {