Files
mule-image/backend/app/routers/photos.py
dtoro 7c003bc92e feat: editable filename in RightSidebar (renames file on disk)
The first phase-11 file operation. Lightroom-style inline rename
of a single photo, in place, in its current directory.

Backend (PATCH /photos/{id})
- PhotoUpdate schema accepts an optional `filename`.
- When set, the handler validates: non-empty, no path separators,
  no `..`/`.`, target name doesn't already exist in the directory,
  source file exists on disk.
- os.renames the file inside its current directory, then updates
  photo.filename + photo.filepath atomically. The DB only changes
  after a successful rename — a filesystem failure leaves the
  rest of the row untouched.
- Other PhotoUpdate fields still apply afterwards in the same
  request.

Frontend (RightSidebar)
- Filename is now an editable monospace input above the Title
  input. Same draft + commit pattern as title/notes (local draft,
  resync on photo.id change, on-blur or Enter commits).
- Esc reverts to the server value.
- Client-side validation mirrors the backend (rejects path
  separators and dot-segments) and shows a toast on backend
  errors with the FastAPI detail message, then rolls the draft
  back so the input matches the still-on-disk filename.
- Removed the old read-only Filename Field from the Basic Info
  section to avoid showing the same value twice.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:48:31 +02:00

526 lines
18 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.folders import SourceRoot
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_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 — the sidebar exposes "source roots" (top-level scan
# paths) under the same UI affordance as folders, so the same param has
# to accept either a folder id or a source root id. If the value matches
# a source root, expand to every folder under that root and use IN.
if folder_id:
sr_check = await db.execute(
select(SourceRoot.id).where(SourceRoot.id == folder_id)
)
if sr_check.scalar_one_or_none() is not None:
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:
# Source root with no folder rows yet — match nothing rather
# than returning the entire library.
filters.append(Photo.id == '__no_match__')
else:
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)
# 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. If `filename` is included, also rename the
file on disk in its current directory (no cross-folder moves through
this endpoint).
"""
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")
update_data = update.dict(exclude_unset=True)
# Filename rename: validate, rename on disk, then update both filename
# and filepath atomically. Done before any other field changes so a
# filesystem failure leaves the rest of the row untouched.
if 'filename' in update_data:
new_name = (update_data.pop('filename') or '').strip()
if not new_name:
raise HTTPException(status_code=400, detail="Filename cannot be empty")
# Reject path separators and parent traversal — same-directory only.
if '/' in new_name or '\\' in new_name or new_name in ('.', '..'):
raise HTTPException(status_code=400, detail="Invalid filename")
if new_name != photo.filename:
current_dir = os.path.dirname(photo.filepath)
new_path = os.path.join(current_dir, new_name)
if not os.path.exists(photo.filepath):
raise HTTPException(status_code=404, detail="Source file missing on disk")
if os.path.exists(new_path):
raise HTTPException(status_code=409, detail="A file with that name already exists")
try:
os.rename(photo.filepath, new_path)
except OSError as e:
logger.error(f"Failed to rename {photo.filepath} -> {new_path}: {e}")
raise HTTPException(status_code=500, detail=f"Rename failed: {e}")
photo.filename = new_name
photo.filepath = new_path
# Apply remaining updates
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
else:
raise HTTPException(status_code=400, detail="Invalid action")
await db.commit()
return {
"status": "success",
"message": f"{action.action} applied to {len(photos)} photos"
}