Files
mule-image/backend/app/routers/photos.py
root 339e1be510 feat: hide-from-views flag on folders
Adds a per-folder "hide from views" toggle so noisy subtrees
(screenshots, WhatsApp dumps, work archives) can be excluded from
cross-cutting views without losing indexing. Photos under a hidden
folder are still scanned, thumbnailed, embedded, OCR'd, face-
extracted — they just stop appearing in All Photos, Rated, Colors,
Map, Tags, People, Search, Duplicates, and the sidebar counts.
Navigating directly into the folder still shows every photo.

Schema (migration 0007_folder_hidden):
- folders.is_hidden   user-set toggle, default false
- photos.is_hidden    denormalized effective flag (true iff any
                      ancestor folder is hidden), indexed so cross-
                      cutting queries stay on the existing planner
                      paths

The denorm is maintained by two paths:
- The scanner walks the ancestry chain on insert, with a per-scan
  memoized cache so each folder is resolved once per scan.
- POST /api/v1/folders/{id}/hide flips folders.is_hidden and runs a
  WITH RECURSIVE CTE to recompute every folder's effective state in
  one query, then bulk-updates photos WHERE IS DISTINCT FROM. Runs
  in ~10 ms on a 13k-photo library.

Filters added (cross-cutting queries):
- /library/stats — every sidebar badge via a shared `visible` filter
- /photos (list) — only when neither folder_id nor heap_id is set;
  folder browse and heap browse always show everything
- /photos/map
- /library/duplicates/groups
- /folders/tree photo_count subquery
- /tags count_subq (drives Tags + People sidebar counts)
- services/duplicates.regroup_duplicates (so hidden dupes never
  contaminate the Duplicates view)
- services/search.hybrid_search — both semantic (pgvector) and FTS
  legs join photos so rankings don't include hidden results

Intentionally NOT filtered:
- /photos?folder_id=X and /photos?heap_id=X (user-intentional browse)
- /library/maintenance/pipeline-stats (tracks real worker state)
- cleanup service (disk-level ops, not views)

Frontend:
- sourceFolders.setHidden(id, hidden) API client method
- FolderTreeNode.is_hidden carried through the tree into TreeItem
- LeftSidebar kebab menu: "Hide from views" / "Show in views" with a
  mutation that invalidates folders, photos, stats, and tags caches
- Hidden folder rows swap the Folder icon for EyeOff and render the
  label italic/muted so the state is visible at a glance

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 10:55:25 +02:00

980 lines
34 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 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)
# Hidden-folder filter. Photos in folders the user has marked
# "hidden from views" (or any descendant of one) are excluded from
# every cross-cutting listing — All Photos, Rated, Colors, Tags,
# People, Map, search, etc. We only apply the filter when the
# request isn't already scoped to a user-intentional collection:
# - folder_id set: the user is explicitly browsing that folder,
# which is precisely how hidden folders are "opened" again.
# - heap_id set: heaps are hand-curated. If the user added a
# photo to a heap and later hid its folder, the heap still
# reflects their explicit pick.
if not folder_id and not heap_id:
filters.append(Photo.is_hidden.is_(False))
# 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("/map")
async def list_photos_with_gps(db: AsyncSession = Depends(get_db)):
"""Lightweight listing of every non-discarded photo that has GPS
coordinates, used by the Map view. Intentionally returns a flat list
(no pagination) with only the fields the map renderer needs, so even
large libraries serialize to a few MB at most. Declared *before*
``/{photo_id}`` so the literal path wins the FastAPI route match."""
result = await db.execute(
select(
Photo.id,
Photo.latitude,
Photo.longitude,
Photo.taken_at,
).where(
Photo.is_discarded.is_(False),
Photo.is_hidden.is_(False),
Photo.latitude.is_not(None),
Photo.longitude.is_not(None),
)
)
return [
{
"id": row.id,
"latitude": row.latitude,
"longitude": row.longitude,
"taken_at": row.taken_at.isoformat() if row.taken_at else None,
}
for row in result.all()
]
@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 <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"}
class MoveRequest(BaseModel):
photo_ids: list[str]
target_id: str # folder id OR source root id
class CopyRequest(BaseModel):
photo_ids: list[str]
target_id: str # folder id OR source root id
@router.post("/copy")
async def copy_photos(
body: CopyRequest,
db: AsyncSession = Depends(get_db),
):
"""Copy photos into a target folder. Same target resolution as /move
(folder id or source root id), but uses shutil.copy2 and creates new
Photo rows for each copied file. Original photos are unaffected.
Each new row gets is_duplicate=true so the user can spot the
duplicates later. The new file's name is suffixed with " (copy)" if
a name collision would otherwise happen, and " (copy 2)", etc., for
further conflicts.
"""
import shutil
# Resolve target_id → (target_dir, target_folder)
sr_check = await db.execute(
select(SourceRoot).where(SourceRoot.id == body.target_id)
)
source_root = sr_check.scalar_one_or_none()
if source_root is not None:
target_dir = source_root.path
from app.tasks.scan import get_or_create_folder
target_folder = await get_or_create_folder(db, target_dir, source_root.id)
else:
folder_check = await db.execute(
select(Folder).where(Folder.id == body.target_id)
)
target_folder = folder_check.scalar_one_or_none()
if target_folder is None:
raise HTTPException(status_code=404, detail="Target folder not found")
target_dir = target_folder.path
if not os.path.isdir(target_dir):
raise HTTPException(
status_code=400,
detail=f"Target directory does not exist: {target_dir}",
)
if not body.photo_ids:
return {"status": "success", "copied": 0, "errors": []}
photos_result = await db.execute(
select(Photo).where(Photo.id.in_(body.photo_ids))
)
photos_to_copy = photos_result.scalars().all()
copied = 0
errors: list[dict] = []
def _unique_target_name(directory: str, filename: str) -> Optional[str]:
"""Find a non-colliding filename in `directory` based on `filename`,
suffixing " (copy)", " (copy 2)", ... if needed. Gives up after 100
attempts."""
if not os.path.exists(os.path.join(directory, filename)):
return filename
stem, ext = os.path.splitext(filename)
for i in range(1, 100):
candidate = f"{stem} (copy{'' if i == 1 else f' {i}'}){ext}"
if not os.path.exists(os.path.join(directory, candidate)):
return candidate
return None
for photo in photos_to_copy:
if not os.path.exists(photo.filepath):
errors.append({"id": photo.id, "error": "source file missing"})
continue
new_name = _unique_target_name(target_dir, photo.filename)
if new_name is None:
errors.append({"id": photo.id, "error": "too many name collisions"})
continue
new_path = os.path.join(target_dir, new_name)
try:
shutil.copy2(photo.filepath, new_path)
except OSError as e:
errors.append({"id": photo.id, "error": str(e)})
continue
# Create a new Photo row pointing at the copy. Most metadata is
# copied verbatim; the file_hash stays so the duplicate flag does
# the right thing across the library.
new_photo = Photo(
filepath=new_path,
filename=new_name,
folder_id=target_folder.id,
file_hash=photo.file_hash,
media_type=photo.media_type,
original_format=photo.original_format,
width=photo.width,
height=photo.height,
file_size=photo.file_size,
taken_at=photo.taken_at,
taken_at_source=photo.taken_at_source,
user_title=photo.user_title,
user_notes=photo.user_notes,
rating=photo.rating,
color_label=photo.color_label,
exif_json=photo.exif_json,
is_duplicate=True,
processing_status='pending',
)
db.add(new_photo)
copied += 1
await db.commit()
return {
"status": "success",
"copied": copied,
"errors": errors,
}
@router.post("/move")
async def move_photos(
body: MoveRequest,
db: AsyncSession = Depends(get_db),
):
"""Move photos into a target folder. The target can be either a Folder
id or a SourceRoot id (since the LeftSidebar only exposes source roots
today). The handler resolves the target to an on-disk directory, calls
shutil.move for each photo, and updates photo.filepath + folder_id.
Per-file failures (target name collision, missing source) are collected
and returned in the response so a single bad photo doesn't abort the
batch.
"""
import shutil
# Resolve target_id → (target_dir, target_folder)
sr_check = await db.execute(
select(SourceRoot).where(SourceRoot.id == body.target_id)
)
source_root = sr_check.scalar_one_or_none()
if source_root is not None:
target_dir = source_root.path
# We need a Folder row to point photo.folder_id at. Reuse the
# scanner's get_or_create helper so we don't duplicate the dedupe
# / normalization logic.
from app.tasks.scan import get_or_create_folder
target_folder = await get_or_create_folder(db, target_dir, source_root.id)
else:
folder_check = await db.execute(
select(Folder).where(Folder.id == body.target_id)
)
target_folder = folder_check.scalar_one_or_none()
if target_folder is None:
raise HTTPException(status_code=404, detail="Target folder not found")
target_dir = target_folder.path
if not os.path.isdir(target_dir):
raise HTTPException(
status_code=400,
detail=f"Target directory does not exist: {target_dir}",
)
if not body.photo_ids:
return {"status": "success", "moved": 0, "errors": []}
# Fetch the photo rows
photos_result = await db.execute(
select(Photo).where(Photo.id.in_(body.photo_ids))
)
photos_to_move = photos_result.scalars().all()
moved = 0
errors: list[dict] = []
for photo in photos_to_move:
# Skip if already in the target folder.
if photo.folder_id == target_folder.id:
continue
new_path = os.path.join(target_dir, photo.filename)
if not os.path.exists(photo.filepath):
errors.append({"id": photo.id, "error": "source file missing"})
continue
if os.path.exists(new_path):
errors.append({"id": photo.id, "error": f"name already exists in target: {photo.filename}"})
continue
try:
shutil.move(photo.filepath, new_path)
except OSError as e:
errors.append({"id": photo.id, "error": str(e)})
continue
photo.filepath = new_path
photo.folder_id = target_folder.id
moved += 1
await db.commit()
return {
"status": "success",
"moved": moved,
"errors": errors,
}
@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 == 'add_tags':
# value is a list of tag ids. We bulk-insert (photo_id, tag_id)
# rows for every (photo, tag) combination that doesn't already
# exist, so the operation is idempotent.
tag_ids = action.value or []
if not isinstance(tag_ids, list) or not tag_ids:
return {"status": "success", "added": 0, "message": "No tags supplied"}
photo_ids = [p.id for p in photos]
existing = await db.execute(
select(photo_tags.c.photo_id, photo_tags.c.tag_id).where(
photo_tags.c.photo_id.in_(photo_ids),
photo_tags.c.tag_id.in_(tag_ids),
)
)
existing_pairs = {(row[0], row[1]) for row in existing.all()}
new_rows = [
{"photo_id": pid, "tag_id": tid}
for pid in photo_ids
for tid in tag_ids
if (pid, tid) not in existing_pairs
]
if new_rows:
from sqlalchemy import insert
await db.execute(insert(photo_tags), new_rows)
await db.commit()
return {
"status": "success",
"added": len(new_rows),
"message": f"Added {len(new_rows)} tag link{'s' if len(new_rows) != 1 else ''}",
}
elif action.action == 'remove_tags':
tag_ids = action.value or []
if not isinstance(tag_ids, list) or not tag_ids:
return {"status": "success", "removed": 0, "message": "No tags supplied"}
photo_ids = [p.id for p in photos]
from sqlalchemy import delete as sql_delete
result = await db.execute(
sql_delete(photo_tags).where(
photo_tags.c.photo_id.in_(photo_ids),
photo_tags.c.tag_id.in_(tag_ids),
)
)
await db.commit()
return {
"status": "success",
"removed": result.rowcount or 0,
"message": f"Removed tag link{'s' if (result.rowcount or 0) != 1 else ''}",
}
else:
raise HTTPException(status_code=400, detail="Invalid action")
await db.commit()
return {
"status": "success",
"message": f"{action.action} applied to {len(photos)} photos"
}