feat: runtime feature flags, upload/download, RAW decoding
Adds Redis-backed feature flags for vision stages with admin UI toggles and manual backfill trigger, photo upload and download routers with frontend upload modal, and rawpy-based RAW decoding with JPEG fallback for misnamed DNGs. Fixes pgvector serialization, is_trashed filter, and naive-datetime bind in incremental duplicate regrouping; bumps Celery time limits on regroup tasks beyond the 5-minute default. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,14 @@ from app.models.user import User
|
||||
from app.models.photos import Photo
|
||||
from app.models.folders import SourceRoot
|
||||
from app.config import settings
|
||||
from app.services.feature_flags import (
|
||||
ALL_FLAGS,
|
||||
snapshot as flags_snapshot,
|
||||
set_flag,
|
||||
reset_flag,
|
||||
is_enabled,
|
||||
FLAG_VISION_ENABLED,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -258,3 +266,141 @@ async def delete_user(
|
||||
|
||||
logger.info(f"Admin '{admin.username}' deactivated user '{user.username}'")
|
||||
return {"status": "ok", "detail": f"User '{user.username}' deactivated"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AI / vision feature flags + manual triggers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class FeatureFlagUpdate(BaseModel):
|
||||
"""PATCH body for toggling a feature flag.
|
||||
|
||||
``value`` sets an explicit override (true/false); omitting it clears
|
||||
the override and reverts the flag to its YAML default.
|
||||
"""
|
||||
value: Optional[bool] = None
|
||||
|
||||
|
||||
@router.get("/feature-flags")
|
||||
async def get_feature_flags(admin: User = Depends(require_admin)):
|
||||
"""Return every tunable feature flag with its current effective
|
||||
value, YAML default, and whether an admin override is in effect."""
|
||||
return {"flags": flags_snapshot()}
|
||||
|
||||
|
||||
@router.patch("/feature-flags/{flag_name}")
|
||||
async def update_feature_flag(
|
||||
flag_name: str,
|
||||
body: FeatureFlagUpdate,
|
||||
admin: User = Depends(require_admin),
|
||||
):
|
||||
"""Set or clear an override for one flag. With ``value`` set, the
|
||||
flag is pinned to that boolean; without it, the override is deleted
|
||||
and the YAML default takes over again.
|
||||
|
||||
New value is observed by vision tasks on their next invocation —
|
||||
there's no worker restart required.
|
||||
"""
|
||||
if flag_name not in ALL_FLAGS:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown flag: {flag_name}")
|
||||
try:
|
||||
if body.value is None:
|
||||
reset_flag(flag_name)
|
||||
action = "cleared override"
|
||||
else:
|
||||
set_flag(flag_name, bool(body.value))
|
||||
action = f"set to {body.value}"
|
||||
except RuntimeError as e:
|
||||
# Redis unreachable — surface as 503 so the UI doesn't think it
|
||||
# succeeded silently.
|
||||
raise HTTPException(status_code=503, detail=str(e))
|
||||
|
||||
logger.info(f"Admin '{admin.username}' {action} for flag '{flag_name}'")
|
||||
return {"flags": flags_snapshot()}
|
||||
|
||||
|
||||
class BackfillVisionBody(BaseModel):
|
||||
"""POST body for triggering a vision backfill. ``task`` picks a
|
||||
specific stage (``embed`` / ``ocr`` / ``detect`` / ``faces`` /
|
||||
``classify``); leaving it null runs every enabled stage. ``limit``
|
||||
caps how many photos per stage are queued — useful for smoke-
|
||||
testing a newly-enabled feature before committing a full run.
|
||||
"""
|
||||
task: Optional[str] = None
|
||||
limit: Optional[int] = None
|
||||
|
||||
|
||||
@router.post("/ai/backfill")
|
||||
async def trigger_ai_backfill(
|
||||
body: BackfillVisionBody,
|
||||
admin: User = Depends(require_admin),
|
||||
):
|
||||
"""Queue a vision backfill pass. Identical code path as the automatic
|
||||
post-scan backfill — just triggered manually from the UI."""
|
||||
if not is_enabled(FLAG_VISION_ENABLED):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Vision is currently disabled; enable it before running a backfill.",
|
||||
)
|
||||
valid_tasks = {'embed', 'ocr', 'detect', 'faces', 'classify'}
|
||||
if body.task is not None and body.task not in valid_tasks:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"task must be one of {sorted(valid_tasks)} or null",
|
||||
)
|
||||
if body.limit is not None and body.limit <= 0:
|
||||
raise HTTPException(status_code=400, detail="limit must be positive")
|
||||
|
||||
# Import lazily so importing admin.py doesn't pull in the whole
|
||||
# vision stack on startup (Celery task module loads numpy etc.).
|
||||
from app.tasks.vision import backfill_vision
|
||||
|
||||
result = backfill_vision.apply_async(
|
||||
kwargs={'task': body.task, 'limit': body.limit}
|
||||
)
|
||||
logger.info(
|
||||
f"Admin '{admin.username}' queued vision backfill "
|
||||
f"(task={body.task}, limit={body.limit}, celery_id={result.id})"
|
||||
)
|
||||
return {
|
||||
"status": "queued",
|
||||
"task_id": result.id,
|
||||
"task": body.task,
|
||||
"limit": body.limit,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/ai/recluster-faces")
|
||||
async def trigger_face_recluster(admin: User = Depends(require_admin)):
|
||||
"""Kick off face recluster. Normally auto-fires after a scan via a
|
||||
debounced scheduler; this endpoint is for admins who want to force
|
||||
a fresh clustering pass (e.g. after tweaking ``cluster_eps`` in
|
||||
the YAML config)."""
|
||||
if not is_enabled(FLAG_VISION_ENABLED):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Vision is currently disabled; enable it before reclustering.",
|
||||
)
|
||||
from app.tasks.vision import recluster_faces
|
||||
|
||||
result = recluster_faces.apply_async()
|
||||
logger.info(
|
||||
f"Admin '{admin.username}' queued face recluster (celery_id={result.id})"
|
||||
)
|
||||
return {"status": "queued", "task_id": result.id}
|
||||
|
||||
|
||||
@router.post("/ai/rescan")
|
||||
async def trigger_full_rescan(admin: User = Depends(require_admin)):
|
||||
"""Dispatch the same scan_all_source_roots job the backend runs at
|
||||
startup. Picks up any new files on disk and, through the
|
||||
post-scan hook, queues a vision backfill for whatever still lacks
|
||||
embeddings / OCR / etc.
|
||||
"""
|
||||
from app.tasks.scan import scan_all_source_roots
|
||||
|
||||
result = scan_all_source_roots.apply_async()
|
||||
logger.info(
|
||||
f"Admin '{admin.username}' queued full rescan (celery_id={result.id})"
|
||||
)
|
||||
return {"status": "queued", "task_id": result.id}
|
||||
|
||||
247
backend/app/routers/download.py
Normal file
247
backend/app/routers/download.py
Normal file
@@ -0,0 +1,247 @@
|
||||
"""
|
||||
Download router — streams a .zip of every photo in a folder (recursively)
|
||||
or a heap back to the browser.
|
||||
|
||||
Auth: both endpoints accept the regular Authorization header *or* a
|
||||
``?token=JWT`` query string, mirroring the media endpoints. That lets the
|
||||
frontend trigger a download with a plain ``<a href>`` (which can't set a
|
||||
header), keeping the client side a one-liner.
|
||||
|
||||
Implementation: we build the zip into a ``NamedTemporaryFile`` and then
|
||||
stream its bytes back, deleting the temp file on the way out. Stored
|
||||
(uncompressed) mode because photos and videos are already compressed —
|
||||
deflating them again just burns CPU for a fraction of a percent. For
|
||||
very large libraries the temp-file route is mildly wasteful vs. a true
|
||||
streaming zip (zipstream-ng etc), but it avoids a new dependency and
|
||||
handles arbitrary folder sizes without blowing out RAM.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import zipfile
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user_media
|
||||
from app.models import Folder, Heap, Photo, SourceRoot
|
||||
from app.models.heaps import heap_photos
|
||||
from app.models.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _safe_filename(name: str) -> str:
|
||||
"""Strip characters that Content-Disposition or Windows filesystems
|
||||
would choke on. Keeps the download's filename readable without
|
||||
needing any escaping on the client side."""
|
||||
cleaned = re.sub(r'[\\/:*?"<>|\r\n\t]', '_', name).strip().strip('.')
|
||||
return cleaned or 'download'
|
||||
|
||||
|
||||
async def _collect_folder_photos(
|
||||
folder_id: str,
|
||||
user: User,
|
||||
db: AsyncSession,
|
||||
) -> tuple[str, str, List[Photo]]:
|
||||
"""Resolve a folder or source-root id → (base_path, display_name,
|
||||
photos). ``base_path`` is the prefix we strip off each photo's
|
||||
filepath when naming zip entries, so the archive mirrors the user's
|
||||
on-disk structure under that folder.
|
||||
"""
|
||||
folder = (await db.execute(
|
||||
select(Folder).where(Folder.id == folder_id, Folder.user_id == user.id)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
base_path: str
|
||||
display_name: str
|
||||
if folder is not None:
|
||||
base_path = os.path.normpath(folder.path)
|
||||
display_name = folder.name or os.path.basename(base_path)
|
||||
else:
|
||||
sr = (await db.execute(
|
||||
select(SourceRoot).where(
|
||||
SourceRoot.id == folder_id,
|
||||
SourceRoot.user_id == user.id,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if sr is None:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
base_path = os.path.normpath(sr.path)
|
||||
display_name = sr.name or os.path.basename(base_path)
|
||||
|
||||
# Every photo whose filepath is at or below the base path — matches
|
||||
# the same prefix convention folders.py uses for recursive deletes.
|
||||
descendant_prefix = base_path.rstrip(os.sep) + os.sep
|
||||
result = await db.execute(
|
||||
select(Photo).where(
|
||||
Photo.user_id == user.id,
|
||||
Photo.is_discarded == False, # noqa: E712
|
||||
(Photo.filepath == base_path) | (Photo.filepath.like(descendant_prefix + '%')),
|
||||
)
|
||||
)
|
||||
photos = list(result.scalars().all())
|
||||
return base_path, display_name, photos
|
||||
|
||||
|
||||
def _build_zip(
|
||||
photos: List[Photo],
|
||||
arcname_fn,
|
||||
) -> tempfile.NamedTemporaryFile:
|
||||
"""Write ``photos`` into a fresh ZIP_STORED temp file.
|
||||
|
||||
``arcname_fn(photo, used_names)`` returns the entry name to use for
|
||||
the given photo; the caller supplies it because folder downloads
|
||||
want path-preserving names while heap downloads flatten to bare
|
||||
filenames (with a collision suffix).
|
||||
"""
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False, suffix='.zip')
|
||||
try:
|
||||
used: set[str] = set()
|
||||
with zipfile.ZipFile(tmp, 'w', zipfile.ZIP_STORED, allowZip64=True) as zf:
|
||||
for p in photos:
|
||||
if not p.filepath or not os.path.exists(p.filepath):
|
||||
# Silent skip: the scanner may have indexed files
|
||||
# that have since been moved / unlinked by a shell.
|
||||
continue
|
||||
name = arcname_fn(p, used)
|
||||
used.add(name)
|
||||
try:
|
||||
zf.write(p.filepath, name)
|
||||
except OSError as e:
|
||||
logger.warning(f"Skipping {p.filepath} in zip: {e}")
|
||||
tmp.close()
|
||||
return tmp
|
||||
except Exception:
|
||||
tmp.close()
|
||||
try:
|
||||
os.unlink(tmp.name)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def _stream_and_cleanup(path: str):
|
||||
"""Yield the temp zip in 1 MiB chunks and unlink it when the
|
||||
iterator is exhausted (or GC'd, if the client disconnects early)."""
|
||||
try:
|
||||
with open(path, 'rb') as f:
|
||||
while True:
|
||||
chunk = f.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
finally:
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError as e:
|
||||
logger.debug(f"Temp zip cleanup failed for {path}: {e}")
|
||||
|
||||
|
||||
def _dedupe(name: str, used: set[str]) -> str:
|
||||
"""Return ``name`` (or ``name (2)``, ``name (3)`` ...) such that the
|
||||
result doesn't collide with anything in ``used``. Needed for heap
|
||||
downloads where two members can have identical filenames from
|
||||
different folders."""
|
||||
if name not in used:
|
||||
return name
|
||||
stem, ext = os.path.splitext(name)
|
||||
n = 2
|
||||
while True:
|
||||
cand = f"{stem} ({n}){ext}"
|
||||
if cand not in used:
|
||||
return cand
|
||||
n += 1
|
||||
|
||||
|
||||
@router.get("/folders/{folder_id}")
|
||||
async def download_folder(
|
||||
folder_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_media),
|
||||
):
|
||||
"""Zip every (non-discarded) photo under a folder/source-root and
|
||||
stream it back. Entries preserve the folder structure relative to
|
||||
the downloaded root so the resulting archive is a faithful snapshot.
|
||||
"""
|
||||
base_path, display_name, photos = await _collect_folder_photos(
|
||||
folder_id, current_user, db
|
||||
)
|
||||
if not photos:
|
||||
raise HTTPException(status_code=404, detail="No photos to download")
|
||||
|
||||
def arcname(p: Photo, _used: set[str]) -> str:
|
||||
# Relative path from the download root, falling back to the
|
||||
# bare filename if the photo somehow lives outside base_path.
|
||||
abs_path = os.path.normpath(p.filepath)
|
||||
if abs_path.startswith(base_path + os.sep):
|
||||
rel = abs_path[len(base_path) + 1:]
|
||||
elif abs_path == base_path:
|
||||
rel = os.path.basename(abs_path)
|
||||
else:
|
||||
rel = p.filename or os.path.basename(abs_path)
|
||||
# Nest everything under display_name so users see one top-level
|
||||
# folder inside the zip rather than loose files.
|
||||
return os.path.join(_safe_filename(display_name), rel)
|
||||
|
||||
tmp = _build_zip(photos, arcname)
|
||||
filename = _safe_filename(display_name) + '.zip'
|
||||
return StreamingResponse(
|
||||
_stream_and_cleanup(tmp.name),
|
||||
media_type='application/zip',
|
||||
headers={
|
||||
'Content-Disposition': f'attachment; filename="{filename}"',
|
||||
'Content-Length': str(os.path.getsize(tmp.name)),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/heaps/{heap_id}")
|
||||
async def download_heap(
|
||||
heap_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_media),
|
||||
):
|
||||
"""Zip every photo in a heap. Heaps are flat collections, so entries
|
||||
use the original filename (with a ``(2)`` collision suffix when
|
||||
two members share a name)."""
|
||||
heap = (await db.execute(
|
||||
select(Heap).where(Heap.id == heap_id, Heap.user_id == current_user.id)
|
||||
)).scalar_one_or_none()
|
||||
if heap is None:
|
||||
raise HTTPException(status_code=404, detail="Heap not found")
|
||||
|
||||
result = await db.execute(
|
||||
select(Photo)
|
||||
.join(heap_photos, heap_photos.c.photo_id == Photo.id)
|
||||
.where(
|
||||
heap_photos.c.heap_id == heap_id,
|
||||
Photo.is_discarded == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
photos = list(result.scalars().all())
|
||||
if not photos:
|
||||
raise HTTPException(status_code=404, detail="Heap is empty")
|
||||
|
||||
def arcname(p: Photo, used: set[str]) -> str:
|
||||
bare = p.filename or os.path.basename(p.filepath or 'photo')
|
||||
entry = os.path.join(_safe_filename(heap.name), _dedupe(bare, used))
|
||||
return entry
|
||||
|
||||
tmp = _build_zip(photos, arcname)
|
||||
filename = _safe_filename(heap.name) + '.zip'
|
||||
return StreamingResponse(
|
||||
_stream_and_cleanup(tmp.name),
|
||||
media_type='application/zip',
|
||||
headers={
|
||||
'Content-Disposition': f'attachment; filename="{filename}"',
|
||||
'Content-Length': str(os.path.getsize(tmp.name)),
|
||||
},
|
||||
)
|
||||
23
backend/app/routers/features.py
Normal file
23
backend/app/routers/features.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
Public feature-flag read API — lets the authenticated frontend know
|
||||
which AI-powered sections to render.
|
||||
|
||||
This is NOT the admin mutation endpoint (that's in ``admin.py`` and
|
||||
gated by ``require_admin``). Here we only expose the effective boolean
|
||||
state so the UI can hide things like the People view, Tags view, or
|
||||
text-search affordances when the underlying pipeline stage is off.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.dependencies import get_current_user
|
||||
from app.models.user import User
|
||||
from app.services.feature_flags import ALL_FLAGS, is_enabled
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_enabled_features(_: User = Depends(get_current_user)):
|
||||
"""Return ``{flag_name: bool}`` for every known flag, reflecting
|
||||
the currently effective value (admin override or YAML default)."""
|
||||
return {name: is_enabled(name) for name in ALL_FLAGS}
|
||||
303
backend/app/routers/upload.py
Normal file
303
backend/app/routers/upload.py
Normal file
@@ -0,0 +1,303 @@
|
||||
"""
|
||||
Upload router — lets users drop files (or whole folders) from their
|
||||
desktop into a destination Folder, preserving any sub-folder structure
|
||||
they bring with them.
|
||||
|
||||
Each POST handles one file. The frontend fans out many parallel requests
|
||||
per drop, giving it per-file progress without the server having to
|
||||
invent a chunking protocol. For folder uploads, the browser passes
|
||||
`webkitRelativePath` under the `relative_path` field; any leading
|
||||
sub-directories there are materialised on disk (and as Folder rows)
|
||||
under the destination.
|
||||
|
||||
Uploaded files are placed under the destination folder on the owner's
|
||||
media mount, indexed immediately (Photo row created), and queued for
|
||||
the same thumb + metadata pipeline that the scanner uses. An optional
|
||||
`heap_id` also drops them into a heap in the same request.
|
||||
"""
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||
from sqlalchemy import insert, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user
|
||||
from app.models import Folder, Heap, Photo, SourceRoot
|
||||
from app.models.heaps import heap_photos
|
||||
from app.models.user import User
|
||||
from app.services.date_guess import has_date_warning
|
||||
from app.tasks.scan import SUPPORTED_EXTENSIONS, get_media_type
|
||||
from app.tasks.thumbs import generate_thumbnails
|
||||
from app.services.metadata import extract_metadata
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
MAX_UPLOAD_BYTES = 500 * 1024 * 1024 # 500 MB per file cap.
|
||||
|
||||
|
||||
def _validate_segment(segment: str) -> str:
|
||||
"""Reject path segments that would escape the destination directory."""
|
||||
segment = segment.strip()
|
||||
if not segment or segment in ('.', '..') or '/' in segment or '\\' in segment:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid path segment: {segment!r}")
|
||||
return segment
|
||||
|
||||
|
||||
def _sanitize_relative_path(rel: Optional[str]) -> list[str]:
|
||||
"""Split `relative_path` into safe segments (dirs + filename).
|
||||
|
||||
Empty or missing → []. Any absolute path, backslash, or `..` segment
|
||||
raises 400 — we never want an upload to escape the destination.
|
||||
"""
|
||||
if not rel:
|
||||
return []
|
||||
# Normalise backslashes to forward slashes; browsers on Windows send
|
||||
# webkitRelativePath with forward slashes anyway, but defend in depth.
|
||||
rel = rel.replace('\\', '/').strip('/')
|
||||
if not rel:
|
||||
return []
|
||||
segs = [_validate_segment(s) for s in rel.split('/') if s]
|
||||
return segs
|
||||
|
||||
|
||||
async def _resolve_destination(
|
||||
folder_id: str,
|
||||
user: User,
|
||||
db: AsyncSession,
|
||||
) -> Folder:
|
||||
"""Resolve `folder_id` to a concrete Folder row the user owns.
|
||||
|
||||
Accepts both Folder ids and SourceRoot ids (for source roots, we
|
||||
return the Folder row at the mount path — the scanner creates one
|
||||
for every source root it walks). Raises 404 if neither matches.
|
||||
"""
|
||||
folder = (await db.execute(
|
||||
select(Folder).where(Folder.id == folder_id, Folder.user_id == user.id)
|
||||
)).scalar_one_or_none()
|
||||
if folder is not None:
|
||||
return folder
|
||||
|
||||
sr = (await db.execute(
|
||||
select(SourceRoot).where(SourceRoot.id == folder_id, SourceRoot.user_id == user.id)
|
||||
)).scalar_one_or_none()
|
||||
if sr is None:
|
||||
raise HTTPException(status_code=404, detail="Destination folder not found")
|
||||
|
||||
root_folder = (await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.source_root_id == sr.id,
|
||||
Folder.user_id == user.id,
|
||||
Folder.path == os.path.normpath(sr.path),
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if root_folder is None:
|
||||
# First-time source root with no walk yet — create the row now so
|
||||
# uploads work even before the initial scan has run.
|
||||
root_folder = Folder(
|
||||
name=sr.name or os.path.basename(sr.path),
|
||||
path=os.path.normpath(sr.path),
|
||||
source_root_id=sr.id,
|
||||
user_id=user.id,
|
||||
)
|
||||
os.makedirs(root_folder.path, exist_ok=True)
|
||||
db.add(root_folder)
|
||||
await db.flush()
|
||||
return root_folder
|
||||
|
||||
|
||||
async def _ensure_subfolder(
|
||||
parent: Folder,
|
||||
name: str,
|
||||
user: User,
|
||||
db: AsyncSession,
|
||||
) -> Folder:
|
||||
"""Return (or create) a Folder row named `name` under `parent`.
|
||||
|
||||
Also mkdirs the directory on disk. Idempotent — safe to call for a
|
||||
path segment that already exists as a Folder row or directory.
|
||||
"""
|
||||
child_path = os.path.normpath(os.path.join(parent.path, name))
|
||||
|
||||
existing = (await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.path == child_path,
|
||||
Folder.user_id == user.id,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
os.makedirs(child_path, exist_ok=True)
|
||||
return existing
|
||||
|
||||
os.makedirs(child_path, exist_ok=True)
|
||||
child = Folder(
|
||||
name=name,
|
||||
path=child_path,
|
||||
parent_id=parent.id,
|
||||
source_root_id=parent.source_root_id,
|
||||
user_id=user.id,
|
||||
is_hidden=parent.is_hidden,
|
||||
)
|
||||
db.add(child)
|
||||
await db.flush()
|
||||
return child
|
||||
|
||||
|
||||
def _unique_path(target_dir: str, filename: str) -> tuple[str, str]:
|
||||
"""Return a (filepath, filename) that doesn't collide with an
|
||||
existing file on disk. Suffixes " (2)", " (3)", ... until a free
|
||||
slot is found. Prevents upload-over-existing and keeps the user's
|
||||
original file intact.
|
||||
"""
|
||||
base, ext = os.path.splitext(filename)
|
||||
candidate = os.path.join(target_dir, filename)
|
||||
n = 2
|
||||
while os.path.exists(candidate):
|
||||
new_name = f"{base} ({n}){ext}"
|
||||
candidate = os.path.join(target_dir, new_name)
|
||||
n += 1
|
||||
return candidate, os.path.basename(candidate)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def upload_file(
|
||||
file: UploadFile = File(...),
|
||||
destination_folder_id: str = Form(...),
|
||||
relative_path: Optional[str] = Form(None),
|
||||
heap_id: Optional[str] = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Upload a single file into a destination folder (and optionally a
|
||||
heap). For folder uploads, `relative_path` carries the sub-folder
|
||||
chain from the browser's `webkitRelativePath`, and we materialise
|
||||
it under the destination on disk + as Folder rows.
|
||||
|
||||
Returns the created photo's id on success. 4xx on unsupported file
|
||||
type, bad path, missing destination, or too-large file.
|
||||
"""
|
||||
# --- validate inputs -------------------------------------------------
|
||||
raw_name = file.filename or ''
|
||||
if not raw_name:
|
||||
raise HTTPException(status_code=400, detail="Missing filename")
|
||||
|
||||
# Prefer the leaf of relative_path when present (it contains the
|
||||
# original filename as the browser saw it inside the picked folder).
|
||||
segs = _sanitize_relative_path(relative_path)
|
||||
if segs:
|
||||
leaf = segs[-1]
|
||||
subdirs = segs[:-1]
|
||||
else:
|
||||
leaf = _validate_segment(os.path.basename(raw_name))
|
||||
subdirs = []
|
||||
|
||||
ext = Path(leaf).suffix.lower()
|
||||
if ext not in SUPPORTED_EXTENSIONS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unsupported file type: {ext or '(none)'}",
|
||||
)
|
||||
|
||||
dest_folder = await _resolve_destination(destination_folder_id, current_user, db)
|
||||
|
||||
target_folder = dest_folder
|
||||
for seg in subdirs:
|
||||
target_folder = await _ensure_subfolder(target_folder, seg, current_user, db)
|
||||
|
||||
target_dir = target_folder.path
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
filepath, final_name = _unique_path(target_dir, leaf)
|
||||
|
||||
# --- stream to disk, hash as we go ----------------------------------
|
||||
hasher = hashlib.sha256()
|
||||
total = 0
|
||||
try:
|
||||
with open(filepath, 'wb') as out:
|
||||
while True:
|
||||
chunk = await file.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > MAX_UPLOAD_BYTES:
|
||||
out.close()
|
||||
os.unlink(filepath)
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"File exceeds {MAX_UPLOAD_BYTES // (1024*1024)}MB limit",
|
||||
)
|
||||
hasher.update(chunk)
|
||||
out.write(chunk)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Upload write failed for {filepath}: {e}")
|
||||
if os.path.exists(filepath):
|
||||
try:
|
||||
os.unlink(filepath)
|
||||
except OSError:
|
||||
pass
|
||||
raise HTTPException(status_code=500, detail=f"Upload failed: {e}")
|
||||
|
||||
file_hash = hasher.hexdigest()
|
||||
|
||||
# --- validate heap before committing the DB row ---------------------
|
||||
if heap_id:
|
||||
heap = (await db.execute(
|
||||
select(Heap).where(Heap.id == heap_id, Heap.user_id == current_user.id)
|
||||
)).scalar_one_or_none()
|
||||
if heap is None:
|
||||
# Destination heap vanished — still keep the file + photo row,
|
||||
# but tell the caller so the UI can surface the mismatch.
|
||||
heap_id = None
|
||||
|
||||
# --- create Photo row ------------------------------------------------
|
||||
mtime_dt = datetime.fromtimestamp(os.stat(filepath).st_mtime)
|
||||
photo = Photo(
|
||||
filepath=filepath,
|
||||
filename=final_name,
|
||||
folder_id=target_folder.id,
|
||||
user_id=current_user.id,
|
||||
file_hash=file_hash,
|
||||
media_type=get_media_type(filepath),
|
||||
original_format=Path(filepath).suffix.upper()[1:],
|
||||
file_size=total,
|
||||
taken_at=mtime_dt,
|
||||
taken_at_source='filesystem',
|
||||
has_date_warning=has_date_warning(filepath, mtime_dt),
|
||||
is_hidden=bool(target_folder.is_hidden),
|
||||
processing_status='pending',
|
||||
)
|
||||
db.add(photo)
|
||||
await db.flush()
|
||||
|
||||
if heap_id:
|
||||
await db.execute(
|
||||
insert(heap_photos),
|
||||
[{"heap_id": heap_id, "photo_id": photo.id}],
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Queue the same background work the scanner does so thumbnails +
|
||||
# EXIF show up without the user having to trigger a rescan.
|
||||
try:
|
||||
generate_thumbnails.delay(photo.id)
|
||||
extract_metadata.delay(photo.id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to queue post-upload tasks for {photo.id}: {e}")
|
||||
|
||||
return {
|
||||
"photo_id": photo.id,
|
||||
"filename": final_name,
|
||||
"folder_id": target_folder.id,
|
||||
"folder_path": target_folder.path,
|
||||
"heap_id": heap_id,
|
||||
}
|
||||
Reference in New Issue
Block a user