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