diff --git a/backend/Dockerfile b/backend/Dockerfile index b238c75..f9dbe1b 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -23,21 +23,9 @@ RUN apt-get update && apt-get install -y \ WORKDIR /app -# Install PyTorch CPU-only FIRST, in its own layer, so open-clip-torch -# doesn't pull the full CUDA build (~7 GB). CPU inference is all we need -# — the heavy lifting happens through ONNX Runtime. -# -# Two cache wins here: -# 1. Its own RUN layer means edits to requirements.txt don't force a -# re-pull of the ~200MB torch wheel. -# 2. The buildkit cache mount keeps pip's download cache on disk -# across builds even when the layer itself is invalidated, so a -# torch-version bump or a builder cache eviction still reuses the -# wheel from local cache instead of re-fetching from pytorch.org. -RUN --mount=type=cache,target=/root/.cache/pip \ - pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu - COPY requirements.txt . +# buildkit cache mount keeps pip's download cache on disk across builds +# so even when this layer is invalidated, wheels are reused locally. RUN --mount=type=cache,target=/root/.cache/pip \ pip install -r requirements.txt diff --git a/backend/alembic/versions/0019_drop_ai_remnants.py b/backend/alembic/versions/0019_drop_ai_remnants.py new file mode 100644 index 0000000..288e2e4 --- /dev/null +++ b/backend/alembic/versions/0019_drop_ai_remnants.py @@ -0,0 +1,47 @@ +"""Drop AI remnants: photos.needs_review and the pgvector extension + +Revision ID: 0019_drop_ai_remnants +Revises: 0018_photos_nextcloud_fileid +Create Date: 2026-05-14 + +The vision pipeline has been removed entirely (no more classifier, no +worker-vision service, no torch/onnxruntime/open-clip-torch deps). The +`needs_review` boolean and its partial index were populated only by +that classifier and have no remaining writers or readers. + +The pgvector extension was originally added by 0003_pgvector_embeddings +for the embeddings table that 0012_strip_ai_pipeline dropped; the +extension itself is now unused, and the next deploy switches the +Postgres image from pgvector/pgvector:pg16 to plain postgres:16. The +extension must be dropped *before* that image swap or the new +container will fail to load existing CREATE EXTENSION declarations. +""" +from typing import Sequence, Union + +from alembic import op + + +revision: str = "0019_drop_ai_remnants" +down_revision: Union[str, None] = "0018_photos_nextcloud_fileid" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute("DROP INDEX IF EXISTS ix_photos_needs_review") + op.execute("ALTER TABLE photos DROP COLUMN IF EXISTS needs_review") + op.execute("DROP EXTENSION IF EXISTS vector") + + +def downgrade() -> None: + # Re-create the column as a no-op (data is gone). The pgvector + # extension is intentionally NOT re-added — matches the precedent + # set by 0012_strip_ai_pipeline for its dropped tables. + op.execute( + "ALTER TABLE photos ADD COLUMN IF NOT EXISTS needs_review " + "BOOLEAN NOT NULL DEFAULT FALSE" + ) + op.execute( + "CREATE INDEX IF NOT EXISTS ix_photos_needs_review " + "ON photos(needs_review) WHERE needs_review" + ) diff --git a/backend/app/config.py b/backend/app/config.py index b6fe530..262c179 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -30,19 +30,6 @@ class PerformanceSettings(BaseModel): db_pool_max_overflow: int = 10 db_pool_recycle: int = 3600 -class ClassifierSettings(BaseModel): - """Binary content classifier (photography vs other).""" - min_confidence: float = 0.3 - -class VisionSettings(BaseModel): - """Vision pipeline — one binary classifier (photography vs other).""" - enabled: bool = True - backend: str = "onnx" - models_dir: str = "/data/models" - execution_providers: list[str] = ["CPUExecutionProvider"] - classifier: ClassifierSettings = ClassifierSettings() - worker_concurrency: int = 2 - class MulitaConfig(BaseModel): """Main configuration from YAML file. Source roots and the discard workflow are owned by the database now — only operational settings @@ -50,13 +37,12 @@ class MulitaConfig(BaseModel): thumbnails: ThumbnailSettings = ThumbnailSettings() scanner: ScannerSettings = ScannerSettings() performance: PerformanceSettings = PerformanceSettings() - vision: VisionSettings = VisionSettings() class Settings(BaseSettings): """Application settings""" - # Database — Postgres + pgvector by default. The SQLite escape hatch - # remains supported via the docker-compose.sqlite.yml override and by - # setting DATABASE_URL=sqlite+aiosqlite:///... in .env for local dev. + # Database — plain Postgres. The SQLite escape hatch remains + # supported via the docker-compose.sqlite.yml override and by setting + # DATABASE_URL=sqlite+aiosqlite:///... in .env for local dev. database_url: str = Field( default="postgresql+asyncpg://mulita:mulita@db:5432/mulita", env="DATABASE_URL" @@ -195,23 +181,6 @@ class Settings(BaseSettings): def performance(self) -> PerformanceSettings: return self.config.performance - # ONNX Runtime execution providers, overridable via env var. - # Comma-separated: "CUDAExecutionProvider,CPUExecutionProvider" - # or "auto" for GPU auto-detection. - vision_execution_providers: str = Field( - default="CPUExecutionProvider", - env="VISION_EXECUTION_PROVIDERS", - ) - - @property - def vision(self) -> VisionSettings: - v = self.config.vision - # Override execution_providers from env if set. - providers = [p.strip() for p in self.vision_execution_providers.split(",") if p.strip()] - if providers: - v.execution_providers = providers - return v - class Config: env_file = ".env" case_sensitive = False diff --git a/backend/app/main.py b/backend/app/main.py index 9ca5872..809dedc 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -12,7 +12,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, download, features, nextcloud, nc_webhook +from app.routers import photos, folders, heaps, tags, discard, library, search, auth, admin, sharing, download, nextcloud, nc_webhook from app.services.scanner import start_initial_scan, bootstrap_default_source_root from app.services.cleanup import cleanup_data_integrity from app.services.nextcloud_dav import init_preview_client, close_preview_client @@ -117,7 +117,6 @@ 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(download.router, prefix="/api/v1/download", tags=["download"]) -app.include_router(features.router, prefix="/api/v1/features", tags=["features"]) app.include_router(nextcloud.router, prefix="/api/v1/nextcloud", tags=["nextcloud"]) app.include_router(nc_webhook.router, prefix="/api/v1/internal", tags=["nc-webhook"]) diff --git a/backend/app/models/photos.py b/backend/app/models/photos.py index f7b707c..09e5cde 100644 --- a/backend/app/models/photos.py +++ b/backend/app/models/photos.py @@ -65,11 +65,6 @@ class Photo(Base): # rows, and POST /folders/{id}/hide recomputes it on toggle. is_hidden = Column(Boolean, nullable=False, default=False, server_default='false', index=True) - # "Needs review" — set by the content classifier when a photo is - # classified as 'other' (screenshot, document, meme, scan, etc.) so - # the user can page through non-photographs in the UI and triage them. - needs_review = Column(Boolean, nullable=False, default=False, server_default='false', index=True) - # "Capture date is probably wrong" — denormalized from the folder/filename # date-guesser. Set at scan time and recomputed on every taken_at edit so # the filter bar can query it directly. See services/date_guess.py for @@ -138,5 +133,4 @@ class Photo(Base): Index('ix_photos_media_type', 'media_type'), Index('ix_photos_processing_status', 'processing_status'), Index('ix_photos_lat_lon', 'latitude', 'longitude'), - Index('ix_photos_needs_review', 'needs_review'), ) \ No newline at end of file diff --git a/backend/app/models/tags.py b/backend/app/models/tags.py index 6391859..fcf615e 100644 --- a/backend/app/models/tags.py +++ b/backend/app/models/tags.py @@ -22,7 +22,7 @@ photo_tags = Table( # ML metadata — null for user-applied tags Column('confidence', Float, nullable=True), Column('bbox', JSONB, nullable=True), # [x1, y1, x2, y2] normalized 0-1 - Column('source', String, nullable=True), # e.g. "vision:clip_classifier" + Column('source', String, nullable=True), # null for user-applied tags Index('ix_photo_tags_photo_id', 'photo_id'), Index('ix_photo_tags_tag_id', 'tag_id'), ) @@ -44,9 +44,8 @@ class Tag(Base): kind = Column(String, nullable=False, default='user', index=True) # kind values: 'user' | 'content_type' - # Which model produced this tag (null for user-created) + # Which producer wrote this tag (null for user-created) source = Column(String, nullable=True) - # e.g. "vision:clip_classifier", null # Relationships photos = relationship("Photo", secondary=photo_tags, backref="tags") diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 3ad5154..27b04fc 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -18,14 +18,6 @@ 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__) @@ -266,104 +258,3 @@ 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 classifier backfill. ``limit`` caps how - many photos are queued.""" - limit: Optional[int] = None - - -@router.post("/ai/backfill") -async def trigger_ai_backfill( - body: BackfillVisionBody, - admin: User = Depends(require_admin), -): - """Queue a classifier backfill pass.""" - if not is_enabled(FLAG_VISION_ENABLED): - raise HTTPException( - status_code=400, - detail="Vision is currently disabled; enable it before running a backfill.", - ) - if body.limit is not None and body.limit <= 0: - raise HTTPException(status_code=400, detail="limit must be positive") - - from app.tasks.vision import backfill_vision - - result = backfill_vision.apply_async(kwargs={'limit': body.limit}) - logger.info( - f"Admin '{admin.username}' queued vision backfill " - f"(limit={body.limit}, celery_id={result.id})" - ) - return { - "status": "queued", - "task_id": result.id, - "limit": body.limit, - } - - -@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/features.py b/backend/app/routers/features.py deleted file mode 100644 index 25b4c3e..0000000 --- a/backend/app/routers/features.py +++ /dev/null @@ -1,23 +0,0 @@ -""" -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/library.py b/backend/app/routers/library.py index a460905..441eb1e 100644 --- a/backend/app/routers/library.py +++ b/backend/app/routers/library.py @@ -102,12 +102,6 @@ async def get_library_stats( ) ).scalar() or 0 - needs_review_count = ( - await db.execute( - select(func.count(Photo.id)).where(visible, Photo.needs_review.is_(True)) - ) - ).scalar() or 0 - # Legacy split (kept for the existing /stats consumers). photo_count = ( await db.execute( @@ -134,7 +128,6 @@ async def get_library_stats( "with_gps": with_gps_count, "duplicates": duplicates_count, "discarded": discarded_count, - "needs_review": needs_review_count, "total_photos": photo_count, "total_videos": video_count, "total_size": size, @@ -465,8 +458,7 @@ async def get_worker_status( r = _redis.Redis.from_url(settings.redis_url, socket_timeout=1.0) r.ping() broker_ok = True - # `vision` runs the content classifier — the only heavy queue. - for q in ('default', 'high', 'low', 'vision'): + for q in ('default', 'high', 'low'): try: queue_depths[q] = int(r.llen(q) or 0) except Exception: @@ -595,17 +587,6 @@ async def get_pipeline_stats( ) ) - # Classified: distinct photos with a content_type tag. - classified_done = await scalar_count( - select(func.count(func.distinct(photo_tags.c.photo_id))) - .select_from(photo_tags) - .join(Photo, Photo.id == photo_tags.c.photo_id) - .where(not_discarded, photo_tags.c.source == 'vision:clip_classifier') - ) - needs_review_count = await scalar_count( - select(func.count(Photo.id)).where(not_discarded, Photo.needs_review.is_(True)) - ) - duplicate_groups = await scalar_count( select(func.count(func.distinct(Photo.duplicate_group_id))).where( not_discarded, Photo.duplicate_group_id.is_not(None) @@ -649,13 +630,6 @@ async def get_pipeline_stats( "total": total_images, "hint": "Feeds duplicate detection.", }, - { - "key": "classification", - "label": "Content classification (photo vs other)", - "done": classified_done, - "total": total_images, - "hint": f"{needs_review_count} photos flagged for review.", - }, { "key": "duplicates", "label": "Duplicate groups", diff --git a/backend/app/routers/photos.py b/backend/app/routers/photos.py index 2d205de..389edfa 100644 --- a/backend/app/routers/photos.py +++ b/backend/app/routers/photos.py @@ -73,7 +73,6 @@ async def list_photos( color_label: Optional[str] = None, is_discarded: Optional[bool] = False, is_duplicate: Optional[bool] = None, - needs_review: Optional[bool] = None, has_date_warning: Optional[bool] = None, heap_id: Optional[str] = None, sort: str = "taken_at", @@ -244,8 +243,6 @@ async def list_photos( # view shows everything regardless of duplicate status. if is_duplicate is not None: filters.append(Photo.is_duplicate == is_duplicate) - if needs_review is not None: - filters.append(Photo.needs_review == needs_review) if has_date_warning is not None: filters.append(Photo.has_date_warning == has_date_warning) diff --git a/backend/app/schemas/photos.py b/backend/app/schemas/photos.py index 41cebde..0ad9fbb 100644 --- a/backend/app/schemas/photos.py +++ b/backend/app/schemas/photos.py @@ -39,7 +39,6 @@ class PhotoResponse(PhotoBase): latitude: Optional[float] = None longitude: Optional[float] = None is_duplicate: bool = False - needs_review: bool = False has_date_warning: bool = False live_photo_video_id: Optional[str] = None owner_username: Optional[str] = None diff --git a/backend/app/services/cleanup.py b/backend/app/services/cleanup.py index 14c8852..cd9fddd 100644 --- a/backend/app/services/cleanup.py +++ b/backend/app/services/cleanup.py @@ -299,6 +299,65 @@ async def prune_missing_photos(dry_run: bool = True) -> dict: raise +async def prune_orphan_thumbnails( + thumbs_root: str = "/data/thumbs", + dry_run: bool = True, +) -> dict: + """Remove `/data/thumbs/{user_id}/{photo_id}/` directories whose + photo_id no longer exists in the photos table. + + Layout was per-Phase-4 set up by app.tasks.thumbs and is keyed by + `{user_id}/{photo_id}/`. The thumbs worker never deletes its own + output on photo removal, so over the lifetime of a library these + directories accumulate. + + Set dry_run=False to actually `rm -rf` each matched directory. + Returns counts of matched / removed dirs and any per-dir errors. + """ + import shutil + + if not os.path.isdir(thumbs_root): + return { + "would_remove": 0, + "removed": 0, + "skipped_no_root": True, + "dry_run": dry_run, + } + + async with AsyncSessionLocal() as session: + live_ids = { + row[0] + for row in (await session.execute(select(Photo.id))).all() + } + + matched: list[str] = [] + errors: list[str] = [] + for user_dir in os.listdir(thumbs_root): + user_path = os.path.join(thumbs_root, user_dir) + if not os.path.isdir(user_path): + continue + for photo_dir in os.listdir(user_path): + if photo_dir in live_ids: + continue + matched.append(os.path.join(user_path, photo_dir)) + + removed = 0 + if not dry_run: + for path in matched: + try: + shutil.rmtree(path) + removed += 1 + except OSError as e: + errors.append(f"{path}: {e}") + + key = "would_remove" if dry_run else "removed" + return { + key: len(matched) if dry_run else removed, + "errors": errors, + "dry_run": dry_run, + } + + async def discard_missing_photos() -> dict: """Soft variant of prune_missing_photos for the periodic beat catch-up. Walks every active source root that is currently diff --git a/backend/app/services/feature_flags.py b/backend/app/services/feature_flags.py deleted file mode 100644 index 0b15cf3..0000000 --- a/backend/app/services/feature_flags.py +++ /dev/null @@ -1,137 +0,0 @@ -""" -Runtime feature flags for the vision pipeline. - -Only one flag now — the master vision switch. Runtime overrides live in -Redis under ``mulita:flags:``; an unset key falls back to the -YAML default. -""" -from __future__ import annotations - -import logging -from typing import Optional - -import redis - -from app.config import settings - -logger = logging.getLogger(__name__) - - -FLAG_VISION_ENABLED = 'vision.enabled' - -ALL_FLAGS = (FLAG_VISION_ENABLED,) - -_VISION_QUEUE = 'vision' - -_REDIS: Optional[redis.Redis] = None - - -def _redis() -> Optional[redis.Redis]: - 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: - if name == FLAG_VISION_ENABLED: - return bool(settings.vision.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: - 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: - 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: - 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) - - -def _apply_worker_side_effects(name: str) -> None: - """Attach or detach the vision consumer and purge queued work when - the master flag flips. Best-effort — state is already persisted.""" - if name != FLAG_VISION_ENABLED: - return - try: - 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 is_enabled(FLAG_VISION_ENABLED): - 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") - except Exception as e: - logger.warning(f"feature_flags: worker side effects failed: {e}") - - -def _purge_queue(queue: str) -> int: - r = _redis() - if r is None: - return 0 - try: - return int(r.delete(queue) or 0) - except Exception as e: - logger.warning(f"feature_flags: purge {queue} failed: {e}") - return 0 - - -def snapshot() -> dict[str, dict[str, object]]: - 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/services/nextcloud_dav.py b/backend/app/services/nextcloud_dav.py index 6b055cd..c144751 100644 --- a/backend/app/services/nextcloud_dav.py +++ b/backend/app/services/nextcloud_dav.py @@ -389,7 +389,7 @@ def get_preview_bytes( user: User, fileid: int, x: int, y: int ) -> Optional[bytes]: """Sync sibling of `get_preview_async` for callers in non-async - contexts (e.g. the vision celery worker, which is sync). + contexts. Returns the preview body on success, None on 404 / non-success / missing credentials. Caller is expected to feed the bytes into diff --git a/backend/app/services/vision/__init__.py b/backend/app/services/vision/__init__.py deleted file mode 100644 index 6977641..0000000 --- a/backend/app/services/vision/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -""" -Vision pipeline services — embedding, OCR, object detection, face recognition. - -All inference is done through the ModelRegistry singleton, which lazy-loads -ONNX Runtime sessions on first use and caches them for the lifetime of the -worker process. -""" diff --git a/backend/app/services/vision/base.py b/backend/app/services/vision/base.py deleted file mode 100644 index 1cdb0c7..0000000 --- a/backend/app/services/vision/base.py +++ /dev/null @@ -1,25 +0,0 @@ -""" -Abstract base classes for the vision backend. - -The pipeline is now a single binary classifier: photography vs other. -Feature extraction is an internal detail of the classifier and is not -exposed as a separate service. -""" -from abc import ABC, abstractmethod -from dataclasses import dataclass - -import numpy as np - - -@dataclass -class ClassificationResult: - label: str - confidence: float - - -class ContentClassifier(ABC): - """Classifies an image into 'photography' or 'other'.""" - - @abstractmethod - def classify(self, image: np.ndarray) -> ClassificationResult: - ... diff --git a/backend/app/services/vision/bootstrap_models.py b/backend/app/services/vision/bootstrap_models.py deleted file mode 100644 index 5df3631..0000000 --- a/backend/app/services/vision/bootstrap_models.py +++ /dev/null @@ -1,51 +0,0 @@ -""" -Ensure the OpenCLIP ViT-B/32 visual encoder is present on worker boot. -Exported via export_models.py if missing. -""" -import logging -from pathlib import Path - -from app.config import settings - -logger = logging.getLogger(__name__) - -REQUIRED = [ - ("embed/visual.onnx", "OpenCLIP ViT-B/32 visual encoder"), -] - - -def bootstrap(models_dir: str | None = None): - base = Path(models_dir or settings.vision.models_dir) - base.mkdir(parents=True, exist_ok=True) - - missing = [(rel, desc) for rel, desc in REQUIRED if not (base / rel).exists()] - if missing: - logger.warning("Missing %d model file(s); attempting automatic export", len(missing)) - try: - from app.services.vision import export_models - export_models.export_openclip_visual(base) - except Exception as e: - logger.error( - "Export failed: %s. Run `python -m app.services.vision.export_models " - "--models-dir %s` manually to retry.", - e, base, - ) - - still_missing = [(r, d) for r, d in REQUIRED if not (base / r).exists()] - if still_missing: - for rel, desc in still_missing: - logger.error(" still missing: %s — %s", base / rel, desc) - else: - logger.info("All model files present in %s", base) - - try: - import redis as _redis - _redis.from_url(settings.redis_url).set("mulita:vision:ready", "1") - logger.info("Set mulita:vision:ready in Redis") - except Exception as e: - logger.warning("Could not set vision readiness flag in Redis: %s", e) - - -if __name__ == "__main__": - logging.basicConfig(level=logging.INFO) - bootstrap() diff --git a/backend/app/services/vision/classify.py b/backend/app/services/vision/classify.py deleted file mode 100644 index d143154..0000000 --- a/backend/app/services/vision/classify.py +++ /dev/null @@ -1,106 +0,0 @@ -""" -Binary content classifier: 'photography' vs 'other'. - -Uses OpenCLIP ViT-B/32 image features (ONNX) and two pre-computed text -prompt centroids. Text centroids are computed once with the native -open_clip text encoder and cached to {models_dir}/classifier/vectors.npz -so steady-state worker startup doesn't pay the PyTorch cost. -""" -from __future__ import annotations - -import logging -from pathlib import Path - -import numpy as np - -from app.config import VisionSettings -from app.services.vision.base import ClassificationResult, ContentClassifier -from app.services.vision.embed import CLIPVisualEncoder - -logger = logging.getLogger(__name__) - - -PROMPTS = { - "photography": [ - "a photograph taken with a camera", - "a real photo of a real scene or person", - "a candid photograph", - "a portrait photograph", - "a landscape photograph", - ], - "other": [ - "a screenshot of a computer screen", - "a screenshot of a phone screen", - "a screen capture of a user interface", - "a scanned document", - "a photo of a document with printed text", - "a photo of a receipt", - "a photo of a bill or invoice", - "an internet meme with text overlay", - "a funny image with caption text", - "a digital illustration or graphic design", - ], -} - - -def _compute_text_centroids() -> dict[str, np.ndarray]: - """Compute the 'photography' and 'other' centroid vectors using the - open_clip text encoder. Only called on the cache-miss path.""" - import open_clip - import torch - - logger.info("Computing CLIP text centroids for binary classifier") - model, _, _ = open_clip.create_model_and_transforms( - "ViT-B-32", pretrained="laion2b_s34b_b79k" - ) - model.eval() - tokenizer = open_clip.get_tokenizer("ViT-B-32") - - centroids: dict[str, np.ndarray] = {} - for label, prompts in PROMPTS.items(): - tokens = tokenizer(prompts) - with torch.no_grad(): - feats = model.encode_text(tokens) - feats = feats / feats.norm(dim=-1, keepdim=True) - avg = feats.mean(dim=0) - avg = avg / avg.norm() - centroids[label] = avg.numpy().astype(np.float32) - return centroids - - -class CLIPContentClassifier(ContentClassifier): - def __init__(self, settings: VisionSettings): - self._min_confidence = settings.classifier.min_confidence - self._encoder = CLIPVisualEncoder(settings) - - cache_dir = Path(settings.models_dir) / "classifier" - cache_dir.mkdir(parents=True, exist_ok=True) - cache_path = cache_dir / "vectors.npz" - - if cache_path.exists(): - logger.info("Loading cached text centroids from %s", cache_path) - data = np.load(cache_path) - self._photo = data["photography"].astype(np.float32) - self._other = data["other"].astype(np.float32) - else: - centroids = _compute_text_centroids() - self._photo = centroids["photography"] - self._other = centroids["other"] - np.savez(cache_path, photography=self._photo, other=self._other) - logger.info("Cached text centroids to %s", cache_path) - - def classify(self, image: np.ndarray) -> ClassificationResult: - vec = self._encoder.encode(image) - s_photo = float(np.dot(vec, self._photo)) - s_other = float(np.dot(vec, self._other)) - - if s_photo >= s_other: - label = "photography" - margin = s_photo - s_other - else: - label = "other" - margin = s_other - s_photo - - # 0.01 margin → ~0.3 conf, 0.03+ → ~1.0 - confidence = min(1.0, margin * 30) - return ClassificationResult(label=label, confidence=confidence) diff --git a/backend/app/services/vision/embed.py b/backend/app/services/vision/embed.py deleted file mode 100644 index 70e43a7..0000000 --- a/backend/app/services/vision/embed.py +++ /dev/null @@ -1,58 +0,0 @@ -""" -OpenCLIP ViT-B/32 visual encoder (ONNX). Produces 512-d image features -consumed by the content classifier. Not exposed as a standalone service; -the classifier owns the lifecycle. -""" -import logging -from pathlib import Path - -import numpy as np -import onnxruntime as ort # noqa: F401 (provider plumbing relies on this) - -from app.config import VisionSettings - -logger = logging.getLogger(__name__) - -_MEAN = np.array([0.48145466, 0.4578275, 0.40821073], dtype=np.float32) -_STD = np.array([0.26862954, 0.26130258, 0.27577711], dtype=np.float32) -_SIZE = 224 - - -def _preprocess(image: np.ndarray) -> np.ndarray: - from PIL import Image - - img = Image.fromarray(image).convert("RGB") - w, h = img.size - scale = _SIZE / min(w, h) - img = img.resize((int(w * scale), int(h * scale)), Image.BICUBIC) - w, h = img.size - left = (w - _SIZE) // 2 - top = (h - _SIZE) // 2 - img = img.crop((left, top, left + _SIZE, top + _SIZE)) - - arr = np.array(img, dtype=np.float32) / 255.0 - arr = (arr - _MEAN) / _STD - arr = arr.transpose(2, 0, 1) - return arr[np.newaxis] - - -class CLIPVisualEncoder: - """OpenCLIP ViT-B/32 image encoder, 512-d normalized output.""" - - def __init__(self, settings: VisionSettings): - model_path = Path(settings.models_dir) / "embed" / "visual.onnx" - from app.services.vision.providers import create_session - from app.config import settings as app_settings - - logger.info("Loading CLIP visual encoder from %s", model_path) - self._session = create_session( - str(model_path), - configured_providers=app_settings.vision.execution_providers, - ) - - def encode(self, image: np.ndarray) -> np.ndarray: - inp = _preprocess(image) - name = self._session.get_inputs()[0].name - out = self._session.run(None, {name: inp})[0][0] - out = out / np.linalg.norm(out) - return out.astype(np.float32) diff --git a/backend/app/services/vision/export_models.py b/backend/app/services/vision/export_models.py deleted file mode 100644 index d93a696..0000000 --- a/backend/app/services/vision/export_models.py +++ /dev/null @@ -1,62 +0,0 @@ -""" -Export the OpenCLIP ViT-B/32 visual encoder to ONNX. - -Run once on any machine with Python + pip (no GPU needed): - - pip install open-clip-torch onnx - python -m app.services.vision.export_models [--models-dir /data/models] - -Produces: - embed/visual.onnx (~350 MB) -""" -import argparse -import logging -from pathlib import Path - -logger = logging.getLogger(__name__) - - -def export_openclip_visual(models_dir: Path): - import torch - import open_clip - - out_dir = models_dir / "embed" - out_dir.mkdir(parents=True, exist_ok=True) - visual_path = out_dir / "visual.onnx" - if visual_path.exists(): - logger.info("OpenCLIP visual.onnx already exists, skipping export") - return - - logger.info("Loading OpenCLIP ViT-B-32 laion2b_s34b_b79k...") - model, _, _ = open_clip.create_model_and_transforms( - "ViT-B-32", pretrained="laion2b_s34b_b79k" - ) - model.eval() - - logger.info("Exporting visual encoder → %s", visual_path) - dummy = torch.randn(1, 3, 224, 224) - torch.onnx.export( - model.visual, - dummy, - str(visual_path), - input_names=["image"], - output_names=["embedding"], - dynamic_axes={"image": {0: "batch"}}, - opset_version=14, - dynamo=False, - ) - logger.info("Visual encoder exported (%.1f MB)", visual_path.stat().st_size / 1e6) - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("--models-dir", type=Path, default=Path("/data/models")) - args = parser.parse_args() - - logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") - args.models_dir.mkdir(parents=True, exist_ok=True) - export_openclip_visual(args.models_dir) - - -if __name__ == "__main__": - main() diff --git a/backend/app/services/vision/providers.py b/backend/app/services/vision/providers.py deleted file mode 100644 index 0a225d9..0000000 --- a/backend/app/services/vision/providers.py +++ /dev/null @@ -1,86 +0,0 @@ -""" -ONNX Runtime execution provider resolution with GPU auto-detection. - -Resolves configured execution providers against what's actually available -in the current ONNX Runtime build. Falls back to CPU if no GPU provider -is available. Logs the selected provider so users can confirm GPU is active. -""" -import logging - -import onnxruntime as ort - -logger = logging.getLogger(__name__) - -_resolved: list[str] | None = None - - -def get_providers(configured: list[str] | None = None) -> list[str]: - """Return the best available execution providers. - - 1. If `configured` is provided, filter to only those that are - actually available in the current ORT build. - 2. If none of the configured providers are available, fall back - to CPUExecutionProvider. - 3. Auto-detect: if configured is ["auto"], probe for GPU providers. - - Results are cached after first call. - """ - global _resolved - if _resolved is not None: - return _resolved - - available = set(ort.get_available_providers()) - logger.info("ONNX Runtime available providers: %s", sorted(available)) - - if configured is None or configured == ["CPUExecutionProvider"]: - _resolved = ["CPUExecutionProvider"] - return _resolved - - if configured == ["auto"]: - # Auto-detect: prefer CUDA > ROCm > OpenVINO > CPU - priority = [ - "CUDAExecutionProvider", - "ROCMExecutionProvider", - "OpenVINOExecutionProvider", - ] - for p in priority: - if p in available: - _resolved = [p, "CPUExecutionProvider"] - logger.info("Auto-detected GPU provider: %s", p) - return _resolved - _resolved = ["CPUExecutionProvider"] - logger.info("No GPU provider detected, using CPU") - return _resolved - - # Filter configured list to available providers. - resolved = [p for p in configured if p in available] - if not resolved: - logger.warning( - "None of the configured providers %s are available. " - "Falling back to CPU. Available: %s", - configured, - sorted(available), - ) - resolved = ["CPUExecutionProvider"] - else: - # Always include CPU as fallback. - if "CPUExecutionProvider" not in resolved: - resolved.append("CPUExecutionProvider") - - _resolved = resolved - logger.info("Using ONNX Runtime providers: %s", _resolved) - return _resolved - - -def create_session( - model_path: str, - opts: ort.SessionOptions | None = None, - configured_providers: list[str] | None = None, -) -> ort.InferenceSession: - """Create an ONNX InferenceSession with the best available providers.""" - providers = get_providers(configured_providers) - if opts is None: - opts = ort.SessionOptions() - opts.inter_op_num_threads = 2 - opts.intra_op_num_threads = 2 - return ort.InferenceSession(model_path, opts, providers=providers) diff --git a/backend/app/services/vision/registry.py b/backend/app/services/vision/registry.py deleted file mode 100644 index 46ff572..0000000 --- a/backend/app/services/vision/registry.py +++ /dev/null @@ -1,29 +0,0 @@ -""" -ModelRegistry — lazy-loads the single content classifier per worker. -""" -import logging -from functools import lru_cache - -from app.config import settings -from app.services.vision.base import ContentClassifier - -logger = logging.getLogger(__name__) - - -class ModelRegistry: - def __init__(self): - self._vision = settings.vision - - @lru_cache(maxsize=1) - def get_classifier(self) -> ContentClassifier: - logger.info("Loading content classifier (backend=%s)", self._vision.backend) - from app.services.vision.classify import CLIPContentClassifier - return CLIPContentClassifier(self._vision) - - def warmup(self): - logger.info("Warming up vision classifier...") - self.get_classifier() - logger.info("Vision warmup complete") - - -registry = ModelRegistry() diff --git a/backend/app/tasks/celery.py b/backend/app/tasks/celery.py index 93a42d8..7f175a1 100644 --- a/backend/app/tasks/celery.py +++ b/backend/app/tasks/celery.py @@ -2,15 +2,12 @@ Celery configuration and app initialization """ import logging -import os from celery import Celery -from celery.signals import worker_process_init from app.config import settings logger = logging.getLogger(__name__) -# Create Celery app celery_app = Celery( 'mulita', broker=settings.celery_broker_url, @@ -19,43 +16,31 @@ celery_app = Celery( 'app.tasks.scan', 'app.tasks.thumbs', 'app.tasks.video', - 'app.tasks.vision', - 'app.services.metadata', # extract_metadata lives here + 'app.services.metadata', ] ) -# Configure Celery celery_app.conf.update( task_serializer='json', accept_content=['json'], result_serializer='json', timezone='UTC', enable_utc=True, - # Robust acknowledgment: keep message in broker until task succeeds. task_acks_late=True, task_reject_on_worker_lost=True, - # Global time limits — individual tasks can override via decorator. - task_soft_time_limit=300, # 5 min — raises SoftTimeLimitExceeded - task_time_limit=600, # 10 min — SIGKILL - # Explicit routes for every task name. Wildcard patterns don't match - # short names produced by @shared_task(name='...'). + task_soft_time_limit=300, + task_time_limit=600, task_routes={ - # Vision queue — CPU-bound binary classification - 'classify_content': {'queue': 'vision'}, - 'vision_fanout': {'queue': 'vision'}, - # High-priority queue — thumbnails & duplicates 'generate_thumbnails': {'queue': 'high'}, 'regenerate_all_thumbnails': {'queue': 'high'}, 'backfill_phashes': {'queue': 'high'}, 'regroup_duplicates': {'queue': 'high'}, 'incremental_regroup_duplicates': {'queue': 'high'}, - # Low-priority queue — scans 'scan_folder': {'queue': 'low'}, 'scan_all_source_roots': {'queue': 'low'}, 'backfill_gps': {'queue': 'low'}, - # Pre-transcode HEVC videos in the background so /playback is a - # cache hit on first user click. CPU-heavy but tolerant of the - # low-priority queue (it doesn't block any user-facing flow). + # CPU-heavy but tolerant of the low-priority queue (doesn't block + # any user-facing flow). 'pretranscode_video': {'queue': 'low'}, # `watch_folders` is retired (file events come from NC webhooks) # but the task definition still exists as a no-op shim for any @@ -79,21 +64,3 @@ celery_app.conf.update( }, }, ) - - -@worker_process_init.connect -def _warmup_vision_models(**kwargs): - """Pre-load vision models in the worker process so the first task - doesn't pay cold-start latency. Only runs on the vision queue.""" - # The worker name contains the queue — only warm up vision workers. - worker_queues = os.environ.get("CELERY_QUEUES", "") - if "vision" not in worker_queues: - # Heuristic: check the celery command line for -Q vision - import sys - if "vision" not in " ".join(sys.argv): - return - try: - from app.services.vision.registry import registry - registry.warmup() - except Exception: - logger.exception("Vision model warmup failed") \ No newline at end of file diff --git a/backend/app/tasks/scan.py b/backend/app/tasks/scan.py index 6e15a01..94c0b8c 100644 --- a/backend/app/tasks/scan.py +++ b/backend/app/tasks/scan.py @@ -479,7 +479,6 @@ async def _scan_all_source_roots_async(): still hit Settings → Re-detect duplicates to force a fresh pass. """ from app.tasks.thumbs import incremental_regroup_duplicates_task - from app.tasks.vision import backfill_vision async with AsyncSessionLocal() as session: result = await session.execute( @@ -511,13 +510,6 @@ async def _scan_all_source_roots_async(): except Exception as e: logger.warning(f"Could not queue post-scan regroup: {e}") - # 90s lets thumbnails finish so photos reach processing_status - # 'completed', which backfill_vision uses as its filter. - try: - backfill_vision.apply_async(countdown=90) - except Exception as e: - logger.warning(f"Could not queue post-scan vision backfill: {e}") - # NOTE: we used to auto-queue `backfill_gps` here so photos # scanned before the GPS-extraction fix would eventually get # their coordinates populated. That fix shipped a long time diff --git a/backend/app/tasks/thumbs.py b/backend/app/tasks/thumbs.py index 83eb9ea..c193786 100644 --- a/backend/app/tasks/thumbs.py +++ b/backend/app/tasks/thumbs.py @@ -48,11 +48,10 @@ THUMB_SIZES = { } # Sizes the worker writes to /data/thumbs. Empty set since Phase 4 — -# the API serves all sizes via Nextcloud's /core/preview proxy, and -# the vision worker also fetches NC previews on demand instead of -# reading a local cache. generate_thumbnails still runs the decode- -# and-pHash side-effect (perceptual dedup is mule-only and needs the -# original-resolution pixels) but no longer touches the disk. +# the API serves all sizes via Nextcloud's /core/preview proxy. +# generate_thumbnails still runs the decode-and-pHash side-effect +# (perceptual dedup is mule-only and needs the original-resolution +# pixels) but no longer touches the disk. WORKER_THUMB_SIZES: set[str] = set() def get_thumb_path(photo_id: str, size: str, user_id: str = None) -> str: @@ -396,14 +395,6 @@ async def _generate_thumbnails_async(photo_id: str, task): logger.info(f"Thumbnails generated for photo {photo_id}") - # Dispatch vision pipeline only after thumbnails succeeded — - # vision tasks need the generated thumbnails to run inference. - try: - from app.tasks.vision import vision_fanout - vision_fanout.delay(photo_id) - except Exception as e: - logger.warning(f"Could not dispatch vision_fanout for {photo_id}: {e}") - return {'status': 'success', 'photo_id': photo_id} except Exception as e: diff --git a/backend/app/tasks/vision.py b/backend/app/tasks/vision.py deleted file mode 100644 index 9796930..0000000 --- a/backend/app/tasks/vision.py +++ /dev/null @@ -1,263 +0,0 @@ -""" -Celery tasks for the vision pipeline. - -A single binary classifier decides whether a photo is 'photography' or -'other'. Photos classified as 'other' get needs_review=true so the user -can triage screenshots / documents / memes in the UI. -""" -import logging -from pathlib import Path - -import numpy as np -from celery import shared_task -from sqlalchemy import create_engine, text as sa_text, select, delete, update -from sqlalchemy.orm import Session, sessionmaker -from PIL import Image - -from app.config import settings -from app.services.feature_flags import is_enabled, FLAG_VISION_ENABLED - -logger = logging.getLogger(__name__) - -VISION_READY_KEY = "mulita:vision:ready" - - -def _vision_worker_ready() -> bool: - try: - import redis as _redis - return bool(_redis.from_url(settings.redis_url).exists(VISION_READY_KEY)) - except Exception: - return False - - -_sync_engine = None - - -def _get_sync_engine(): - global _sync_engine - if _sync_engine is None: - sync_url = settings.database_url.replace("+asyncpg", "+psycopg2").replace("+aiosqlite", "") - _sync_engine = create_engine(sync_url, pool_pre_ping=True, pool_size=3, max_overflow=5) - return _sync_engine - - -def _get_sync_session() -> Session: - return sessionmaker(bind=_get_sync_engine())() - - -# CLIP was trained against 640px medium thumbs that the on-disk -# pipeline used to produce. Now we ask Nextcloud's preview endpoint -# for the same edge size so the classifier sees the same input -# distribution. -_VISION_PREVIEW_PX = 640 - - -def _load_thumb(photo_id: str, size: str = "medium") -> np.ndarray | None: - """Load the photo's RGB pixels into a numpy array for inference. - - Primary path: ask Nextcloud's `/index.php/core/preview` for a - 640px preview via the sync helper. Replaces the disk read of - `/data/thumbs/{photo_id}/medium.webp` so the on-disk pipeline - can retire entirely. - - Disk fallback (transitional): if NC has no preview or no - credentials, look for the medium.webp the old pipeline wrote. - Goes dead once `generate_thumbnails` stops writing files. - """ - from io import BytesIO - from app.models import Photo - from app.models.user import User - from app.services.nextcloud_dav import get_preview_bytes - - user_id: str | None = None - fileid: int | None = None - session = _get_sync_session() - try: - row = session.execute( - select(Photo.user_id, Photo.nextcloud_fileid).where( - Photo.id == photo_id - ) - ).one_or_none() - if row: - user_id, fileid = row[0], row[1] - finally: - session.close() - - if user_id and fileid: - session = _get_sync_session() - try: - owner = session.execute( - select(User).where(User.id == user_id) - ).scalar_one_or_none() - finally: - session.close() - if owner is not None and owner.nextcloud_app_password_enc: - try: - body = get_preview_bytes( - owner, fileid, _VISION_PREVIEW_PX, _VISION_PREVIEW_PX, - ) - except Exception as e: - logger.warning( - "NC preview fetch failed for %s: %s", photo_id, e - ) - body = None - if body: - try: - img = Image.open(BytesIO(body)).convert("RGB") - img.load() - arr = np.array(img) - img.close() - return arr - except Exception as e: - logger.warning( - "NC preview decode failed for %s: %s", photo_id, e - ) - - # Legacy disk fallback — transitional, dead once thumbs.py stops - # writing /data/thumbs. - thumb_base = Path("/data/thumbs") - thumb_path = thumb_base / photo_id / f"{size}.webp" - if not thumb_path.exists(): - matches = list(thumb_base.glob(f"*/{photo_id}/{size}.webp")) - if matches: - thumb_path = matches[0] - else: - logger.warning("Thumbnail not found anywhere for %s", photo_id) - return None - try: - img = Image.open(thumb_path).convert("RGB") - img.load() - arr = np.array(img) - img.close() - return arr - except Exception as e: - logger.warning("Corrupt or unreadable thumbnail for %s: %s", photo_id, e) - return None - - -@shared_task(name='vision_fanout', queue='vision') -def vision_fanout(photo_id: str): - """Dispatch vision work for a photo. Today this is just the binary - classifier; the indirection stays so scanner/upload code keeps one - entrypoint.""" - if not is_enabled(FLAG_VISION_ENABLED): - return {'status': 'skipped', 'reason': 'vision disabled'} - classify_content.delay(photo_id) - return {'status': 'dispatched', 'photo_id': photo_id} - - -@shared_task(name='classify_content', queue='vision', bind=True, max_retries=3) -def classify_content(self, photo_id: str): - """Run the binary classifier and write: - - a Tag(kind='content_type', name IN ('photography','other')) - - Photo.needs_review = (label == 'other') - """ - if not is_enabled(FLAG_VISION_ENABLED): - return {'status': 'skipped', 'reason': 'vision disabled'} - - image = _load_thumb(photo_id, "medium") - if image is None: - return {'status': 'error', 'message': 'thumbnail not found'} - - try: - from app.services.vision.registry import registry - classifier = registry.get_classifier() - result = classifier.classify(image) - except Exception as exc: - logger.exception("classify_content failed for %s", photo_id) - raise self.retry(exc=exc, countdown=60) - - from app.models import Photo - from app.models.tags import Tag, photo_tags - - source_name = "vision:clip_classifier" - label = result.label - confidence = result.confidence - - session = _get_sync_session() - try: - photo = session.execute( - select(Photo).where(Photo.id == photo_id) - ).scalar_one_or_none() - if photo is None: - return {'status': 'error', 'message': 'photo not found'} - owner_id = photo.user_id - - # Drop any previous classification for this photo. - session.execute( - delete(photo_tags).where( - photo_tags.c.photo_id == photo_id, - photo_tags.c.source == source_name, - ) - ) - - tag = session.execute( - select(Tag).where( - Tag.name == label, Tag.kind == 'content_type', Tag.user_id == owner_id - ) - ).scalar_one_or_none() - if not tag: - tag = Tag(name=label, kind='content_type', source=source_name, user_id=owner_id) - session.add(tag) - session.flush() - - session.execute( - photo_tags.insert().values( - photo_id=photo_id, - tag_id=tag.id, - confidence=confidence, - source=source_name, - ) - ) - - session.execute( - update(Photo) - .where(Photo.id == photo_id) - .values(needs_review=(label == 'other')) - ) - - session.commit() - except Exception: - session.rollback() - raise - finally: - session.close() - - logger.info("[%s] Classified %s as %s (%.2f)", self.request.id, photo_id, label, confidence) - return {'status': 'success', 'photo_id': photo_id, 'label': label} - - -@shared_task(name='backfill_vision', bind=True, max_retries=10) -def backfill_vision(self, limit: int | None = None, **_ignored): - """Queue classify_content for photos without a content_type tag.""" - if not _vision_worker_ready(): - logger.info("Vision worker not ready yet — retrying in 30s") - raise self.retry(countdown=30) - - ordering = "ORDER BY p.taken_at DESC NULLS LAST, p.added_at DESC NULLS LAST" - limit_clause = " LIMIT :lim" if limit else "" - params: dict = {} - if limit: - params["lim"] = int(limit) - - session = _get_sync_session() - try: - sql = f""" - SELECT p.id FROM photos p - WHERE p.processing_status = 'completed' - AND NOT EXISTS ( - SELECT 1 FROM photo_tags pt - WHERE pt.photo_id = p.id - AND pt.source = 'vision:clip_classifier' - ) - {ordering}{limit_clause} - """ - ids = [r[0] for r in session.execute(sa_text(sql), params).fetchall()] - finally: - session.close() - - for pid in ids: - classify_content.delay(pid) - - logger.info("Backfill queued %d photos for classification", len(ids)) - return {'status': 'queued', 'count': len(ids)} diff --git a/backend/requirements.txt b/backend/requirements.txt index 4ccca9c..8fa13fb 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -38,9 +38,7 @@ pyexiftool==0.5.6 # File watching watchfiles==0.21.0 -# Vision pipeline (ONNX Runtime CPU inference) -onnxruntime==1.18.1 -open-clip-torch==2.24.0 # tokenizer + export helper; inference via ONNX +# Transitive dep of rawpy 0.26.1, which requires numpy<2 (see comment above). numpy>=1.26.0,<2.0 # Utilities diff --git a/backend/scripts/full_refresh.py b/backend/scripts/full_refresh.py new file mode 100644 index 0000000..cbe0791 --- /dev/null +++ b/backend/scripts/full_refresh.py @@ -0,0 +1,115 @@ +"""Bring the mule-image DB into 100% sync with Nextcloud + filesystem. + +Multi-phase one-shot operation invoked via: + + docker exec mulita-backend python scripts/full_refresh.py [--dry-run] + +Phases: + 1. Data integrity (sync, ~1s): cleanup_data_integrity dedupes + SourceRoots / Folders by normalized path and recomputes folder + photo_count. + 2. Forward scan (async, minutes): walk every active SourceRoot on + disk, create/update Photo rows for new files, resurrect any + accidentally-discarded photos whose mtime advanced. + 3. Hard prune (sync, seconds): delete Photo + Folder rows for paths + that no longer exist on disk under a *mounted* root. Skips + unmounted roots — matches prune_missing_photos's existing + refuse-when-empty behavior. + 4. Orphan thumbnail dirs (sync, seconds): remove + /data/thumbs/{user_id}/{photo_id}/ for any photo_id that's no + longer in the photos table. + +Pass --dry-run to compute counts for phases 3+4 without making changes. +Phases 1 and 2 always run for real — they're idempotent and additive. + +Print a structured summary at the end. Exit non-zero on any phase +error; partial completion still surfaces the counts gathered so far. +""" +from __future__ import annotations + +import argparse +import asyncio +import logging +import sys + +from app.services.cleanup import ( + cleanup_data_integrity, + prune_missing_photos, + prune_orphan_thumbnails, +) +from app.tasks.scan import _scan_folder_async +from app.database import AsyncSessionLocal +from app.models.folders import SourceRoot +from sqlalchemy import select + + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", +) +logger = logging.getLogger("full_refresh") + + +async def _scan_all_inline() -> int: + """Scan every active SourceRoot inline (not via celery). Returns the + number of roots actually walked.""" + import os + async with AsyncSessionLocal() as session: + result = await session.execute( + select(SourceRoot).where(SourceRoot.is_active.is_(True)) + ) + roots = result.scalars().all() + + walked = 0 + for sr in roots: + if not os.path.exists(sr.path): + logger.warning("source root path missing, skipping: %s", sr.path) + continue + logger.info("scanning %s …", sr.path) + await _scan_folder_async(sr.path, sr.id, task=None) + walked += 1 + return walked + + +async def main(dry_run: bool) -> dict: + summary: dict = {"dry_run": dry_run} + + logger.info("phase 1: cleanup_data_integrity") + summary["phase1_cleanup"] = await cleanup_data_integrity() + + logger.info("phase 2: scan_all_source_roots (inline)") + summary["phase2_scan_roots_walked"] = await _scan_all_inline() + + logger.info("phase 3: prune_missing_photos (dry_run=%s)", dry_run) + summary["phase3_prune"] = await prune_missing_photos(dry_run=dry_run) + + logger.info("phase 4: prune_orphan_thumbnails (dry_run=%s)", dry_run) + summary["phase4_orphan_thumbs"] = await prune_orphan_thumbnails( + dry_run=dry_run, + ) + + return summary + + +def cli() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--dry-run", + action="store_true", + help="Phases 3+4 report counts without making changes", + ) + args = parser.parse_args() + + try: + result = asyncio.run(main(dry_run=args.dry_run)) + except Exception: + logger.exception("full_refresh failed") + return 1 + + import json + print(json.dumps(result, indent=2, default=str)) + return 0 + + +if __name__ == "__main__": + sys.exit(cli()) diff --git a/docker-compose.yml b/docker-compose.yml index ff70ec4..bfb4eec 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -146,7 +146,7 @@ services: dockerfile: Dockerfile image: mule-image-worker container_name: mulita-worker-light - command: sh -c "python -m app.services.vision.bootstrap_models && celery -A app.tasks.celery worker --beat --loglevel=${LOG_LEVEL:-info} --concurrency=${CELERY_LIGHT_CONCURRENCY:-2} -Q default,high,low -n light@%h" + command: sh -c "celery -A app.tasks.celery worker --beat --loglevel=${LOG_LEVEL:-info} --concurrency=${CELERY_LIGHT_CONCURRENCY:-2} -Q default,high,low -n light@%h" volumes: - ./mulita.yml:/app/config/mulita.yml:ro - ${PHOTO_DIRS:-./photos}:/photos:rw @@ -155,7 +155,6 @@ services: - proxies_data:/data/proxies - video_cache_data:/data/video-cache - db_data:/data/db - - models_data:/data/models environment: - DATABASE_URL=postgresql+asyncpg://mulita:mulita@db:5432/mulita - REDIS_URL=redis://redis:6379 @@ -203,85 +202,8 @@ services: # * `--beat` was folded into worker-light's command so the # periodic discard_missing_photos_beat job still fires. - worker-vision: - build: - context: ./backend - dockerfile: Dockerfile - image: mule-image-worker - container_name: mulita-worker-vision - command: sh -c "python -m app.services.vision.bootstrap_models && celery -A app.tasks.celery worker --loglevel=${LOG_LEVEL:-info} --concurrency=${CELERY_VISION_CONCURRENCY:-5} -Q vision -n vision@%h" - volumes: - - ./mulita.yml:/app/config/mulita.yml:ro - - ${PHOTO_DIRS:-./photos}:/photos:rw - - ${NEXTCLOUD_USERS_HOST_PATH:-./photos}:/nextcloud-users:rw - - thumbs_data:/data/thumbs - - proxies_data:/data/proxies - - video_cache_data:/data/video-cache - - db_data:/data/db - - models_data:/data/models - environment: - - DATABASE_URL=postgresql+asyncpg://mulita:mulita@db:5432/mulita - - REDIS_URL=redis://redis:6379 - - CELERY_BROKER_URL=redis://redis:6379 - - CELERY_RESULT_BACKEND=redis://redis:6379 - - PHOTO_DIRS=/photos - - NEXTCLOUD_USERS_ROOT=${NEXTCLOUD_USERS_ROOT:-/nextcloud-users} - - NEXTCLOUD_BASE_URL=${NEXTCLOUD_BASE_URL:-} - - NEXTCLOUD_WEBHOOK_SECRET=${NEXTCLOUD_WEBHOOK_SECRET:-} - - SECRET_KEY=${SECRET_KEY:-mulita-dev-secret-change-me} - - LOG_LEVEL=${LOG_LEVEL:-INFO} - - TZ=${TZ:-UTC} - - MULITA_CELERY_WORKER=1 - # ONNX Runtime execution providers. Set to "auto" to auto-detect - # GPU (CUDA > ROCm > OpenVINO > CPU), or explicitly: - # "CUDAExecutionProvider,CPUExecutionProvider" - # "ROCMExecutionProvider,CPUExecutionProvider" - # Default: CPU only. To enable GPU, also uncomment the deploy - # section below and install nvidia-container-toolkit on the host. - - VISION_EXECUTION_PROVIDERS=${VISION_EXECUTION_PROVIDERS:-CPUExecutionProvider} - # Pin each ONNX session to one intra-op thread so N prefork children - # × default-all-cores doesn't oversubscribe the box. With - # concurrency=5 and OMP=1, vision peaks at 5 busy cores, leaving - # one for worker-light + system. These env vars cover the three - # threading runtimes ONNX Runtime might pick up on first use. - - OMP_NUM_THREADS=1 - - OPENBLAS_NUM_THREADS=1 - - MKL_NUM_THREADS=1 - # Uncomment for NVIDIA GPU passthrough: - # deploy: - # resources: - # reservations: - # devices: - # - driver: nvidia - # count: all - # capabilities: [gpu] - healthcheck: - test: ["CMD-SHELL", "celery -A app.tasks.celery inspect ping -d vision@$$HOSTNAME 2>/dev/null | grep -q OK"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 300s - depends_on: - redis: - condition: service_started - backend: - condition: service_started - db: - condition: service_healthy - networks: - - mulita-network - # Pin cloud.hubris.network to the LAN caddy IP. Without this, the - # docker DNS forwards the lookup to the host's resolver, which - # returns the public IONOS VPS IP — but cloud isn't in the VPS - # traefik exposure list, so TLS handshakes against it die with - # "unexpected eof while reading". Caddy on 192.168.8.175 holds the - # cloud.hubris.network cert and proxies to the Nextcloud LXC. - extra_hosts: - - "cloud.hubris.network:192.168.8.175" - restart: unless-stopped - db: - image: pgvector/pgvector:pg16 + image: postgres:16 container_name: mulita-db environment: POSTGRES_USER: mulita @@ -343,5 +265,4 @@ volumes: video_cache_data: db_data: redis_data: - pg_data: - models_data: \ No newline at end of file + pg_data: \ No newline at end of file diff --git a/frontend/src/components/dialogs/SettingsDialog.tsx b/frontend/src/components/dialogs/SettingsDialog.tsx index 0ff1c8e..d21deea 100644 --- a/frontend/src/components/dialogs/SettingsDialog.tsx +++ b/frontend/src/components/dialogs/SettingsDialog.tsx @@ -32,8 +32,6 @@ import { type PipelineStage, type ScanStatus, type WorkerStatus, - type FeatureFlagSnapshot, - type FeaturesMap, type NextcloudSourceRoot, } from '../../services/api' import { toast } from '../ToastContainer' @@ -57,14 +55,10 @@ 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' | 'ai' | 'users' +type SettingsTab = 'library' | 'users' const TABS: { id: SettingsTab; label: string; adminOnly?: boolean }[] = [ { id: 'library', label: 'Library Management' }, - // AI is open to non-admins; per-user scope. Admin-only mutations - // (system-wide flag toggles, bulk backfill, rescan-all) are hidden - // inside the panel for non-admins. - { id: 'ai', label: 'AI Features' }, { id: 'users', label: 'Users', adminOnly: true }, ] @@ -869,14 +863,6 @@ export function SettingsPage() { )} - {activeTab === 'ai' && ( - - )} - {activeTab === 'users' && isAdmin && (
} @@ -1110,223 +1096,6 @@ function ActionButton({ } -// --------------------------------------------------------------------------- -// AI Features admin tab -// --------------------------------------------------------------------------- - -interface AiFeaturesTabProps { - busy: Record - runAction: ( - key: string, - fn: () => Promise, - successTitle: string, - describe?: (result: T) => string | undefined, - ) => Promise - isAdmin: boolean -} - -const FLAG_META: Array<{ - id: string - label: string - description: string - icon: React.ReactNode -}> = [ - { - id: 'vision.enabled', - label: 'Vision classifier', - description: - 'Binary photo-vs-other classifier. Flags screenshots, documents, memes and ' + - 'scans with "needs review" so they can be triaged.', - icon: , - }, -] - -function AiFeaturesTab({ busy, runAction, isAdmin }: AiFeaturesTabProps) { - const queryClient = useQueryClient() - // Admins get the full snapshot (default + effective + overridden) so - // they can toggle. Non-admins only read effective values via the - // public /features endpoint — no leaked override metadata, no 403s. - const flagsQuery = useQuery<{ flags: FeatureFlagSnapshot }>({ - queryKey: SETTINGS_FEATURE_FLAGS_KEY, - queryFn: adminApi.listFeatureFlags, - staleTime: 5_000, - enabled: isAdmin, - }) - const featuresQuery = useQuery({ - queryKey: ['features'], - queryFn: featuresApi.list, - staleTime: 5_000, - enabled: !isAdmin, - }) - - const flags: FeatureFlagSnapshot = isAdmin - ? (flagsQuery.data?.flags ?? {}) - : Object.fromEntries( - Object.entries(featuresQuery.data ?? {}).map(([name, on]) => [ - name, - { effective: on, default: on, overridden: false }, - ]), - ) - 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'] }) - } - - const runBackfill = () => - runAction( - `ai-backfill:all`, - () => adminApi.triggerAiBackfill({}), - 'Classifier backfill queued', - (r) => `Celery task ${r.task_id}`, - ) - - return ( - <> -
} title="AI feature flags"> -

- {isAdmin - ? '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.' - : 'Effective AI features for your library. Flags are system-wide; only an admin can toggle them.'} -

- - {(isAdmin ? flagsQuery.isLoading : featuresQuery.isLoading) && ( -
- - Loading feature flags… -
- )} - {(isAdmin ? flagsQuery.error : featuresQuery.error) && ( - - )} - - {!(isAdmin ? flagsQuery.isLoading : featuresQuery.isLoading) - && !(isAdmin ? flagsQuery.error : featuresQuery.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'} - -

-
-
- applyFlag(meta.id, v)} - disabled={!isAdmin || isBusy || (dimmed && !isMaster)} - title={ - !isAdmin - ? 'Admin only — flags are system-wide' - : state.effective - ? 'Click to disable' - : 'Click to enable' - } - /> - {isAdmin && state.overridden && ( - - )} -
-
- -
- ) - })} -
- )} -
- - {isAdmin && ( -
} title="Manual pipeline triggers"> -

- Run the classifier over any photos that haven't been classified yet, - or force a fresh filesystem scan. Both are safe to run repeatedly. - These are bulk admin operations across every user; non-admins - should use "Re-scan source folders" in the Library tab to - re-scan their own libraries. -

-
- runBackfill()} - disabled={masterOff} - > - - Run classifier backfill - - - runAction( - 'rescan-full', - () => adminApi.triggerFullRescan(), - 'Rescan queued', - (r) => `Celery task ${r.task_id}`, - ) - } - > - - Rescan all source roots - -
-
- )} - - ) -} - // ── Nextcloud integration card ───────────────────────────────────────── // diff --git a/frontend/src/components/filter/FilterBar.tsx b/frontend/src/components/filter/FilterBar.tsx index 06d9866..38b2153 100644 --- a/frontend/src/components/filter/FilterBar.tsx +++ b/frontend/src/components/filter/FilterBar.tsx @@ -81,8 +81,6 @@ export function FilterBar({ const sortBy = useFilterStore((s) => s.sortBy) const sortOrder = useFilterStore((s) => s.sortOrder) const tagIds = useFilterStore((s) => s.tagIds) - const needsReview = useFilterStore((s) => s.needsReview) - const setNeedsReview = useFilterStore((s) => s.setNeedsReview) const dateFrom = useFilterStore((s) => s.dateFrom) const dateTo = useFilterStore((s) => s.dateTo) const setDateFrom = useFilterStore((s) => s.setDateFrom) @@ -119,14 +117,12 @@ export function FilterBar({ const colorActive = colorLabel !== null const colorValue = colorActive ? colorLabel : null - const flagActive = flag !== 'any' || needsReview - const flagValue = needsReview - ? 'needs review' - : flag !== 'any' - ? flag === 'date_warning' - ? 'date issues' - : flag - : null + const flagActive = flag !== 'any' + const flagValue = flagActive + ? flag === 'date_warning' + ? 'date issues' + : flag + : null const tagActive = tagIds.length > 0 const activeTagNames = allTags @@ -304,19 +300,13 @@ export function FilterBar({ {/* Flag — hidden in the Discarded section, where the flag is - * pinned to "discarded" by the section preset. The Needs review - * option lives here too: it sets a different store field - * (`needsReview`) but is mutually exclusive with the other flag - * values from the user's perspective. */} + * pinned to "discarded" by the section preset. */} {!hideFlagPill && ( { - setFlag('any') - setNeedsReview(false) - }} + onClear={() => setFlag('any')} > { - // Flag state is mutually exclusive in the store; treat - // the just-added value as the new single selection, or - // clear everything when the user unchecks the current. - const added = next.find( - (v) => - v !== - (needsReview ? 'needs_review' : flag !== 'any' ? flag : '') - ) - if (!added) { - setFlag('any') - setNeedsReview(false) - return - } - if (added === 'needs_review') { - setFlag('any') - setNeedsReview(true) - } else { - setFlag(added as 'discarded' | 'date_warning') - setNeedsReview(false) - } - }} + // Flag state is mutually exclusive in the store; treat + // the just-added value as the new single selection. + const added = next.find((v) => v !== (flag !== 'any' ? flag : '')) + if (!added) { + setFlag('any') + return + } + setFlag(added as 'discarded' | 'date_warning') + }} /> )} diff --git a/frontend/src/components/layout/LeftSidebar.tsx b/frontend/src/components/layout/LeftSidebar.tsx index 8fcd368..03df716 100644 --- a/frontend/src/components/layout/LeftSidebar.tsx +++ b/frontend/src/components/layout/LeftSidebar.tsx @@ -62,7 +62,6 @@ import { ContextMenuTrigger, } from '@/components/ui/context-menu' import { useAuth } from '../../contexts/AuthContext' -import { useFeaturesQuery } from '../../hooks/useFeaturesQuery' import { useScanActivity } from '../../hooks/useScanActivity' import { Input } from '@/components/ui/input' import { @@ -105,8 +104,6 @@ export function LeftSidebar() { const currentSection = useFilterStore((s) => s.currentSection) const { data: allTags = [] } = useTagsQuery() const { data: stats } = useLibraryStatsQuery() - const { data: featuresMap } = useFeaturesQuery() - const visionOn = featuresMap ? featuresMap['vision.enabled'] !== false : true const tagsOn = true const [dropTargetId, setDropTargetId] = useState(null) @@ -289,9 +286,6 @@ export function LeftSidebar() { case 'tags': navigateToSection('tags', { groupBy: 'tag' }) break - case 'needs-review': - navigateToSection('needs-review', { needsReview: true }) - break case 'colors': navigateToSection('colors', { groupBy: 'color' }) break @@ -430,7 +424,6 @@ export function LeftSidebar() { { id: 'all-photos', label: 'All Photos', icon: , count: stats?.all_photos ?? 0 }, { id: 'rated', label: 'Rated', icon: , count: stats?.rated ?? 0 }, ...(tagsOn ? [{ id: 'tags', label: 'Tags', icon: , count: tagsTotalCount }] : []), - ...(visionOn ? [{ id: 'needs-review', label: 'Needs Review', icon: , count: stats?.needs_review ?? 0 }] : []), { id: 'colors', label: 'Colors', icon: , count: stats?.colored ?? 0 }, { id: 'map', label: 'Map', icon: , count: stats?.with_gps ?? 0 }, { id: 'memories', label: 'Memories', icon: }, diff --git a/frontend/src/hooks/useBulkPhotoMutations.ts b/frontend/src/hooks/useBulkPhotoMutations.ts index ac6cd27..1bd3e5f 100644 --- a/frontend/src/hooks/useBulkPhotoMutations.ts +++ b/frontend/src/hooks/useBulkPhotoMutations.ts @@ -18,7 +18,7 @@ import type { MemoriesResponse, MemoryPhoto } from '../services/api' * per-photo cache so rating stars / color swatches flip instantly. * - Rolls back the patch on error and surfaces a toast. * - Invalidates the photo/library queries on success so server-side - * derived fields (needs_review, date_warning, etc.) reconcile. + * derived fields (date_warning, etc.) reconcile. * * Discard lives elsewhere — its two call sites have deliberately * different semantics (keep-in-place for the X hotkey; strip-from- diff --git a/frontend/src/hooks/useFeaturesQuery.ts b/frontend/src/hooks/useFeaturesQuery.ts deleted file mode 100644 index a3d6501..0000000 --- a/frontend/src/hooks/useFeaturesQuery.ts +++ /dev/null @@ -1,32 +0,0 @@ -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'): 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/hooks/useFilterUrlSync.ts b/frontend/src/hooks/useFilterUrlSync.ts index e8ac524..65c9d34 100644 --- a/frontend/src/hooks/useFilterUrlSync.ts +++ b/frontend/src/hooks/useFilterUrlSync.ts @@ -86,7 +86,6 @@ function parseUrl(): HydratePayload { } if (sp.get('duplicates') === 'true') out.duplicates = true - if (sp.get('needs_review') === 'true') out.needsReview = true const groupBy = sp.get('group') if (groupBy === 'date' || groupBy === 'tag') out.groupBy = groupBy @@ -120,7 +119,6 @@ function writeUrl(f: FilterState & { currentSection?: string }) { if (f.folderId) sp.set('folder_id', f.folderId) if (f.tagIds.length > 0) sp.set('tag_ids', f.tagIds.join(',')) if (f.duplicates) sp.set('duplicates', 'true') - if (f.needsReview) sp.set('needs_review', 'true') if (f.groupBy !== 'date') sp.set('group', f.groupBy) if (f.currentSection && f.currentSection !== 'all-photos') sp.set('section', f.currentSection) diff --git a/frontend/src/hooks/usePhotosQuery.ts b/frontend/src/hooks/usePhotosQuery.ts index 4642c56..5da1946 100644 --- a/frontend/src/hooks/usePhotosQuery.ts +++ b/frontend/src/hooks/usePhotosQuery.ts @@ -65,7 +65,6 @@ export function usePhotosQuery() { folderId: s.folderId, tagIds: s.tagIds, duplicates: s.duplicates, - needsReview: s.needsReview, groupBy: s.groupBy, sortBy: s.sortBy, sortOrder: s.sortOrder, diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index be5618e..dfa63dc 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -611,7 +611,6 @@ export interface LibraryStats { with_gps: number duplicates: number discarded: number - needs_review: number total_photos: number total_videos: number total_size: number @@ -1008,55 +1007,6 @@ 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: { - limit?: number | null - }): Promise<{ status: string; task_id: string }> => { - const response = await api.post('/admin/ai/backfill', body) - 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 - }, } // ── Nextcloud integration ────────────────────────────────────────────── diff --git a/frontend/src/store/filterStore.ts b/frontend/src/store/filterStore.ts index e057e4d..101122a 100644 --- a/frontend/src/store/filterStore.ts +++ b/frontend/src/store/filterStore.ts @@ -30,8 +30,6 @@ export interface FilterState { tagIds: string[] /** When true, restrict to photos flagged as duplicates by the scanner. */ duplicates: boolean - /** When true, restrict to photos classified as 'other' (needs_review). */ - needsReview: boolean /** Visual grouping mode. 'date' groups by month when sortBy is a date * field; 'tag' groups by photo tag membership. Independent of filters. */ groupBy: GroupBy @@ -68,7 +66,6 @@ interface FilterStore extends FilterState { setTagIds: (ids: string[]) => void toggleTagId: (id: string) => void setDuplicates: (v: boolean) => void - setNeedsReview: (v: boolean) => void setGroupBy: (mode: GroupBy) => void setSortBy: (field: SortField) => void setSortOrder: (order: SortOrder) => void @@ -102,7 +99,6 @@ export const INITIAL_FILTERS: FilterState = { folderId: null, tagIds: [], duplicates: false, - needsReview: false, groupBy: 'date', sortBy: 'taken_at', sortOrder: 'desc', @@ -124,7 +120,6 @@ function snapshotFilters(s: FilterState): FilterState { folderId: s.folderId, tagIds: [...s.tagIds], duplicates: s.duplicates, - needsReview: s.needsReview, groupBy: s.groupBy, sortBy: s.sortBy, sortOrder: s.sortOrder, @@ -159,7 +154,6 @@ export const useFilterStore = create((set) => ({ : [...s.tagIds, id], })), setDuplicates: (duplicates) => set({ duplicates }), - setNeedsReview: (needsReview) => set({ needsReview }), setGroupBy: (groupBy) => set({ groupBy }), setSortBy: (sortBy) => set({ sortBy }), setSortOrder: (sortOrder) => set({ sortOrder }), @@ -223,7 +217,6 @@ export function filtersToParams(f: FilterState): Record if (f.folderId) params.folder_id = f.folderId if (f.tagIds.length > 0) params.tag_ids = f.tagIds.join(',') if (f.duplicates) params.is_duplicate = 'true' - if (f.needsReview) params.needs_review = 'true' params.sort = f.sortBy params.order = f.sortOrder return params @@ -242,7 +235,6 @@ export function hasActiveFilters(f: FilterState): boolean { f.heapId !== null || f.folderId !== null || f.tagIds.length > 0 || - f.duplicates || - f.needsReview + f.duplicates ) } diff --git a/frontend/src/types/photo.ts b/frontend/src/types/photo.ts index 69af07b..12f9d9a 100644 --- a/frontend/src/types/photo.ts +++ b/frontend/src/types/photo.ts @@ -18,7 +18,6 @@ export interface Photo { user_notes?: string | null is_discarded: boolean is_duplicate: boolean - needs_review?: boolean has_date_warning?: boolean file_hash: string folder_id: string | null