Three small phase-11 follow-ups in one commit since they all touch the
same surface area.
1. Watcher source-root resolution
The watch_folders task previously called scan_folder.delay(parent_dir)
when files arrived, with no source_root_id. scan_folder would then
auto-create a fresh SourceRoot for that arbitrary subdir, polluting
the source_root list. Now the watcher loads (path, id) pairs at
startup, defines find_source_root_for() that walks the parent chain,
and dispatches with the resolved id. Events under no known root are
logged at debug and ignored instead of creating stale rows.
2. Folder rename via UI
- Backend: PATCH /folders/{id} accepts { name } and updates the
SourceRoot display label only. The on-disk path is controlled by
the docker mount and intentionally not editable from the UI.
- Frontend: double-click a folder row in the LeftSidebar to start
editing; Enter or blur commits, Esc reverts. New renamingId /
renameDraft local state and a renameMutation that invalidates
['folders']. The click handler ignores clicks while the row is
in edit mode so it doesn't navigate.
- api.ts: new sourceFolders.rename(id, name) helper.
3. Bulk copy via Alt-drag onto folder
- Backend: new POST /photos/copy that mirrors /photos/move but uses
shutil.copy2 and creates fresh Photo rows with is_duplicate=true.
Name collisions are resolved by appending " (copy)", " (copy 2)",
etc., up to 100 tries before erroring. Same target_id resolution
as /move (folder id or source root id).
- Frontend: photos.copy(ids, targetId) helper. LeftSidebar's
handleDrop now takes a `copy` flag derived from e.altKey on the
drop event; folder targets dispatch copyDropMutation when held,
moveDropMutation otherwise. The drop-effect cursor flips to
'copy' on dragover when Alt is pressed so the user gets visual
confirmation. Discard target ignores the modifier.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
744 lines
25 KiB
Python
744 lines
25 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
|
|
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"}
|
|
|
|
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
|
|
else:
|
|
raise HTTPException(status_code=400, detail="Invalid action")
|
|
|
|
await db.commit()
|
|
|
|
return {
|
|
"status": "success",
|
|
"message": f"{action.action} applied to {len(photos)} photos"
|
|
} |