- 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>
266 lines
8.8 KiB
Python
266 lines
8.8 KiB
Python
"""
|
|
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 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
|
|
matches the filter the sidebar applies when you click it, so the
|
|
sidebar badges and the timeline below them stay in sync.
|
|
|
|
- all_photos: non-discarded photos + videos (matches the All
|
|
Photos section's default filter)
|
|
- rated: non-discarded with rating >= 1
|
|
- duplicates: non-discarded with is_duplicate = true
|
|
- discarded: is_discarded = true
|
|
- total_size: raw bytes across every row, including discarded
|
|
"""
|
|
not_discarded = Photo.is_discarded.is_(False)
|
|
|
|
all_photos_count = (
|
|
await db.execute(select(func.count(Photo.id)).where(not_discarded))
|
|
).scalar() or 0
|
|
|
|
rated_count = (
|
|
await db.execute(
|
|
select(func.count(Photo.id)).where(not_discarded, Photo.rating >= 1)
|
|
)
|
|
).scalar() or 0
|
|
|
|
duplicates_count = (
|
|
await db.execute(
|
|
select(func.count(Photo.id)).where(
|
|
not_discarded, Photo.is_duplicate.is_(True)
|
|
)
|
|
)
|
|
).scalar() or 0
|
|
|
|
discarded_count = (
|
|
await db.execute(
|
|
select(func.count(Photo.id)).where(Photo.is_discarded.is_(True))
|
|
)
|
|
).scalar() or 0
|
|
|
|
# Legacy split (kept for the existing /stats consumers).
|
|
photo_count = (
|
|
await db.execute(
|
|
select(func.count(Photo.id)).where(
|
|
Photo.media_type.in_(['photo', 'heic', 'raw'])
|
|
)
|
|
)
|
|
).scalar() or 0
|
|
video_count = (
|
|
await db.execute(
|
|
select(func.count(Photo.id)).where(Photo.media_type == 'video')
|
|
)
|
|
).scalar() or 0
|
|
|
|
size = (await db.execute(select(func.sum(Photo.file_size)))).scalar() or 0
|
|
|
|
return {
|
|
"all_photos": all_photos_count,
|
|
"rated": rated_count,
|
|
"duplicates": duplicates_count,
|
|
"discarded": discarded_count,
|
|
"total_photos": photo_count,
|
|
"total_videos": video_count,
|
|
"total_size": size,
|
|
"total_size_gb": round(size / (1024**3), 2) if size else 0,
|
|
}
|
|
|
|
@router.post("/scan")
|
|
async def trigger_scan():
|
|
"""Trigger full library re-scan"""
|
|
from app.tasks.scan import scan_all_source_roots
|
|
|
|
scan_all_source_roots.delay()
|
|
|
|
return {"status": "success", "message": "Library scan started"}
|
|
|
|
@router.get("/scan/status")
|
|
async def get_scan_status(db: AsyncSession = Depends(get_db)):
|
|
"""Get current scan status"""
|
|
import redis
|
|
from app.config import settings
|
|
|
|
# Connect to Redis to get scan status
|
|
r = redis.Redis.from_url(settings.redis_url)
|
|
|
|
# Get scan status from Redis (set by worker tasks)
|
|
is_scanning = r.get('scan:active') == b'true'
|
|
current_folder = r.get('scan:current_folder')
|
|
processed_files = int(r.get('scan:processed_files') or 0)
|
|
total_files = int(r.get('scan:total_files') or 0)
|
|
errors = r.lrange('scan:errors', 0, -1)
|
|
|
|
return {
|
|
"is_scanning": is_scanning,
|
|
"current_folder": current_folder.decode() if current_folder else None,
|
|
"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)} |