User-facing labels and code now use "discard" (verb) and "Discarded"
(state/view label) instead of "trash" / "Trashed". The DB column names
stay (is_trashed / trashed_at) so no migration is required — only the
SQLAlchemy attribute names are renamed via Column('old_name', ...).
Backend
- Photo model: is_discarded / discarded_at attributes (DB columns
unchanged).
- PhotoBase / PhotoResponse / PhotoUpdate schemas use the new field
names.
- Photos list endpoint: is_discarded query param, filter logic.
- DELETE /photos/{id} now sets is_discarded; success message updated.
- Bulk action 'trash' renamed to 'discard'.
- backend/app/routers/trash.py renamed to discard.py with renamed
functions and route prefix /api/v1/discard.
- main.py imports and mounts the discard router.
- tasks/scan.py marks missing files as is_discarded.
Frontend
- Photo TS type: is_discarded.
- PhotoThumbnail: shows the trash-can icon when is_discarded.
- RightSidebar: button label "Discard"; mutation field name; local
variable rename.
- TopBar: discardPhotosMutation and "Discard" button; toast text
"Discarded".
- LeftSidebar: virtual node id 'discarded' / label "Discarded".
- FilterBar / filterStore / useFilterUrlSync: FlagFilter enum value
'trashed' → 'discarded'; backend param key is_discarded.
- KeyboardHints: X label "Discard".
- useKeyboardShortcuts: PhotoUpdate field rename, X handler.
- api.ts: /trash routes → /discard, trash export → discard,
bulkUpdate trash field → discard.
Out of scope (intentional): the docker-compose trash_data volume,
backend/Dockerfile mkdir /data/trash, config.py TrashSettings, and
the spec doc — all unused since soft-discard, and renaming them is
churn for no benefit.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
474 lines
16 KiB
Python
474 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.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)
|
|
|
|
# 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"
|
|
} |