diff --git a/backend/app/main.py b/backend/app/main.py index 353edfe..0df4e4f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -11,7 +11,7 @@ import os from app.config import settings from app.database import init_db -from app.routers import photos, folders, heaps, tags, discard, library, search, auth, admin, sharing +from app.routers import photos, folders, heaps, tags, discard, library, search, auth, admin, sharing, upload, download, features from app.services.scanner import start_initial_scan, bootstrap_default_source_root from app.services.cleanup import cleanup_data_integrity @@ -95,6 +95,9 @@ app.include_router(tags.router, prefix="/api/v1/tags", tags=["tags"]) app.include_router(discard.router, prefix="/api/v1/discard", tags=["discard"]) app.include_router(library.router, prefix="/api/v1/library", tags=["library"]) app.include_router(search.router, prefix="/api/v1/photos/search", tags=["search"]) +app.include_router(upload.router, prefix="/api/v1/upload", tags=["upload"]) +app.include_router(download.router, prefix="/api/v1/download", tags=["download"]) +app.include_router(features.router, prefix="/api/v1/features", tags=["features"]) @app.get("/") async def root(): diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 27b04fc..1e756a0 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -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} diff --git a/backend/app/routers/download.py b/backend/app/routers/download.py new file mode 100644 index 0000000..498305f --- /dev/null +++ b/backend/app/routers/download.py @@ -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 ```` (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)), + }, + ) diff --git a/backend/app/routers/features.py b/backend/app/routers/features.py new file mode 100644 index 0000000..25b4c3e --- /dev/null +++ b/backend/app/routers/features.py @@ -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} diff --git a/backend/app/routers/upload.py b/backend/app/routers/upload.py new file mode 100644 index 0000000..c717817 --- /dev/null +++ b/backend/app/routers/upload.py @@ -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, + } diff --git a/backend/app/services/duplicates.py b/backend/app/services/duplicates.py index 3f4ae13..50b2207 100644 --- a/backend/app/services/duplicates.py +++ b/backend/app/services/duplicates.py @@ -221,6 +221,13 @@ async def incremental_regroup( from datetime import timedelta since = datetime.now(timezone.utc) - timedelta(hours=1) + # Photo.added_at is stored as TIMESTAMP WITHOUT TIME ZONE, so + # asyncpg rejects aware datetimes with "can't subtract offset-naive + # and offset-aware". Normalise: if `since` has a tzinfo, convert + # it to UTC and drop the tzinfo so the bind parameter is naive. + if since.tzinfo is not None: + since = since.astimezone(timezone.utc).replace(tzinfo=None) + # Get newly added photos (the "new" set). new_rows = ( await session.execute( @@ -349,6 +356,14 @@ async def _clip_neighbor_scan( for photo_id, vector in target_embeddings: # pgvector cosine distance: <=> operator # Find top 20 nearest neighbors within threshold. + # Serialize the vector as "[a,b,c,...]" — pgvector's text + # format uses commas; numpy's default str() joins with spaces + # which Postgres rejects with "invalid input syntax for vector". + if hasattr(vector, 'tolist'): + vec_seq = vector.tolist() + else: + vec_seq = list(vector) + vec_text = '[' + ','.join(f'{float(x):.8f}' for x in vec_seq) + ']' result = await session.execute( text(""" SELECT e.photo_id, (e.vector <=> :vec) AS distance @@ -356,14 +371,14 @@ async def _clip_neighbor_scan( JOIN photos p ON p.id = e.photo_id WHERE e.model = :model AND e.photo_id != :pid - AND p.is_discarded = false + AND p.is_trashed = false AND p.is_hidden = false AND (e.vector <=> :vec) < :threshold ORDER BY e.vector <=> :vec LIMIT 20 """), { - 'vec': str(vector), + 'vec': vec_text, 'pid': photo_id, 'model': embedder_model, 'threshold': threshold, diff --git a/backend/app/services/feature_flags.py b/backend/app/services/feature_flags.py new file mode 100644 index 0000000..e630672 --- /dev/null +++ b/backend/app/services/feature_flags.py @@ -0,0 +1,301 @@ +""" +Runtime feature flags for expensive pipeline stages. + +The YAML config (``mulita.yml``) ships reasonable defaults. Admins can +toggle these at runtime from the Settings → AI Features tab without +rebuilding the image or editing the bind-mounted YAML; the overrides +live in Redis so both the FastAPI backend and the Celery workers see +the same value within ~1s of the write. + +The key namespace is: + + mulita:flags: → "true" | "false" + +An unset key means "fall back to the YAML default" — so an admin who +has never touched the tab sees exactly the config-file behaviour. + +Only bool flags live here. Thresholds, batch sizes, model names etc. +stay in the YAML file because flipping them safely requires restarting +the vision workers (model reload, ONNX session re-init); that's not +something a single admin click should do. +""" +from __future__ import annotations + +import logging +from typing import Optional + +import redis + +from app.config import settings + +logger = logging.getLogger(__name__) + + +# Feature identifiers. The public name is what the admin UI sends; the +# ``yaml_default`` getter returns the value the YAML would have set. +# Keep these in sync with the VisionSettings fields in ``config.py``. +FLAG_VISION_ENABLED = 'vision.enabled' +FLAG_OCR_ENABLED = 'vision.ocr.enabled' +FLAG_DETECTOR_ENABLED = 'vision.detector.enabled' +FLAG_FACES_ENABLED = 'vision.faces.enabled' +FLAG_CLASSIFIER_ENABLED = 'vision.classifier.enabled' + +ALL_FLAGS = ( + FLAG_VISION_ENABLED, + FLAG_OCR_ENABLED, + FLAG_DETECTOR_ENABLED, + FLAG_FACES_ENABLED, + FLAG_CLASSIFIER_ENABLED, +) + + +_REDIS: Optional[redis.Redis] = None + + +def _redis() -> Optional[redis.Redis]: + """Lazy Redis client. Returns None if the broker is unreachable so + callers can fall back to YAML defaults instead of crashing.""" + global _REDIS + if _REDIS is None: + try: + _REDIS = redis.Redis.from_url( + settings.celery_broker_url, decode_responses=True + ) + _REDIS.ping() + except Exception as e: + logger.warning(f"feature_flags: Redis unavailable, using YAML defaults ({e})") + _REDIS = None + return _REDIS + + +def _yaml_default(name: str) -> bool: + """Return the YAML-configured default for a flag. Used when Redis + has no value set (fresh install or admin never touched the tab).""" + v = settings.vision + if name == FLAG_VISION_ENABLED: + return bool(v.enabled) + if name == FLAG_OCR_ENABLED: + return bool(v.ocr.enabled) + if name == FLAG_DETECTOR_ENABLED: + return bool(v.detector.enabled) + if name == FLAG_FACES_ENABLED: + return bool(v.faces.enabled) + if name == FLAG_CLASSIFIER_ENABLED: + return bool(v.classifier.enabled) + raise ValueError(f"Unknown feature flag: {name!r}") + + +def _redis_key(name: str) -> str: + return f"mulita:flags:{name}" + + +def is_enabled(name: str) -> bool: + """Return True if feature ``name`` is currently enabled. + + Order of precedence: + 1. Redis override (set by PATCH /admin/feature-flags) + 2. YAML default + + Reads are cheap (~ms) and we intentionally do NOT add a local + process cache — the whole point of runtime flags is that a toggle + takes effect on the next task without a worker restart. + """ + r = _redis() + if r is not None: + try: + raw = r.get(_redis_key(name)) + if raw is not None: + return raw.lower() == 'true' + except Exception as e: + logger.warning(f"feature_flags: Redis read failed for {name} ({e})") + return _yaml_default(name) + + +def set_flag(name: str, value: bool) -> None: + """Persist a flag override to Redis. No-op if Redis is unreachable + (we don't silently pretend to have written; raise so the admin + request returns a 500 instead of misleading success).""" + if name not in ALL_FLAGS: + raise ValueError(f"Unknown feature flag: {name!r}") + r = _redis() + if r is None: + raise RuntimeError("Redis unavailable; cannot update feature flags") + r.set(_redis_key(name), 'true' if value else 'false') + _apply_worker_side_effects(name) + + +def reset_flag(name: str) -> None: + """Delete the Redis override so the flag falls back to its YAML + default. Useful if an admin wants a clean slate without guessing + what the config defaults are.""" + if name not in ALL_FLAGS: + raise ValueError(f"Unknown feature flag: {name!r}") + r = _redis() + if r is None: + raise RuntimeError("Redis unavailable; cannot reset feature flags") + r.delete(_redis_key(name)) + _apply_worker_side_effects(name) + + +# --------------------------------------------------------------------------- +# Worker-level side effects: when the admin flips a flag we don't just want +# gating at task-start (which still executes the message, it just returns +# 'skipped'). We also want queued work gone and the vision worker genuinely +# idle when the master switch is off. +# --------------------------------------------------------------------------- + +_VISION_QUEUE = 'vision' + +# Flag → celery task name(s) whose queued messages should be dropped when +# the flag goes off. Keeps the queue from replaying yesterday's work the +# moment someone re-enables the stage. +_TASKS_BY_FLAG: dict[str, tuple[str, ...]] = { + FLAG_VISION_ENABLED: ( + 'embed_photo', 'ocr_photo', 'detect_objects', 'extract_faces', + 'classify_content', 'vision_fanout', 'recluster_faces', + ), + FLAG_OCR_ENABLED: ('ocr_photo',), + FLAG_DETECTOR_ENABLED: ('detect_objects',), + FLAG_FACES_ENABLED: ('extract_faces', 'recluster_faces'), + FLAG_CLASSIFIER_ENABLED: ('classify_content',), +} + + +def _apply_worker_side_effects(name: str) -> None: + """Bring the live workers in line with the new flag value. + + For the master ``vision.enabled`` flag we go beyond task gating and + actually stop consumption from the ``vision`` queue — flipping it + off puts the vision worker to sleep (no CPU, no model memory + churn) until it's flipped back on. For per-feature flags, the + running tasks already skip via ``is_enabled``; we just purge any + messages already sitting in the queue so the admin doesn't pay for + a backlog on re-enable. + + All operations are best-effort — if control messaging or a Redis + op fails, we log and return; the flag state itself is already + persisted so the gating path continues to work. + """ + try: + # Lazy import: avoids a circular dependency between the services + # module (imported from tasks.vision) and the celery app config. + from app.tasks.celery import celery_app + except Exception as e: + logger.warning(f"feature_flags: celery app unavailable for side effects ({e})") + return + + try: + if name == FLAG_VISION_ENABLED: + if is_enabled(FLAG_VISION_ENABLED): + # Re-attach the vision consumer so workers pick up tasks + # again. broadcast=True ensures every running worker + # receives the command. + celery_app.control.add_consumer(_VISION_QUEUE, reply=False) + logger.info("feature_flags: vision re-enabled; consumer added") + else: + celery_app.control.cancel_consumer(_VISION_QUEUE, reply=False) + _purge_queue(_VISION_QUEUE) + logger.info( + "feature_flags: vision disabled; consumer cancelled " + "and queue purged" + ) + return + + # Per-feature flag going off → drop pending tasks of its types. + if not is_enabled(name): + targets = _TASKS_BY_FLAG.get(name, ()) + if targets: + removed = _purge_queue_by_task_names(_VISION_QUEUE, targets) + logger.info( + f"feature_flags: {name} disabled; removed {removed} " + f"pending messages from {_VISION_QUEUE}" + ) + except Exception as e: + logger.warning(f"feature_flags: worker side effects failed for {name}: {e}") + + +def _purge_queue(queue: str) -> int: + """Drop every pending message from ``queue``. Returns the count + deleted. Celery's control.purge() purges the default queue only, + so we delete the Redis key directly (the broker's queue list).""" + r = _redis() + if r is None: + return 0 + try: + removed = r.delete(queue) + return int(removed or 0) + except Exception as e: + logger.warning(f"feature_flags: purge {queue} failed: {e}") + return 0 + + +def _purge_queue_by_task_names(queue: str, task_names: tuple[str, ...]) -> int: + """Walk ``queue`` and drop any message whose Celery task name is in + ``task_names``. Other messages are preserved (pushed back in order) + so we don't flush embed tasks when the admin disabled only OCR. + + Celery stores each message as a JSON blob in a Redis list; the + task name lives at ``headers.task``. + """ + import json + r = _redis() + if r is None: + return 0 + try: + # Snapshot the queue, then rebuild it without the filtered names. + # Done inside a Redis transaction so a concurrent enqueue doesn't + # race with us (worst case it gets re-delivered after we release, + # which is the normal enqueue path anyway). + pipe = r.pipeline() + pipe.lrange(queue, 0, -1) + pipe.delete(queue) + raw_items, _ = pipe.execute() + kept: list[bytes | str] = [] + removed = 0 + for raw in raw_items or []: + try: + # Messages can be bytes or str depending on decode_responses. + payload = raw.decode() if isinstance(raw, bytes) else raw + msg = json.loads(payload) + task = ( + msg.get('headers', {}).get('task') + or msg.get('task') + ) + if task in task_names: + removed += 1 + continue + except Exception: + # Unparseable message — keep it, better to leak than + # to silently drop a message we can't identify. + pass + kept.append(raw) + if kept: + r.rpush(queue, *kept) + return removed + except Exception as e: + logger.warning(f"feature_flags: selective purge failed on {queue}: {e}") + return 0 + + +def snapshot() -> dict[str, dict[str, object]]: + """Return every flag's current effective value, YAML default, and + whether it's overridden. Powers the admin UI tab. + """ + r = _redis() + out: dict[str, dict[str, object]] = {} + for name in ALL_FLAGS: + default = _yaml_default(name) + override = None + if r is not None: + try: + raw = r.get(_redis_key(name)) + if raw is not None: + override = raw.lower() == 'true' + except Exception: + pass + out[name] = { + 'effective': override if override is not None else default, + 'default': default, + 'overridden': override is not None, + } + return out diff --git a/backend/app/tasks/thumbs.py b/backend/app/tasks/thumbs.py index 6626a0e..cd70e34 100644 --- a/backend/app/tasks/thumbs.py +++ b/backend/app/tasks/thumbs.py @@ -312,7 +312,25 @@ async def _generate_thumbnails_async(photo_id: str, task): else: logger.error(f"Unsupported media type: {photo.media_type}") image = create_placeholder_thumbnail(photo.media_type) - + + # Fallback: some files wear a RAW/HEIC extension but are actually + # plain JPEGs — e.g. iPhones that write ProRAW-style .DNG for + # images where no RAW sensor data was captured, or re-exports + # that kept the original suffix. Pillow can open them directly, + # so before giving up, try reading the file as a standard image. + if not image and photo.media_type in ('raw', 'heic'): + try: + image = process_standard_image(photo.filepath) + if image is not None: + logger.info( + f"{photo.filepath}: {photo.media_type} decode failed " + f"but file opens as a standard image — using fallback" + ) + except Exception as e: + logger.debug( + f"Standard-image fallback failed for {photo.filepath}: {e}" + ) + if not image: raise Exception("Failed to process image") @@ -493,7 +511,16 @@ async def _backfill_phashes_async(): } -@shared_task(name='regroup_duplicates') +@shared_task( + name='regroup_duplicates', + # Full regroup scales with O(N²) on phash plus one pgvector query per + # embedded photo. On a 16k-photo library that's comfortably past the + # default 5-minute soft limit — bump to 2h / 2h30m. (Passing None here + # does NOT disable limits; Celery falls back to the worker default + # of 300s/600s. An explicit number overrides.) + soft_time_limit=7200, + time_limit=9000, +) def regroup_duplicates_task(): """Full recompute of duplicate groups (pHash + CLIP similarity). @@ -502,7 +529,15 @@ def regroup_duplicates_task(): return asyncio.run(regroup_duplicates()) -@shared_task(name='incremental_regroup_duplicates') +@shared_task( + name='incremental_regroup_duplicates', + # O(new × N); still cheaper than a full regroup but can easily exceed + # the 5-minute default after a big batch import. Same caveat as + # regroup_duplicates above — None would just re-inherit the worker + # default, so we pass explicit values. + soft_time_limit=3600, + time_limit=4200, +) def incremental_regroup_duplicates_task(since_iso: str | None = None): """Incremental duplicate detection for newly added photos. diff --git a/backend/app/tasks/vision.py b/backend/app/tasks/vision.py index 412dfe4..112655d 100644 --- a/backend/app/tasks/vision.py +++ b/backend/app/tasks/vision.py @@ -20,6 +20,14 @@ from PIL import Image from app.models.embeddings import Embedding from app.config import settings +from app.services.feature_flags import ( + is_enabled, + FLAG_VISION_ENABLED, + FLAG_OCR_ENABLED, + FLAG_DETECTOR_ENABLED, + FLAG_FACES_ENABLED, + FLAG_CLASSIFIER_ENABLED, +) logger = logging.getLogger(__name__) @@ -83,7 +91,7 @@ def _load_thumb(photo_id: str, size: str = "medium") -> np.ndarray | None: @shared_task(name='embed_photo', queue='vision', bind=True, max_retries=3) def embed_photo(self, photo_id: str): """Generate CLIP embedding for a photo and store in pgvector.""" - if not settings.vision.enabled: + if not is_enabled(FLAG_VISION_ENABLED): return {'status': 'skipped', 'reason': 'vision disabled'} image = _load_thumb(photo_id, "medium") # 640px @@ -128,18 +136,18 @@ def embed_photo(self, photo_id: str): @shared_task(name='vision_fanout', queue='vision') def vision_fanout(photo_id: str): """Dispatch all enabled vision tasks for a photo.""" - if not settings.vision.enabled: + if not is_enabled(FLAG_VISION_ENABLED): return {'status': 'skipped', 'reason': 'vision disabled'} embed_photo.delay(photo_id) - if settings.vision.ocr.enabled: + if is_enabled(FLAG_OCR_ENABLED): ocr_photo.delay(photo_id) - if settings.vision.detector.enabled: + if is_enabled(FLAG_DETECTOR_ENABLED): detect_objects.delay(photo_id) - if settings.vision.faces.enabled: + if is_enabled(FLAG_FACES_ENABLED): extract_faces.delay(photo_id) - if settings.vision.classifier.enabled: + if is_enabled(FLAG_CLASSIFIER_ENABLED): classify_content.delay(photo_id) return {'status': 'dispatched', 'photo_id': photo_id} @@ -148,7 +156,7 @@ def vision_fanout(photo_id: str): @shared_task(name='ocr_photo', queue='vision', bind=True, max_retries=3) def ocr_photo(self, photo_id: str): """Run OCR on a photo and store text regions.""" - if not settings.vision.enabled or not settings.vision.ocr.enabled: + if not is_enabled(FLAG_VISION_ENABLED) or not is_enabled(FLAG_OCR_ENABLED): return {'status': 'skipped', 'reason': 'OCR disabled'} image = _load_thumb(photo_id, "large") # 1280px for better OCR accuracy @@ -195,7 +203,7 @@ def ocr_photo(self, photo_id: str): def detect_objects(self, photo_id: str): """Detect objects in a photo, create Tag(kind=object) rows, and link via photo_tags with confidence/bbox/source.""" - if not settings.vision.enabled or not settings.vision.detector.enabled: + if not is_enabled(FLAG_VISION_ENABLED) or not is_enabled(FLAG_DETECTOR_ENABLED): return {'status': 'skipped', 'reason': 'detection disabled'} image = _load_thumb(photo_id, "medium") # 640px @@ -279,7 +287,7 @@ def detect_objects(self, photo_id: str): def classify_content(self, photo_id: str): """Classify image content type (screenshot, document, artwork, etc.) using CLIP zero-shot classification. Writes Tag(kind=content_type).""" - if not settings.vision.enabled or not settings.vision.classifier.enabled: + if not is_enabled(FLAG_VISION_ENABLED) or not is_enabled(FLAG_CLASSIFIER_ENABLED): return {'status': 'skipped', 'reason': 'classifier disabled'} image = _load_thumb(photo_id, "medium") @@ -394,7 +402,7 @@ def extract_faces(self, photo_id: str): """Detect faces and store recognition embeddings using InsightFace (RetinaFace + ArcFace). No YOLO workaround needed — RetinaFace has strong human-vs-non-human precision on its own.""" - if not settings.vision.enabled or not settings.vision.faces.enabled: + if not is_enabled(FLAG_VISION_ENABLED) or not is_enabled(FLAG_FACES_ENABLED): return {'status': 'skipped', 'reason': 'faces disabled'} image = _load_original(photo_id) @@ -478,7 +486,7 @@ def recluster_faces(self): logger.info("Vision worker not ready yet — retrying in 30s") raise self.retry(countdown=30) - if not settings.vision.enabled or not settings.vision.faces.enabled: + if not is_enabled(FLAG_VISION_ENABLED) or not is_enabled(FLAG_FACES_ENABLED): return {'status': 'skipped', 'reason': 'faces disabled'} from app.models import Photo @@ -603,7 +611,7 @@ def backfill_vision(self, task: str | None = None, limit: int | None = None): embed_ids = [r[0] for r in session.execute(sa_text(sql), params).fetchall()] ocr_ids = [] - if task in ('ocr', None) and settings.vision.ocr.enabled: + if task in ('ocr', None) and is_enabled(FLAG_OCR_ENABLED): sql = f""" SELECT p.id FROM photos p LEFT JOIN ocr_text o ON o.photo_id = p.id @@ -613,7 +621,7 @@ def backfill_vision(self, task: str | None = None, limit: int | None = None): ocr_ids = [r[0] for r in session.execute(sa_text(sql), params).fetchall()] detect_ids = [] - if task in ('detect', None) and settings.vision.detector.enabled: + if task in ('detect', None) and is_enabled(FLAG_DETECTOR_ENABLED): sql = f""" SELECT p.id FROM photos p WHERE p.processing_status = 'completed' @@ -626,7 +634,7 @@ def backfill_vision(self, task: str | None = None, limit: int | None = None): detect_ids = [r[0] for r in session.execute(sa_text(sql), params).fetchall()] face_ids = [] - if task in ('faces', None) and settings.vision.faces.enabled: + if task in ('faces', None) and is_enabled(FLAG_FACES_ENABLED): sql = f""" SELECT p.id FROM photos p LEFT JOIN face_embeddings fe ON fe.photo_id = p.id @@ -636,7 +644,7 @@ def backfill_vision(self, task: str | None = None, limit: int | None = None): face_ids = [r[0] for r in session.execute(sa_text(sql), params).fetchall()] classify_ids = [] - if task in ('classify', None) and settings.vision.classifier.enabled: + if task in ('classify', None) and is_enabled(FLAG_CLASSIFIER_ENABLED): sql = f""" SELECT p.id FROM photos p WHERE p.processing_status = 'completed' diff --git a/backend/requirements.txt b/backend/requirements.txt index 780e53b..e9db2bc 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -18,7 +18,12 @@ flower==2.0.1 # Image processing # pyvips==2.2.1 # Optional - having compatibility issues, using Pillow as fallback -# rawpy==0.19.0 # Optional - numpy compatibility issues, using Pillow as fallback +rawpy==0.26.1 # RAW decoder (CR2/NEF/ARW/DNG/…). cp312 wheels + # ship with libraw bundled; the older 0.19 pin + # had numpy 2.x incompatibilities — 0.26 is fine + # with our numpy 1.26. iPhone ProRAW-style DNGs + # that aren't real RAW still fail here; thumbs.py + # falls back to opening them as JPEG in that case. pillow==10.2.0 pillow-heif==0.15.0 imagehash==4.3.1 # perceptual hash for duplicate detection diff --git a/frontend/src/components/dialogs/SettingsDialog.tsx b/frontend/src/components/dialogs/SettingsDialog.tsx index 2c8f92d..dcb0f31 100644 --- a/frontend/src/components/dialogs/SettingsDialog.tsx +++ b/frontend/src/components/dialogs/SettingsDialog.tsx @@ -15,15 +15,23 @@ import { Activity, FolderSearch, Shield, + Brain, + ScanText, + UserSquare2, + Boxes, + Tags as TagsIcon, + RotateCcw, } from 'lucide-react' import clsx from 'clsx' import { useQuery, useQueryClient } from '@tanstack/react-query' import { library, + admin as adminApi, type MediaType, type PipelineStage, type ScanStatus, type WorkerStatus, + type FeatureFlagSnapshot, } from '../../services/api' import { toast } from '../ToastContainer' import { useAuth } from '../../contexts/AuthContext' @@ -42,13 +50,16 @@ const SETTINGS_SCAN_STATUS_KEY = ['settings', 'scan-status'] as const // the grid renders from. Imported via the canonical hook key. import { DUPLICATE_GROUPS_QUERY_KEY } from '../../hooks/useDuplicateGroupsQuery' -type SettingsTab = 'library' | 'users' +type SettingsTab = 'library' | 'ai' | 'users' const TABS: { id: SettingsTab; label: string; adminOnly?: boolean }[] = [ { id: 'library', label: 'Library Management' }, + { id: 'ai', label: 'AI Features', adminOnly: true }, { id: 'users', label: 'Users', adminOnly: true }, ] +const SETTINGS_FEATURE_FLAGS_KEY = ['settings', 'feature-flags'] as const + /** * Full-page settings view with tabbed navigation. Replaces the old * modal dialog — renders as a top-level section in the main content @@ -842,6 +853,13 @@ export function SettingsPage() { )} + {activeTab === 'ai' && isAdmin && ( + + )} + {activeTab === 'users' && isAdmin && (
} @@ -1073,3 +1091,267 @@ function ActionButton({ ) } + + +// --------------------------------------------------------------------------- +// AI Features admin tab +// --------------------------------------------------------------------------- + +interface AiFeaturesTabProps { + busy: Record + runAction: ( + key: string, + fn: () => Promise, + successTitle: string, + describe?: (result: T) => string | undefined, + ) => Promise +} + +// Flags are keyed by the backend's canonical name ("vision.enabled", +// "vision.ocr.enabled", ...). The metadata here just adds presentation +// (label, short description, icon) so the tab layout stays data-driven. +const FLAG_META: Array<{ + id: string + label: string + description: string + icon: React.ReactNode + // Optional "run this backfill" hook — lets the user kick off a stage's + // backfill right from the toggle row without hopping to a separate UI. + backfillTask?: 'embed' | 'ocr' | 'detect' | 'faces' | 'classify' +}> = [ + { + id: 'vision.enabled', + label: 'Vision pipeline (master switch)', + description: + 'When off, every AI stage below is skipped — including newly uploaded photos. ' + + 'Existing results stay intact.', + icon: , + }, + { + id: 'vision.ocr.enabled', + label: 'Text recognition (OCR)', + description: 'Extract printed / handwritten text from photos so it becomes searchable.', + icon: , + backfillTask: 'ocr', + }, + { + id: 'vision.detector.enabled', + label: 'Object detection', + description: 'Tag photos with detected objects (person, car, dog, …) via YOLOv8n.', + icon: , + backfillTask: 'detect', + }, + { + id: 'vision.faces.enabled', + label: 'Face recognition', + description: + 'Find and cluster faces across the library (RetinaFace + ArcFace). ' + + 'Expensive on big libraries — disable if you don\'t need the People view.', + icon: , + backfillTask: 'faces', + }, + { + id: 'vision.classifier.enabled', + label: 'Content classification', + description: 'Zero-shot CLIP tags for scenes / activities (beach, wedding, …).', + icon: , + backfillTask: 'classify', + }, +] + +function AiFeaturesTab({ busy, runAction }: AiFeaturesTabProps) { + const queryClient = useQueryClient() + const flagsQuery = useQuery<{ flags: FeatureFlagSnapshot }>({ + queryKey: SETTINGS_FEATURE_FLAGS_KEY, + queryFn: adminApi.listFeatureFlags, + staleTime: 5_000, + }) + + const flags = flagsQuery.data?.flags ?? {} + const masterOff = flags['vision.enabled'] && !flags['vision.enabled'].effective + + const applyFlag = async (name: string, value: boolean | null) => { + await runAction( + `flag:${name}`, + () => adminApi.setFeatureFlag(name, value), + value === null ? 'Override cleared' : `Feature ${value ? 'enabled' : 'disabled'}`, + ) + queryClient.invalidateQueries({ queryKey: SETTINGS_FEATURE_FLAGS_KEY }) + // Non-admin feature map drives sidebar gating — invalidate so the + // People / Tags entries appear / disappear immediately without a + // page reload. + queryClient.invalidateQueries({ queryKey: ['features'] }) + } + + type BackfillTask = 'embed' | 'ocr' | 'detect' | 'faces' | 'classify' | null + const runBackfill = (task: BackfillTask) => + runAction( + `ai-backfill:${task ?? 'all'}`, + () => adminApi.triggerAiBackfill({ task }), + task ? `Backfill queued for ${task}` : 'Full backfill queued', + (r) => `Celery task ${r.task_id}`, + ) + + return ( + <> +
} title="AI feature flags"> +

+ Toggle each stage at runtime. Changes are observed by Celery + workers on the next task — no restart needed. "Default" means + the flag hasn\'t been overridden and is tracking the YAML config; + an overridden flag is pinned to the value shown until cleared. +

+ + {flagsQuery.isLoading && ( +
+ + Loading feature flags… +
+ )} + {flagsQuery.error && ( + + )} + + {!flagsQuery.isLoading && !flagsQuery.error && ( +
+ {FLAG_META.map((meta) => { + const state = flags[meta.id] + if (!state) return null + const busyKey = `flag:${meta.id}` + const isBusy = !!busy[busyKey] + const isMaster = meta.id === 'vision.enabled' + const dimmed = !isMaster && masterOff + return ( +
+
+
+
+ {meta.icon} + {meta.label} + {state.overridden && ( + + overridden + + )} +
+

{meta.description}

+

+ Default: {state.default ? 'on' : 'off'} · Currently:{' '} + + {state.effective ? 'on' : 'off'} + +

+
+
+ + {state.overridden && ( + + )} +
+
+ + {meta.backfillTask && state.effective && !masterOff && ( +
+ runBackfill(meta.backfillTask!)} + > + + Run {meta.backfillTask} backfill + +
+ )} +
+ ) + })} +
+ )} +
+ +
} title="Manual pipeline triggers"> +

+ Run a full pass across the enabled stages, recompute face + clusters, or force a fresh filesystem scan. All three are safe + to run repeatedly — the backfill only touches photos that + don\'t yet have a given output, and the rescan skips files + that are already indexed. +

+
+ runBackfill(null)} + disabled={masterOff} + > + + Run full vision backfill + + + runAction( + 'recluster', + () => adminApi.triggerFaceRecluster(), + 'Face recluster queued', + (r) => `Celery task ${r.task_id}`, + ) + } + disabled={masterOff || !flags['vision.faces.enabled']?.effective} + > + + Recluster faces + + + runAction( + 'rescan-full', + () => adminApi.triggerFullRescan(), + 'Rescan queued', + (r) => `Celery task ${r.task_id}`, + ) + } + > + + Rescan all source roots + +
+
+ + ) +} diff --git a/frontend/src/components/heaps/HeapsPanel.tsx b/frontend/src/components/heaps/HeapsPanel.tsx index 125f750..53dd589 100644 --- a/frontend/src/components/heaps/HeapsPanel.tsx +++ b/frontend/src/components/heaps/HeapsPanel.tsx @@ -11,12 +11,13 @@ import { Copy, Trash2, Users, + Download as DownloadIcon, } from 'lucide-react' import clsx from 'clsx' import { useMutation, useQueryClient } from '@tanstack/react-query' import { useHeapsQuery, HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery' import { useSharedHeapsQuery } from '../../hooks/useSharingQueries' -import { heaps as heapsApi, type Heap } from '../../services/api' +import { heaps as heapsApi, downloads, type Heap } from '../../services/api' import { useFilterStore } from '../../store/filterStore' import { toast } from '../ToastContainer' import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail' @@ -448,6 +449,14 @@ export function HeapsPanel() { setSharingHeap(heap) }} /> + } + label="Download as zip" + onClick={() => { + setOpenMenuId(null) + downloads.trigger(downloads.heapUrl(heap.id)) + }} + />
} diff --git a/frontend/src/components/layout/LeftSidebar.tsx b/frontend/src/components/layout/LeftSidebar.tsx index c07ff4d..f79dc47 100644 --- a/frontend/src/components/layout/LeftSidebar.tsx +++ b/frontend/src/components/layout/LeftSidebar.tsx @@ -24,9 +24,11 @@ import { LogOut, Shield, Clock, + Upload as UploadIcon, + Download as DownloadIcon, } from 'lucide-react' import clsx from 'clsx' -import { sourceFolders, photos as photosApi, type FolderTreeNode } from '../../services/api' +import { sourceFolders, photos as photosApi, downloads, type FolderTreeNode } from '../../services/api' import { useMutation, useQueryClient } from '@tanstack/react-query' import { toast } from '../ToastContainer' import { useFilterStore } from '../../store/filterStore' @@ -45,8 +47,10 @@ import { registerUndoable } from '../../store/undoStore' import type { Photo } from '../../types/photo' import { DeleteFolderDialog } from '../dialogs/DeleteFolderDialog' import { ShareDialog } from '../sharing/ShareDialog' +import { UploadModal } from '../upload/UploadModal' import { useSharedFoldersQuery } from '../../hooks/useSharingQueries' import { useAuth } from '../../contexts/AuthContext' +import { useFeaturesQuery } from '../../hooks/useFeaturesQuery' interface TreeItem { id: string @@ -78,6 +82,15 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) { const { data: allTags = [] } = useTagsQuery() const { data: faceClusters = [] } = useTagsQuery('face_cluster') const { data: stats } = useLibraryStatsQuery() + const { data: featuresMap } = useFeaturesQuery() + const visionOn = featuresMap ? featuresMap['vision.enabled'] !== false : true + const facesOn = visionOn && (featuresMap ? featuresMap['vision.faces.enabled'] !== false : true) + const tagsOn = + visionOn && + (featuresMap + ? featuresMap['vision.detector.enabled'] !== false || + featuresMap['vision.classifier.enabled'] !== false + : true) const [dropTargetId, setDropTargetId] = useState(null) // Per-folder kebab menu open state. Stores the tree-item id ("folder-..." @@ -117,6 +130,14 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) { id: string name: string } | null>(null) + // Upload modal state. `uploadTarget` stores the pre-selected + // destination Folder/SourceRoot id so "Upload here…" on a folder row + // drops files straight into that folder; null means the general + // Library-level button (defaults to the first source root). + const [uploadTarget, setUploadTarget] = useState<{ open: boolean; folderId: string | null }>({ + open: false, + folderId: null, + }) const { data: sharedFolders = [] } = useSharedFoldersQuery() // Bulk discard mutation for the drag-onto-Discarded interaction. @@ -414,8 +435,8 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) { children: [ { id: 'all-photos', label: 'All Photos', icon: , count: stats?.all_photos ?? 0 }, { id: 'rated', label: 'Rated', icon: , count: stats?.rated ?? 0 }, - { id: 'tags', label: 'Tags', icon: , count: tagsTotalCount }, - { id: 'people', label: 'People', icon: , count: peopleTotalCount }, + ...(tagsOn ? [{ id: 'tags', label: 'Tags', icon: , count: tagsTotalCount }] : []), + ...(facesOn ? [{ id: 'people', label: 'People', icon: , count: peopleTotalCount }] : []), { id: 'colors', label: 'Colors', icon: , count: stats?.colored ?? 0 }, { id: 'map', label: 'Map', icon: , count: stats?.with_gps ?? 0 }, { id: 'memories', label: 'Memories', icon: }, @@ -670,6 +691,14 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) { onClick={(e) => e.stopPropagation()} className="absolute right-0 top-full z-30 mt-1 min-w-[180px] overflow-hidden rounded-lg border border-border bg-surface py-1 text-sm shadow-xl" > + } + label="Upload here…" + onClick={() => { + setOpenMenuId(null) + setUploadTarget({ open: true, folderId }) + }} + /> } label="New sub-folder" @@ -718,6 +747,14 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) { setSharingFolder({ id: folderId, name: item.label }) }} /> + } + label="Download as zip" + onClick={() => { + setOpenMenuId(null) + downloads.trigger(downloads.folderUrl(folderId)) + }} + />
} @@ -801,14 +838,24 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) {

Library

- +
+ + +
{/* Active heap card — pinned just below the Library header so * toasts (bottom-left fixed) can't cover it. Returns null when @@ -922,6 +969,11 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) { targetName={sharingFolder?.name ?? ''} onClose={() => setSharingFolder(null)} /> + setUploadTarget({ open: false, folderId: null })} + />
) } diff --git a/frontend/src/components/upload/UploadModal.tsx b/frontend/src/components/upload/UploadModal.tsx new file mode 100644 index 0000000..c2af59c --- /dev/null +++ b/frontend/src/components/upload/UploadModal.tsx @@ -0,0 +1,608 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { + ChevronDown, + ChevronRight, + Folder as FolderIcon, + Upload as UploadIcon, + X, + FileImage, + CheckCircle2, + AlertCircle, +} from 'lucide-react' +import clsx from 'clsx' +import { useQueryClient } from '@tanstack/react-query' +import { uploads, type FolderTreeNode } from '../../services/api' +import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery' +import { useHeapsQuery, HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery' +import { FOLDER_TREE_QUERY_KEY } from '../../hooks/useFolderTreeQuery' +import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery' +import { toast } from '../ToastContainer' + +interface UploadModalProps { + isOpen: boolean + onClose: () => void + /** Optional pre-selected destination. Accepts a Folder id or SourceRoot + * id — the backend resolves source roots to their root Folder row. */ + initialFolderId?: string | null +} + +interface QueuedFile { + /** Stable key; the browser may give us multiple files with the same + * name from different subfolders, so we key on index + path. */ + key: string + file: File + relativePath: string + status: 'pending' | 'uploading' | 'done' | 'error' + progress: number + error?: string +} + +const SUPPORTED_EXTENSIONS = new Set([ + '.jpg', '.jpeg', '.png', '.tiff', '.tif', '.webp', '.bmp', + '.cr2', '.cr3', '.nef', '.arw', '.raf', '.dng', '.orf', '.rw2', '.pef', '.srw', + '.heic', '.heif', + '.mp4', '.mov', '.avi', '.mkv', '.mts', '.m2ts', '.3gp', '.wmv', '.flv', +]) + +function extOf(name: string): string { + const i = name.lastIndexOf('.') + return i === -1 ? '' : name.slice(i).toLowerCase() +} + +function isSupported(name: string): boolean { + return SUPPORTED_EXTENSIONS.has(extOf(name)) +} + +const MAX_PARALLEL = 4 + +/** Walk the tree to collect the ids on the path from root to `targetId`, + * excluding the target itself — used to expand ancestor rows so a + * pre-selected destination is visible. */ +function collectAncestors(tree: FolderTreeNode[], targetId: string): string[] { + const path: string[] = [] + const walk = (nodes: FolderTreeNode[], chain: string[]): boolean => { + for (const n of nodes) { + if (n.id === targetId) { + path.push(...chain) + return true + } + if (n.children && walk(n.children, [...chain, n.id])) return true + } + return false + } + walk(tree, []) + return path +} + +/** + * Upload from desktop. Supports: + * - Dropping files or folders onto the drop zone + * - Picking files with "Select files" + * - Picking a whole folder with "Select folder" (webkitdirectory); + * every file's webkitRelativePath is sent to the backend so sub- + * folder structure is preserved under the chosen destination. + * + * Destination is a Folder row (or a source root, which the backend + * resolves to its root folder). An optional heap can also be chosen — + * uploaded photos are added to that heap in the same request. + */ +export function UploadModal({ isOpen, onClose, initialFolderId }: UploadModalProps) { + const queryClient = useQueryClient() + const { data: folderTree } = useFolderTreeQuery() + const { data: allHeaps = [] } = useHeapsQuery() + + const [queue, setQueue] = useState([]) + const [destFolderId, setDestFolderId] = useState(null) + const [destHeapId, setDestHeapId] = useState(null) + const [isUploading, setIsUploading] = useState(false) + const [dragOver, setDragOver] = useState(false) + const [expandedFolders, setExpandedFolders] = useState>(new Set()) + + const fileInputRef = useRef(null) + const dirInputRef = useRef(null) + const abortRef = useRef(null) + + // Default destination: caller-provided initialFolderId wins; otherwise + // first source root in the tree. Re-runs when the modal is re-opened + // with a different initial target so the right row is highlighted. + useEffect(() => { + if (!isOpen) return + if (initialFolderId) { + setDestFolderId(initialFolderId) + // Expand every ancestor of the pre-selected folder so the row is + // actually visible in the tree. + if (folderTree) { + const ancestors = collectAncestors(folderTree, initialFolderId) + setExpandedFolders((prev) => new Set([...prev, ...ancestors])) + } + return + } + if (!destFolderId && folderTree && folderTree.length > 0) { + setDestFolderId(folderTree[0].id) + setExpandedFolders(new Set([folderTree[0].id])) + } + }, [isOpen, initialFolderId, folderTree, destFolderId]) + + // Esc closes (unless mid-upload — don't orphan in-flight requests). + useEffect(() => { + if (!isOpen) return + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape' && !isUploading) onClose() + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, [isOpen, isUploading, onClose]) + + // Reset transient state on open so a previous session's queue doesn't + // bleed into a fresh one. + useEffect(() => { + if (isOpen) { + setQueue([]) + setIsUploading(false) + } + }, [isOpen]) + + const addFiles = (incoming: File[], relPathFn?: (f: File) => string) => { + const next: QueuedFile[] = [] + let skipped = 0 + for (const file of incoming) { + const relPath = (relPathFn?.(file) ?? '').replace(/\\/g, '/').replace(/^\/+/, '') + const filename = relPath || file.name + if (!isSupported(filename)) { + skipped++ + continue + } + next.push({ + key: `${relPath || file.name}::${file.size}::${file.lastModified}::${next.length}`, + file, + relativePath: relPath, + status: 'pending', + progress: 0, + }) + } + if (skipped > 0) { + toast.info?.(`Skipped ${skipped} unsupported file${skipped === 1 ? '' : 's'}`) + } + setQueue((prev) => [...prev, ...next]) + } + + const handleFilePick = (e: React.ChangeEvent) => { + const files = Array.from(e.target.files ?? []) + addFiles(files, (f) => f.name) // no relative path for single files + e.target.value = '' // allow re-picking the same file + } + + const handleDirPick = (e: React.ChangeEvent) => { + const files = Array.from(e.target.files ?? []) + addFiles(files, (f) => (f as File & { webkitRelativePath?: string }).webkitRelativePath || f.name) + e.target.value = '' + } + + // Drag-and-drop handler. We walk the DataTransferItemList recursively + // with webkitGetAsEntry so dropped folders contribute every nested + // file, with relative paths reconstructed from the entry chain. + const handleDrop = async (e: React.DragEvent) => { + e.preventDefault() + setDragOver(false) + const items = Array.from(e.dataTransfer.items) + const collected: { file: File; relativePath: string }[] = [] + + const walkEntry = (entry: any, pathPrefix: string): Promise => { + return new Promise((resolve) => { + if (!entry) return resolve() + if (entry.isFile) { + entry.file((f: File) => { + collected.push({ + file: f, + relativePath: pathPrefix ? `${pathPrefix}/${entry.name}` : '', + }) + resolve() + }, () => resolve()) + } else if (entry.isDirectory) { + const reader = entry.createReader() + const readBatch = () => { + reader.readEntries(async (entries: any[]) => { + if (!entries.length) return resolve() + const childPrefix = pathPrefix ? `${pathPrefix}/${entry.name}` : entry.name + await Promise.all(entries.map((c) => walkEntry(c, childPrefix))) + // readEntries only returns a batch at a time; loop until empty. + readBatch() + }, () => resolve()) + } + readBatch() + } else { + resolve() + } + }) + } + + await Promise.all( + items.map((it) => { + const entry = (it as DataTransferItem & { webkitGetAsEntry?: () => any }).webkitGetAsEntry?.() + return walkEntry(entry, '') + }) + ) + + if (collected.length === 0) { + // Fallback for browsers without webkitGetAsEntry — use plain files. + const files = Array.from(e.dataTransfer.files) + addFiles(files, (f) => f.name) + return + } + + const files = collected.map((c) => c.file) + const pathMap = new Map(collected.map((c) => [c.file, c.relativePath])) + addFiles(files, (f) => pathMap.get(f) || f.name) + } + + const removeFromQueue = (key: string) => { + setQueue((prev) => prev.filter((q) => q.key !== key)) + } + + const startUpload = async () => { + if (!destFolderId || queue.length === 0) return + setIsUploading(true) + const ctrl = new AbortController() + abortRef.current = ctrl + + // Simple worker-pool: up to MAX_PARALLEL concurrent uploads. + const pending = queue.filter((q) => q.status === 'pending' || q.status === 'error') + let cursor = 0 + let successCount = 0 + let failCount = 0 + + const uploadOne = async (item: QueuedFile) => { + setQueue((prev) => + prev.map((q) => (q.key === item.key ? { ...q, status: 'uploading', progress: 0, error: undefined } : q)) + ) + try { + await uploads.uploadFile(item.file, destFolderId, { + relativePath: item.relativePath || undefined, + heapId: destHeapId, + signal: ctrl.signal, + onProgress: (loaded, total) => { + const pct = total > 0 ? loaded / total : 0 + setQueue((prev) => + prev.map((q) => (q.key === item.key ? { ...q, progress: pct } : q)) + ) + }, + }) + successCount++ + setQueue((prev) => + prev.map((q) => (q.key === item.key ? { ...q, status: 'done', progress: 1 } : q)) + ) + } catch (err: any) { + failCount++ + const msg = err?.response?.data?.detail || err?.message || 'Upload failed' + setQueue((prev) => + prev.map((q) => (q.key === item.key ? { ...q, status: 'error', error: msg } : q)) + ) + } + } + + const workers: Promise[] = [] + for (let i = 0; i < Math.min(MAX_PARALLEL, pending.length); i++) { + workers.push( + (async () => { + while (cursor < pending.length && !ctrl.signal.aborted) { + const idx = cursor++ + await uploadOne(pending[idx]) + } + })() + ) + } + await Promise.all(workers) + + setIsUploading(false) + abortRef.current = null + + // Refresh everything affected by new photos. + queryClient.invalidateQueries({ queryKey: FOLDER_TREE_QUERY_KEY }) + queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY }) + queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY }) + queryClient.invalidateQueries({ queryKey: ['photos'] }) + + if (successCount > 0) { + toast.success( + `Uploaded ${successCount} file${successCount === 1 ? '' : 's'}`, + failCount > 0 ? `${failCount} failed — see list` : undefined + ) + } + if (successCount === 0 && failCount > 0) { + toast.error('Upload failed', `${failCount} file${failCount === 1 ? '' : 's'} errored`) + } + } + + const cancelUpload = () => { + abortRef.current?.abort() + } + + const totalBytes = useMemo(() => queue.reduce((s, q) => s + q.file.size, 0), [queue]) + const uploadedBytes = useMemo( + () => queue.reduce((s, q) => s + q.file.size * (q.status === 'done' ? 1 : q.progress), 0), + [queue] + ) + const overallPct = totalBytes > 0 ? Math.round((uploadedBytes / totalBytes) * 100) : 0 + + if (!isOpen) return null + + return ( +
+
+
+
+ {/* Header */} +
+
+ +

Upload photos

+
+ +
+ + {/* Body */} +
+ {/* Left: destination picker */} +
+
+ +
+ {folderTree && folderTree.length > 0 ? ( + folderTree.map((n) => ( + + setExpandedFolders((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } + /> + )) + ) : ( +
No folders yet.
+ )} +
+
+ +
+ + +
+
+ + {/* Right: drop zone + queue */} +
+
{ + e.preventDefault() + setDragOver(true) + }} + onDragLeave={() => setDragOver(false)} + onDrop={handleDrop} + className={clsx( + 'flex flex-col items-center justify-center rounded border-2 border-dashed px-4 py-6 text-center transition-colors', + dragOver + ? 'border-primary bg-primary/10' + : 'border-border bg-bg' + )} + > + +
+ Drop files or folders here +
+
+ Folder structure is preserved under the destination. +
+
+ + +
+ + +
+ + {/* Queue */} +
+ {queue.length === 0 ? ( +
+ No files added yet. +
+ ) : ( +
    + {queue.map((item) => ( +
  • + +
    +
    + {item.relativePath || item.file.name} +
    + {item.status === 'uploading' && ( +
    +
    +
    + )} + {item.status === 'error' && item.error && ( +
    {item.error}
    + )} +
    +
    + {item.status === 'done' && } + {item.status === 'error' && } + {item.status !== 'done' && !isUploading && ( + + )} +
    +
  • + ))} +
+ )} +
+ + {queue.length > 0 && ( +
+ {queue.length} file{queue.length === 1 ? '' : 's'} •{' '} + {(totalBytes / 1024 / 1024).toFixed(1)} MB + {isUploading && ` • ${overallPct}% uploaded`} +
+ )} +
+
+ + {/* Footer */} +
+ + +
+
+
+
+ ) +} + +interface FolderTreeRowProps { + node: FolderTreeNode + depth: number + selectedId: string | null + onSelect: (id: string) => void + expanded: Set + onToggle: (id: string) => void +} + +function FolderTreeRow({ + node, + depth, + selectedId, + onSelect, + expanded, + onToggle, +}: FolderTreeRowProps) { + const isExpanded = expanded.has(node.id) + const hasChildren = node.children && node.children.length > 0 + const isSelected = selectedId === node.id + + return ( + <> +
onSelect(node.id)} + > + + + {node.name} +
+ {isExpanded && + node.children?.map((c) => ( + + ))} + + ) +} diff --git a/frontend/src/hooks/useFeaturesQuery.ts b/frontend/src/hooks/useFeaturesQuery.ts new file mode 100644 index 0000000..201ce2d --- /dev/null +++ b/frontend/src/hooks/useFeaturesQuery.ts @@ -0,0 +1,39 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { features, type FeaturesMap } from '../services/api' + +export const FEATURES_QUERY_KEY = ['features'] as const + +/** Read the effective feature-flag state (admin override or YAML + * default). Powers conditional rendering of pipeline-dependent UI — + * People view, Tags view, OCR snippets, etc. */ +export function useFeaturesQuery() { + return useQuery({ + queryKey: FEATURES_QUERY_KEY, + queryFn: features.list, + // Re-read every minute so admin toggles reflect without a page + // reload. The admin tab also invalidates this key on write so the + // refresh can be immediate for the admin who just flipped it. + staleTime: 60_000, + refetchInterval: 60_000, + }) +} + +export function useIsFeatureEnabled( + name: + | 'vision.enabled' + | 'vision.ocr.enabled' + | 'vision.detector.enabled' + | 'vision.faces.enabled' + | 'vision.classifier.enabled', +): boolean { + const { data } = useFeaturesQuery() + // Default to enabled while loading so we don't flash "feature off" + // during a first-paint fetch. The backend is the source of truth; + // any gated UI that slipped through just returns empty data anyway. + if (!data) return true + return !!data[name] +} + +export function invalidateFeaturesQuery(queryClient: ReturnType) { + queryClient.invalidateQueries({ queryKey: FEATURES_QUERY_KEY }) +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 5c237f6..0d33764 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -713,6 +713,72 @@ export const heaps = { }, } +// Upload API — single file per request so the browser can fan out many +// POSTs in parallel with per-file progress. For folder uploads the +// caller passes each File's webkitRelativePath so the backend can +// materialise the folder structure under the destination. +export const uploads = { + uploadFile: async ( + file: File, + destinationFolderId: string, + opts: { + relativePath?: string + heapId?: string | null + onProgress?: (loadedBytes: number, totalBytes: number) => void + signal?: AbortSignal + } = {} + ) => { + const form = new FormData() + form.append('file', file) + form.append('destination_folder_id', destinationFolderId) + if (opts.relativePath) form.append('relative_path', opts.relativePath) + if (opts.heapId) form.append('heap_id', opts.heapId) + + const response = await api.post('/upload', form, { + headers: { 'Content-Type': 'multipart/form-data' }, + signal: opts.signal, + onUploadProgress: (evt) => { + if (opts.onProgress && evt.total) opts.onProgress(evt.loaded, evt.total) + }, + }) + return response.data as { + photo_id: string + filename: string + folder_id: string + folder_path: string + heap_id: string | null + } + }, +} + +// Download helpers — build a URL the browser can pull directly via an +//
. The backend accepts `?token=` so an works without a +// custom fetch + save-blob dance; the Authorization header is not +// settable on a plain link click. +export const downloads = { + folderUrl: (folderId: string): string => { + const token = localStorage.getItem('access_token') || '' + return `${API_BASE_URL}/download/folders/${folderId}?token=${encodeURIComponent(token)}` + }, + heapUrl: (heapId: string): string => { + const token = localStorage.getItem('access_token') || '' + return `${API_BASE_URL}/download/heaps/${heapId}?token=${encodeURIComponent(token)}` + }, + trigger: (url: string) => { + // Kicking off a download via a transient click keeps the + // browser in charge of the file dialog + progress indicator. We + // use target=_blank so the current SPA route isn't replaced if + // the server returns an error mid-stream. + const a = document.createElement('a') + a.href = url + a.rel = 'noopener' + a.target = '_blank' + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + }, +} + // Sharing API export interface SharedHeap { @@ -928,6 +994,61 @@ export const admin = { const response = await api.delete(`/admin/users/${userId}`) return response.data }, + + // --- AI / vision feature flags + manual pipeline triggers ----------- + + listFeatureFlags: async (): Promise<{ flags: FeatureFlagSnapshot }> => { + const response = await api.get('/admin/feature-flags') + return response.data + }, + + /** Set ``value`` to toggle; pass ``null`` to clear the override and + * fall back to the YAML default. */ + setFeatureFlag: async ( + name: string, + value: boolean | null, + ): Promise<{ flags: FeatureFlagSnapshot }> => { + const response = await api.patch(`/admin/feature-flags/${encodeURIComponent(name)}`, { value }) + return response.data + }, + + triggerAiBackfill: async (body: { + task?: 'embed' | 'ocr' | 'detect' | 'faces' | 'classify' | null + limit?: number | null + }): Promise<{ status: string; task_id: string }> => { + const response = await api.post('/admin/ai/backfill', body) + return response.data + }, + + triggerFaceRecluster: async (): Promise<{ status: string; task_id: string }> => { + const response = await api.post('/admin/ai/recluster-faces') + return response.data + }, + + triggerFullRescan: async (): Promise<{ status: string; task_id: string }> => { + const response = await api.post('/admin/ai/rescan') + return response.data + }, +} + +export interface FeatureFlagState { + effective: boolean + default: boolean + overridden: boolean +} + +export type FeatureFlagSnapshot = Record + +export type FeaturesMap = Record + +// Public read of effective feature flags. Available to any signed-in +// user so the frontend can hide sections that depend on a disabled +// pipeline stage (e.g. People when faces are off). +export const features = { + list: async (): Promise => { + const response = await api.get('/features') + return response.data + }, } export default api \ No newline at end of file