354 lines
11 KiB
Python
354 lines
11 KiB
Python
"""
|
|
Photos API router
|
|
"""
|
|
from typing import List, Optional, Dict, Any
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
|
from fastapi.responses import FileResponse
|
|
from sqlalchemy import select, and_, or_, func
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
import json
|
|
import os
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
from app.database import get_db
|
|
from app.models import Photo, Folder, Tag, PhotoTag
|
|
from app.schemas.photos import PhotoResponse, PhotoUpdate, PhotoListResponse, BulkAction
|
|
from app.config import settings
|
|
|
|
router = APIRouter()
|
|
|
|
@router.get("", response_model=PhotoListResponse)
|
|
async def list_photos(
|
|
q: Optional[str] = None,
|
|
date_from: Optional[datetime] = None,
|
|
date_to: Optional[datetime] = None,
|
|
folder_id: Optional[str] = None,
|
|
tag_ids: Optional[str] = None,
|
|
media_type: Optional[str] = None,
|
|
rating_min: Optional[int] = Query(None, ge=0, le=5),
|
|
rating_max: Optional[int] = Query(None, ge=0, le=5),
|
|
color_label: Optional[str] = None,
|
|
is_picked: Optional[bool] = None,
|
|
is_rejected: Optional[bool] = None,
|
|
is_trashed: Optional[bool] = False,
|
|
heap_id: Optional[str] = None,
|
|
sort: str = "taken_at",
|
|
order: str = "desc",
|
|
page: int = Query(1, ge=1),
|
|
per_page: int = Query(100, ge=1, le=500),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""List photos with filters and pagination"""
|
|
|
|
# Build query
|
|
query = select(Photo)
|
|
|
|
# Apply filters
|
|
filters = []
|
|
|
|
# Text search (would use FTS5 in production)
|
|
if q:
|
|
search_pattern = f"%{q}%"
|
|
filters.append(
|
|
or_(
|
|
Photo.filename.ilike(search_pattern),
|
|
Photo.user_title.ilike(search_pattern),
|
|
Photo.user_notes.ilike(search_pattern),
|
|
Photo.exif_json.ilike(search_pattern)
|
|
)
|
|
)
|
|
|
|
# Date range
|
|
if date_from:
|
|
filters.append(Photo.taken_at >= date_from)
|
|
if date_to:
|
|
filters.append(Photo.taken_at <= date_to)
|
|
|
|
# Folder filter
|
|
if folder_id:
|
|
filters.append(Photo.folder_id == folder_id)
|
|
|
|
# Media type filter
|
|
if media_type:
|
|
types = media_type.split(',')
|
|
filters.append(Photo.media_type.in_(types))
|
|
|
|
# Rating filter
|
|
if rating_min is not None:
|
|
filters.append(Photo.rating >= rating_min)
|
|
if rating_max is not None:
|
|
filters.append(Photo.rating <= rating_max)
|
|
|
|
# Color label filter
|
|
if color_label:
|
|
if color_label == 'none':
|
|
filters.append(Photo.color_label.is_(None))
|
|
else:
|
|
filters.append(Photo.color_label == color_label)
|
|
|
|
# Flag filters
|
|
if is_picked is not None:
|
|
filters.append(Photo.is_picked == is_picked)
|
|
if is_rejected is not None:
|
|
filters.append(Photo.is_rejected == is_rejected)
|
|
|
|
# Trash filter
|
|
filters.append(Photo.is_trashed == is_trashed)
|
|
|
|
# Apply all filters
|
|
if filters:
|
|
query = query.where(and_(*filters))
|
|
|
|
# Apply sorting
|
|
sort_column = getattr(Photo, sort, Photo.taken_at)
|
|
if order == "desc":
|
|
query = query.order_by(sort_column.desc())
|
|
else:
|
|
query = query.order_by(sort_column.asc())
|
|
|
|
# Count total results
|
|
count_query = select(func.count()).select_from(query.subquery())
|
|
total_result = await db.execute(count_query)
|
|
total = total_result.scalar()
|
|
|
|
# Apply pagination
|
|
offset = (page - 1) * per_page
|
|
query = query.offset(offset).limit(per_page)
|
|
|
|
# Execute query
|
|
result = await db.execute(query)
|
|
photos = result.scalars().all()
|
|
|
|
# Convert to response
|
|
return PhotoListResponse(
|
|
photos=[PhotoResponse.from_orm(photo) for photo in photos],
|
|
total=total,
|
|
page=page,
|
|
per_page=per_page,
|
|
pages=(total + per_page - 1) // per_page
|
|
)
|
|
|
|
@router.get("/{photo_id}", response_model=PhotoResponse)
|
|
async def get_photo(
|
|
photo_id: str,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Get single photo with full EXIF and tags"""
|
|
result = await db.execute(
|
|
select(Photo).where(Photo.id == photo_id)
|
|
)
|
|
photo = result.scalar_one_or_none()
|
|
|
|
if not photo:
|
|
raise HTTPException(status_code=404, detail="Photo not found")
|
|
|
|
return PhotoResponse.from_orm(photo)
|
|
|
|
@router.get("/{photo_id}/thumb/{size}")
|
|
async def get_thumbnail(
|
|
photo_id: str,
|
|
size: str,
|
|
response: Response,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Serve thumbnail (with Nginx X-Accel-Redirect support)"""
|
|
if size not in ['small', 'medium', 'large']:
|
|
raise HTTPException(status_code=400, detail="Invalid thumbnail size")
|
|
|
|
result = await db.execute(
|
|
select(Photo).where(Photo.id == photo_id)
|
|
)
|
|
photo = result.scalar_one_or_none()
|
|
|
|
if not photo:
|
|
raise HTTPException(status_code=404, detail="Photo not found")
|
|
|
|
# Check if thumbnail exists, generate if not
|
|
thumb_dir = f"/data/thumbs/{photo_id}"
|
|
thumb_path = f"{thumb_dir}/{size}.webp"
|
|
|
|
if not os.path.exists(thumb_path):
|
|
# Generate thumbnail on demand
|
|
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):
|
|
from PIL import Image
|
|
try:
|
|
os.makedirs(thumb_dir, exist_ok=True)
|
|
img = Image.open(photo.filepath)
|
|
|
|
# Auto-rotate based on EXIF
|
|
from PIL import ExifTags
|
|
try:
|
|
for orientation in ExifTags.TAGS.keys():
|
|
if ExifTags.TAGS[orientation] == 'Orientation':
|
|
break
|
|
exif = img._getexif()
|
|
if exif is not None:
|
|
orient = exif.get(orientation)
|
|
if orient == 3:
|
|
img = img.rotate(180, expand=True)
|
|
elif orient == 6:
|
|
img = img.rotate(270, expand=True)
|
|
elif orient == 8:
|
|
img = img.rotate(90, expand=True)
|
|
except:
|
|
pass
|
|
|
|
# Generate thumbnail size
|
|
sizes = {'small': 150, 'medium': 400, 'large': 800}
|
|
target_size = sizes.get(size, 400)
|
|
img.thumbnail((target_size, target_size), Image.Resampling.LANCZOS)
|
|
|
|
# Save as WebP
|
|
img.save(thumb_path, 'WEBP', quality=85, optimize=True)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error generating thumbnail: {e}")
|
|
raise HTTPException(status_code=404, detail="Could not generate thumbnail")
|
|
|
|
# Check if we're behind Nginx
|
|
if os.environ.get('USE_X_ACCEL_REDIRECT'):
|
|
# Use Nginx X-Accel-Redirect for better performance
|
|
response.headers['X-Accel-Redirect'] = f'/internal_thumbs/{photo_id}/{size}.webp'
|
|
response.headers['Content-Type'] = 'image/webp'
|
|
return Response()
|
|
else:
|
|
# Direct file serving for development
|
|
return FileResponse(thumb_path, media_type='image/webp')
|
|
|
|
@router.get("/{photo_id}/original")
|
|
async def get_original(
|
|
photo_id: str,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Serve original file for download"""
|
|
result = await db.execute(
|
|
select(Photo).where(Photo.id == photo_id)
|
|
)
|
|
photo = result.scalar_one_or_none()
|
|
|
|
if not photo:
|
|
raise HTTPException(status_code=404, detail="Photo not found")
|
|
|
|
if not os.path.exists(photo.filepath):
|
|
raise HTTPException(status_code=404, detail="File not found")
|
|
|
|
return FileResponse(
|
|
photo.filepath,
|
|
filename=photo.filename,
|
|
media_type='application/octet-stream'
|
|
)
|
|
|
|
@router.patch("/{photo_id}", response_model=PhotoResponse)
|
|
async def update_photo(
|
|
photo_id: str,
|
|
update: PhotoUpdate,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Update photo metadata"""
|
|
result = await db.execute(
|
|
select(Photo).where(Photo.id == photo_id)
|
|
)
|
|
photo = result.scalar_one_or_none()
|
|
|
|
if not photo:
|
|
raise HTTPException(status_code=404, detail="Photo not found")
|
|
|
|
# Apply updates
|
|
update_data = update.dict(exclude_unset=True)
|
|
for field, value in update_data.items():
|
|
setattr(photo, field, value)
|
|
|
|
await db.commit()
|
|
await db.refresh(photo)
|
|
|
|
return PhotoResponse.from_orm(photo)
|
|
|
|
@router.delete("/{photo_id}")
|
|
async def trash_photo(
|
|
photo_id: str,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Move photo to trash"""
|
|
result = await db.execute(
|
|
select(Photo).where(Photo.id == photo_id)
|
|
)
|
|
photo = result.scalar_one_or_none()
|
|
|
|
if not photo:
|
|
raise HTTPException(status_code=404, detail="Photo not found")
|
|
|
|
# Move file to trash directory
|
|
import shutil
|
|
trash_dir = f"{settings.trash.path}/{photo_id}"
|
|
os.makedirs(trash_dir, exist_ok=True)
|
|
|
|
trash_path = f"{trash_dir}/original{Path(photo.filepath).suffix}"
|
|
|
|
try:
|
|
shutil.move(photo.filepath, trash_path)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Failed to move file: {e}")
|
|
|
|
# Update database
|
|
photo.is_trashed = True
|
|
photo.trashed_at = datetime.utcnow()
|
|
|
|
await db.commit()
|
|
|
|
return {"status": "success", "message": "Photo moved to trash"}
|
|
|
|
@router.post("/bulk")
|
|
async def bulk_action(
|
|
action: BulkAction,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Perform bulk actions on multiple photos"""
|
|
# Get photos
|
|
result = await db.execute(
|
|
select(Photo).where(Photo.id.in_(action.ids))
|
|
)
|
|
photos = result.scalars().all()
|
|
|
|
if not photos:
|
|
raise HTTPException(status_code=404, detail="No photos found")
|
|
|
|
# Perform action based on type
|
|
if action.action == 'trash':
|
|
for photo in photos:
|
|
photo.is_trashed = True
|
|
photo.trashed_at = datetime.utcnow()
|
|
elif action.action == 'restore':
|
|
for photo in photos:
|
|
photo.is_trashed = False
|
|
photo.trashed_at = None
|
|
elif action.action == 'set_rating':
|
|
for photo in photos:
|
|
photo.rating = action.value
|
|
elif action.action == 'set_color':
|
|
for photo in photos:
|
|
photo.color_label = action.value
|
|
elif action.action == 'pick':
|
|
for photo in photos:
|
|
photo.is_picked = True
|
|
photo.is_rejected = False
|
|
elif action.action == 'reject':
|
|
for photo in photos:
|
|
photo.is_rejected = True
|
|
photo.is_picked = False
|
|
else:
|
|
raise HTTPException(status_code=400, detail="Invalid action")
|
|
|
|
await db.commit()
|
|
|
|
return {
|
|
"status": "success",
|
|
"message": f"{action.action} applied to {len(photos)} photos"
|
|
} |