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:
@@ -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
|
||||
|
||||
|
||||
47
backend/alembic/versions/0019_drop_ai_remnants.py
Normal file
47
backend/alembic/versions/0019_drop_ai_remnants.py
Normal 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"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@@ -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'),
|
||||
)
|
||||
@@ -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")
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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:
|
||||
...
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)}
|
||||
@@ -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
|
||||
|
||||
115
backend/scripts/full_refresh.py
Normal file
115
backend/scripts/full_refresh.py
Normal 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())
|
||||
@@ -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
|
||||
@@ -344,4 +266,3 @@ volumes:
|
||||
db_data:
|
||||
redis_data:
|
||||
pg_data:
|
||||
models_data:
|
||||
@@ -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 ─────────────────────────────────────────
|
||||
//
|
||||
|
||||
@@ -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,10 +117,8 @@ export function FilterBar({
|
||||
const colorActive = colorLabel !== null
|
||||
const colorValue = colorActive ? colorLabel : null
|
||||
|
||||
const flagActive = flag !== 'any' || needsReview
|
||||
const flagValue = needsReview
|
||||
? 'needs review'
|
||||
: flag !== 'any'
|
||||
const flagActive = flag !== 'any'
|
||||
const flagValue = flagActive
|
||||
? flag === 'date_warning'
|
||||
? 'date issues'
|
||||
: flag
|
||||
@@ -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,36 +314,17 @@ 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 : '')
|
||||
)
|
||||
// the just-added value as the new single selection.
|
||||
const added = next.find((v) => v !== (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)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FilterPill>
|
||||
|
||||
@@ -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" /> },
|
||||
|
||||
@@ -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-
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user