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"""