Adds the spec §6.10 heaps concept: named photo collections with a
single "active" target for fast keyboard adds. Uses a basket icon
(ShoppingBasket) to visually distinguish heaps from folders.
Backend (routers/heaps.py)
- Replaces the 27-line stub with full CRUD: list (with photo counts
via a single LEFT JOIN), create, patch (rename + set active), delete.
- Add/remove photos endpoints with idempotent semantics: re-adding an
existing member is a no-op, removing a non-member is a no-op.
- Setting is_active=true on one heap clears the flag on every other
heap in a single UPDATE so we maintain the single-active invariant.
- routers/photos.py list endpoint now applies the heap_id filter via
IN-subquery against heap_photos (it was a declared param but had
no filter logic).
Frontend
- New hooks/useHeapsQuery.ts and useFilterUrlSync wires heap_id as
another URL-persisted filter; usePhotosQuery threads it through.
- New components/heaps/HeapsPanel.tsx replaces the LeftSidebar Heaps
stub. Shows the basket icon, photo counts, lets you create heaps
inline, click to filter the timeline, set active via the target
icon, and delete heaps.
- TopBar shows an "active heap" pill (basket + name) so the user
always knows where the next T-press will land.
- KeyboardHints adds T → Add to heap.
T shortcut (useKeyboardShortcuts)
- Reads the active heap from the heaps query cache and the selection
from the photo store at fire time. Adds the selected photos (or the
active photo if nothing is selected) via POST /heaps/{id}/photos.
- Toasts:
- "Added to {heap}: N photos (M already present)" on success
- "No active heap" hint when none is set
- "Nothing selected" hint when there's no selection or active photo
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
483 lines
16 KiB
Python
483 lines
16 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.models.heaps import heap_photos
|
|
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_discarded: 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)
|
|
|
|
# Discard filter — defaults to hiding discarded photos
|
|
filters.append(Photo.is_discarded == is_discarded)
|
|
|
|
# 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)
|
|
)
|
|
)
|
|
|
|
# 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 (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 <video> tags work without forcing a download.
|
|
ext = Path(photo.filepath).suffix.lower()
|
|
inline_types = {
|
|
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
'.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif',
|
|
'.mp4': 'video/mp4', '.mov': 'video/quicktime',
|
|
'.webm': 'video/webm', '.mkv': 'video/x-matroska',
|
|
}
|
|
media_type = inline_types.get(ext, 'application/octet-stream')
|
|
|
|
return FileResponse(
|
|
photo.filepath,
|
|
filename=photo.filename if media_type == 'application/octet-stream' else None,
|
|
media_type=media_type,
|
|
)
|
|
|
|
|
|
# Extensions that the browser can decode natively. Anything else (RAW, HEIC,
|
|
# TIFF) needs the /proxy endpoint to convert to WebP for display.
|
|
_WEB_SAFE_DISPLAY_EXTS = {'.jpg', '.jpeg', '.png', '.webp', '.gif'}
|
|
|
|
|
|
def _generate_proxy_webp(src_path: str, dst_path: str) -> None:
|
|
"""Decode src_path with the appropriate backend and write a full-res WebP
|
|
to dst_path. Used by GET /photos/{id}/proxy for RAW/HEIC/TIFF display.
|
|
|
|
Conservative: catches per-format failures and falls back to extracting an
|
|
embedded preview where possible (RAW), so a single broken file never
|
|
crashes the request.
|
|
"""
|
|
from PIL import Image
|
|
ext = Path(src_path).suffix.lower()
|
|
|
|
img = None
|
|
|
|
# RAW formats — decode via rawpy at full size
|
|
raw_exts = {'.cr2', '.cr3', '.nef', '.nrw', '.arw', '.srf',
|
|
'.raf', '.rw2', '.orf', '.srw', '.pef', '.rwl', '.dng'}
|
|
if ext in raw_exts:
|
|
try:
|
|
import rawpy
|
|
with rawpy.imread(src_path) as raw:
|
|
rgb = raw.postprocess(use_camera_wb=True, no_auto_bright=False)
|
|
img = Image.fromarray(rgb, 'RGB')
|
|
except Exception as e:
|
|
logger.warning(f"rawpy decode failed for {src_path}: {e}; trying embedded preview")
|
|
try:
|
|
import rawpy
|
|
with rawpy.imread(src_path) as raw:
|
|
thumb = raw.extract_thumb()
|
|
if thumb.format == rawpy.ThumbFormat.JPEG:
|
|
from io import BytesIO
|
|
img = Image.open(BytesIO(thumb.data))
|
|
except Exception as e2:
|
|
logger.error(f"RAW preview extraction also failed for {src_path}: {e2}")
|
|
raise HTTPException(status_code=415, detail="Unable to decode RAW file")
|
|
|
|
# HEIC/HEIF — pillow-heif registers a PIL plugin
|
|
elif ext in {'.heic', '.heif'}:
|
|
try:
|
|
from pillow_heif import register_heif_opener
|
|
register_heif_opener()
|
|
img = Image.open(src_path)
|
|
except Exception as e:
|
|
logger.error(f"HEIC decode failed for {src_path}: {e}")
|
|
raise HTTPException(status_code=415, detail="Unable to decode HEIC file")
|
|
|
|
# TIFF and any other PIL-supported format
|
|
else:
|
|
try:
|
|
img = Image.open(src_path)
|
|
except Exception as e:
|
|
logger.error(f"PIL open failed for {src_path}: {e}")
|
|
raise HTTPException(status_code=415, detail="Unable to decode image")
|
|
|
|
# Auto-rotate via EXIF
|
|
try:
|
|
from PIL import ImageOps
|
|
img = ImageOps.exif_transpose(img)
|
|
except Exception:
|
|
pass
|
|
|
|
if img.mode not in ('RGB', 'RGBA'):
|
|
img = img.convert('RGB')
|
|
|
|
os.makedirs(os.path.dirname(dst_path), exist_ok=True)
|
|
img.save(dst_path, 'WEBP', quality=90, method=4)
|
|
|
|
|
|
@router.get("/{photo_id}/proxy")
|
|
async def get_proxy(
|
|
photo_id: str,
|
|
response: Response,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Serve a full-resolution WebP proxy for non-web-safe formats (RAW, HEIC,
|
|
TIFF) so the loupe view can display them inline. Web-safe formats are
|
|
redirected to /original to avoid pointless transcoding.
|
|
|
|
Cached at /data/proxies/{photo_id}.webp; subsequent requests serve the
|
|
cached file (with optional X-Accel-Redirect for production).
|
|
"""
|
|
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")
|
|
|
|
ext = Path(photo.filepath).suffix.lower()
|
|
|
|
# Web-safe formats don't need a proxy — serve the original directly so the
|
|
# browser uses its native decoder. Saves disk and CPU.
|
|
if ext in _WEB_SAFE_DISPLAY_EXTS:
|
|
return FileResponse(
|
|
photo.filepath,
|
|
media_type={
|
|
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
'.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif',
|
|
}[ext],
|
|
)
|
|
|
|
proxy_dir = "/data/proxies"
|
|
proxy_path = f"{proxy_dir}/{photo_id}.webp"
|
|
|
|
if not os.path.exists(proxy_path):
|
|
try:
|
|
_generate_proxy_webp(photo.filepath, proxy_path)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Proxy generation failed for {photo_id}: {e}")
|
|
raise HTTPException(status_code=500, detail="Proxy generation failed")
|
|
|
|
if os.environ.get('USE_X_ACCEL_REDIRECT'):
|
|
response.headers['X-Accel-Redirect'] = f'/internal_proxies/{photo_id}.webp'
|
|
response.headers['Content-Type'] = 'image/webp'
|
|
return Response()
|
|
|
|
return FileResponse(proxy_path, media_type='image/webp')
|
|
|
|
@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 discard_photo(
|
|
photo_id: str,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Soft-discard a photo: sets is_discarded=true. The file stays on disk so
|
|
restore is just a flag flip. Permanent deletion happens via DELETE
|
|
/discard/{id} or DELETE /discard/empty.
|
|
"""
|
|
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")
|
|
|
|
photo.is_discarded = True
|
|
photo.discarded_at = datetime.utcnow()
|
|
await db.commit()
|
|
|
|
return {"status": "success", "message": "Photo discarded"}
|
|
|
|
@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 == 'discard':
|
|
for photo in photos:
|
|
photo.is_discarded = True
|
|
photo.discarded_at = datetime.utcnow()
|
|
elif action.action == 'restore':
|
|
for photo in photos:
|
|
photo.is_discarded = False
|
|
photo.discarded_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_discarded = 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"
|
|
} |