""" 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 pydantic import BaseModel from sqlalchemy import select, and_, or_, func from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload import json import os import logging logger = logging.getLogger(__name__) from app.database import get_db from app.models import Photo, Folder, Tag from app.models.folders import SourceRoot from app.models.heaps import heap_photos from app.models.tags import photo_tags from app.schemas.photos import PhotoResponse, PhotoUpdate, PhotoListResponse, BulkAction from app.config import settings router = APIRouter() @router.get("") 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_discarded: Optional[bool] = False, is_duplicate: Optional[bool] = None, 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 — eager-load tags so the response can include them # without an N+1 round-trip per photo. query = select(Photo).options(selectinload(Photo.tags)) # 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. The sidebar can pass either a SourceRoot id or a # Folder id; both should include descendants so clicking a parent # folder shows everything under it (Lightroom semantics). if folder_id: sr_check = await db.execute( select(SourceRoot).where(SourceRoot.id == folder_id) ) sr_row = sr_check.scalar_one_or_none() if sr_row is not None: # Source root → all folders under it (any depth). child_folders = await db.execute( select(Folder.id).where(Folder.source_root_id == folder_id) ) child_ids = [row[0] for row in child_folders.all()] if child_ids: filters.append(Photo.folder_id.in_(child_ids)) else: filters.append(Photo.id == '__no_match__') else: # Folder id → that folder + every descendant by path prefix. target_check = await db.execute( select(Folder).where(Folder.id == folder_id) ) target = target_check.scalar_one_or_none() if target is None: filters.append(Photo.id == '__no_match__') else: target_path = os.path.normpath(target.path).rstrip(os.sep) desc_result = await db.execute( select(Folder.id).where( (Folder.path == target_path) | (Folder.path.like(target_path + os.sep + '%')) ) ) desc_ids = [row[0] for row in desc_result.all()] filters.append(Photo.folder_id.in_(desc_ids)) # 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) # Discard filter — defaults to hiding discarded photos filters.append(Photo.is_discarded == is_discarded) # Duplicate filter — only applied when explicitly set, so the default # view shows everything regardless of duplicate status. if is_duplicate is not None: filters.append(Photo.is_duplicate == is_duplicate) # Heap membership filter — restrict to photos that belong to the heap. if heap_id: filters.append( Photo.id.in_( select(heap_photos.c.photo_id).where(heap_photos.c.heap_id == heap_id) ) ) # Tag filter — comma-separated tag ids, AND semantics. A photo must # have a row in photo_tags for EVERY listed tag. Implemented as a # single GROUP BY ... HAVING COUNT(DISTINCT) = N subquery so the cost # is independent of the number of tags being filtered. if tag_ids: tag_id_list = [t.strip() for t in tag_ids.split(',') if t.strip()] if tag_id_list: matching_photos = ( select(photo_tags.c.photo_id) .where(photo_tags.c.tag_id.in_(tag_id_list)) .group_by(photo_tags.c.photo_id) .having( func.count(func.distinct(photo_tags.c.tag_id)) == len(tag_id_list) ) ) filters.append(Photo.id.in_(matching_photos)) # Apply all filters if filters: query = query.where(and_(*filters)) # Apply sorting. The sort field is whitelisted so a malicious client # can't pass an arbitrary column name (e.g. "filepath" leaks paths or # any other Photo attribute the model exposes). SORT_WHITELIST = { "taken_at": Photo.taken_at, "added_at": Photo.added_at, "filename": Photo.filename, "file_size": Photo.file_size, "rating": Photo.rating, } sort_column = SORT_WHITELIST.get(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, attaching tags inline so the frontend can group # client-side without a second round-trip. photo_dicts = [] for photo in photos: d = PhotoResponse.from_orm(photo).dict() d["tags"] = [ {"id": t.id, "name": t.name, "color": t.color} for t in (photo.tags or []) ] photo_dicts.append(d) return { "photos": photo_dicts, "total": total, "page": page, "per_page": per_page, "pages": (total + per_page - 1) // per_page if total else 0, } @router.get("/{photo_id}") async def get_photo( photo_id: str, db: AsyncSession = Depends(get_db) ): """Get single photo with full EXIF and its 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") # Fetch tags via the join table so we don't need to declare a # relationship on the Photo model side. tag_result = await db.execute( select(Tag) .join(photo_tags, Tag.id == photo_tags.c.tag_id) .where(photo_tags.c.photo_id == photo_id) .order_by(Tag.name.asc()) ) tags = tag_result.scalars().all() base = PhotoResponse.from_orm(photo).dict() base["tags"] = [ {"id": t.id, "name": t.name, "color": t.color} for t in tags ] return base @router.post("/{photo_id}/tags", status_code=201) async def add_photo_tags( photo_id: str, body: dict, db: AsyncSession = Depends(get_db), ): """Add one or more tags to a photo. Body: { tag_ids: [str, ...] }. Idempotent: re-adding existing members is a no-op.""" photo_result = await db.execute(select(Photo).where(Photo.id == photo_id)) if photo_result.scalar_one_or_none() is None: raise HTTPException(status_code=404, detail="Photo not found") tag_ids = body.get("tag_ids") or [] if not isinstance(tag_ids, list) or not tag_ids: return {"status": "success", "added": 0} existing = await db.execute( select(photo_tags.c.tag_id).where( photo_tags.c.photo_id == photo_id, photo_tags.c.tag_id.in_(tag_ids), ) ) existing_ids = {row[0] for row in existing.all()} new_ids = [tid for tid in tag_ids if tid not in existing_ids] if new_ids: from sqlalchemy import insert await db.execute( insert(photo_tags), [{"photo_id": photo_id, "tag_id": tid} for tid in new_ids], ) await db.commit() return {"status": "success", "added": len(new_ids)} @router.delete("/{photo_id}/tags/{tag_id}", status_code=204) async def remove_photo_tag( photo_id: str, tag_id: str, db: AsyncSession = Depends(get_db), ): """Remove a tag from a photo. Removing a non-member is a no-op.""" from sqlalchemy import delete as sql_delete await db.execute( sql_delete(photo_tags).where( photo_tags.c.photo_id == photo_id, photo_tags.c.tag_id == tag_id, ) ) await db.commit() return None @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): # Queue background generation (handles RAW/HEIC/video properly) from app.tasks.thumbs import generate_thumbnails generate_thumbnails.delay(photo_id) # 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) 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 HTTPException: raise except Exception as e: 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 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 (download for RAW, inline for web-safe formats)""" 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") # Pick a media type the browser can render inline for web-safe formats # so the loupe view and