refactor: drop AI/vision pipeline + plain Postgres + full-refresh script

Removes the OpenCLIP-on-ONNX classifier and everything that fed or
consumed it:
  - backend: app/services/vision/, app/tasks/vision.py,
    app/services/feature_flags.py, app/routers/features.py — all
    deleted; admin AI/feature-flag endpoints and the worker-vision
    bootstrap call gone. Photo.needs_review and its index dropped.
  - frontend: AI Settings tab, useFeaturesQuery hook, FeatureFlag
    types, "Needs Review" sidebar entry + filter, needs_review filter
    URL param all gone.
  - infra: worker-vision compose service + models_data volume deleted;
    worker-light command no longer runs bootstrap_models; the db
    image switches from pgvector/pgvector:pg16 to postgres:16; backend
    Dockerfile drops the dedicated torch RUN layer; requirements.txt
    drops torch/torchvision/open-clip-torch/onnxruntime.

Alembic 0019_drop_ai_remnants:
  - drops photos.needs_review + ix_photos_needs_review
  - DROP EXTENSION IF EXISTS vector (must run before the image swap;
    the new postgres:16 doesn't ship pgvector)

New scripts/full_refresh.py: one-shot DB ↔ filesystem reconciliation.
Runs cleanup_data_integrity, scans every active SourceRoot inline
(no celery dependency so the worker can be stopped), hard-prunes
photo + folder rows for files that are gone, removes orphan
/data/thumbs/{user}/{photo}/ directories. New helper
prune_orphan_thumbnails in cleanup.py.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
claudio
2026-05-14 00:20:38 +02:00
parent 6915c30911
commit a27267f7ad
39 changed files with 265 additions and 1573 deletions

View File

@@ -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

View File

@@ -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"
)

View File

@@ -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

View File

@@ -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"])

View File

@@ -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'),
)

View File

@@ -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")

View File

@@ -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}

View File

@@ -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}

View File

@@ -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",

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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:<name>``; 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

View File

@@ -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

View File

@@ -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.
"""

View File

@@ -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:
...

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()

View File

@@ -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)

View File

@@ -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()

View File

@@ -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")

View File

@@ -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

View File

@@ -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:

View File

@@ -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)}

View File

@@ -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

View File

@@ -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())

View File

@@ -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:
pg_data:

View File

@@ -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() {
</Section>
</>)}
{activeTab === 'ai' && (
<AiFeaturesTab
busy={busy}
runAction={runAction}
isAdmin={isAdmin}
/>
)}
{activeTab === 'users' && isAdmin && (
<Section
icon={<Shield className="h-4 w-4" />}
@@ -1110,223 +1096,6 @@ function ActionButton({
}
// ---------------------------------------------------------------------------
// AI Features admin tab
// ---------------------------------------------------------------------------
interface AiFeaturesTabProps {
busy: Record<string, boolean>
runAction: <T>(
key: string,
fn: () => Promise<T>,
successTitle: string,
describe?: (result: T) => string | undefined,
) => Promise<void>
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: <Sparkles className="h-3.5 w-3.5" />,
},
]
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<FeaturesMap>({
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 (
<>
<Section icon={<Brain className="h-4 w-4" />} title="AI feature flags">
<p className="text-xs text-text-muted">
{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.'}
</p>
{(isAdmin ? flagsQuery.isLoading : featuresQuery.isLoading) && (
<div className="mt-3 flex items-center gap-2 text-xs text-text-muted">
<Loader2 className="h-3 w-3 animate-spin" />
Loading feature flags
</div>
)}
{(isAdmin ? flagsQuery.error : featuresQuery.error) && (
<ErrorBanner
title="Could not load feature flags"
detail={String(
((isAdmin ? flagsQuery.error : featuresQuery.error) as Error).message
|| (isAdmin ? flagsQuery.error : featuresQuery.error),
)}
/>
)}
{!(isAdmin ? flagsQuery.isLoading : featuresQuery.isLoading)
&& !(isAdmin ? flagsQuery.error : featuresQuery.error) && (
<div className="mt-3 space-y-2">
{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 (
<div
key={meta.id}
className={cn(
'rounded border border-border bg-surface p-3 text-xs',
dimmed && 'opacity-60',
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 text-text">
{meta.icon}
<span className="font-medium">{meta.label}</span>
{state.overridden && (
<span className="rounded bg-primary/20 px-1 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-primary">
overridden
</span>
)}
</div>
<p className="mt-1 text-[11px] text-text-muted">{meta.description}</p>
<p className="mt-1 text-[10px] text-text-faint">
Default: {state.default ? 'on' : 'off'} · Currently:{' '}
<span className={state.effective ? 'text-pick' : 'text-reject'}>
{state.effective ? 'on' : 'off'}
</span>
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Switch
checked={state.effective}
onCheckedChange={(v) => 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 && (
<Button
variant="outline"
size="icon"
onClick={() => applyFlag(meta.id, null)}
disabled={isBusy}
className="h-6 w-6"
title="Reset to YAML default"
aria-label="Reset override"
>
<RotateCcw className="h-3 w-3" />
</Button>
)}
</div>
</div>
</div>
)
})}
</div>
)}
</Section>
{isAdmin && (
<Section icon={<Cpu className="h-4 w-4" />} title="Manual pipeline triggers">
<p className="text-xs text-text-muted">
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.
</p>
<div className="mt-3 flex flex-wrap gap-2">
<ActionButton
loading={!!busy['ai-backfill:all']}
onClick={() => runBackfill()}
disabled={masterOff}
>
<Sparkles className="h-4 w-4" />
Run classifier backfill
</ActionButton>
<ActionButton
loading={!!busy['rescan-full']}
onClick={() =>
runAction(
'rescan-full',
() => adminApi.triggerFullRescan(),
'Rescan queued',
(r) => `Celery task ${r.task_id}`,
)
}
>
<RefreshCw className="h-4 w-4" />
Rescan all source roots
</ActionButton>
</div>
</Section>
)}
</>
)
}
// ── Nextcloud integration card ─────────────────────────────────────────
//

View File

@@ -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({
</FilterPill>
{/* 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 && (
<FilterPill
label="Flag"
value={flagValue}
isActive={flagActive}
onClear={() => {
setFlag('any')
setNeedsReview(false)
}}
onClear={() => setFlag('any')}
>
<MultiSelect
searchable={false}
@@ -324,37 +314,18 @@ export function FilterBar({
options={[
{ value: 'discarded', label: 'Discarded' },
{ value: 'date_warning', label: 'Date issues' },
{ value: 'needs_review', label: 'Needs review' },
]}
values={
needsReview
? ['needs_review']
: flag !== 'any'
? [flag]
: []
}
values={flag !== 'any' ? [flag] : []}
onChange={(next) => {
// 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')
}}
/>
</FilterPill>
)}

View File

@@ -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<string | null>(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: <Image className="h-4 w-4" />, count: stats?.all_photos ?? 0 },
{ id: 'rated', label: 'Rated', icon: <Star className="h-4 w-4" />, count: stats?.rated ?? 0 },
...(tagsOn ? [{ id: 'tags', label: 'Tags', icon: <TagIcon className="h-4 w-4" />, count: tagsTotalCount }] : []),
...(visionOn ? [{ id: 'needs-review', label: 'Needs Review', icon: <Users className="h-4 w-4" />, count: stats?.needs_review ?? 0 }] : []),
{ id: 'colors', label: 'Colors', icon: <Palette className="h-4 w-4" />, count: stats?.colored ?? 0 },
{ id: 'map', label: 'Map', icon: <MapPin className="h-4 w-4" />, count: stats?.with_gps ?? 0 },
{ id: 'memories', label: 'Memories', icon: <Clock className="h-4 w-4" /> },

View File

@@ -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-

View File

@@ -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<FeaturesMap>({
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<typeof useQueryClient>) {
queryClient.invalidateQueries({ queryKey: FEATURES_QUERY_KEY })
}

View File

@@ -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)

View File

@@ -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,

View File

@@ -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<string, FeatureFlagState>
export type FeaturesMap = Record<string, boolean>
// 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<FeaturesMap> => {
const response = await api.get('/features')
return response.data
},
}
// ── Nextcloud integration ──────────────────────────────────────────────

View File

@@ -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<FilterStore>((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<string, string | number>
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
)
}

View File

@@ -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