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>
189 lines
7.3 KiB
Python
189 lines
7.3 KiB
Python
"""
|
|
Application configuration using Pydantic Settings
|
|
"""
|
|
from pydantic_settings import BaseSettings
|
|
from pydantic import BaseModel, Field
|
|
from typing import Optional
|
|
import yaml
|
|
from pathlib import Path
|
|
|
|
class ThumbnailSettings(BaseModel):
|
|
"""Thumbnail generation settings"""
|
|
small: int = 240
|
|
medium: int = 640
|
|
large: int = 1280
|
|
quality: int = 85
|
|
format: str = "webp"
|
|
|
|
class ScannerSettings(BaseModel):
|
|
"""File scanner settings"""
|
|
watch: bool = True
|
|
initial_scan_on_start: bool = True
|
|
batch_size: int = 100
|
|
concurrent_workers: int = 4
|
|
|
|
class PerformanceSettings(BaseModel):
|
|
"""Performance tuning settings"""
|
|
max_concurrent_thumbnails: int = 10
|
|
cache_ttl: int = 3600
|
|
db_pool_size: int = 10
|
|
db_pool_max_overflow: int = 10
|
|
db_pool_recycle: int = 3600
|
|
|
|
class MulitaConfig(BaseModel):
|
|
"""Main configuration from YAML file. Source roots and the discard
|
|
workflow are owned by the database now — only operational settings
|
|
live here."""
|
|
thumbnails: ThumbnailSettings = ThumbnailSettings()
|
|
scanner: ScannerSettings = ScannerSettings()
|
|
performance: PerformanceSettings = PerformanceSettings()
|
|
|
|
class Settings(BaseSettings):
|
|
"""Application settings"""
|
|
# 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"
|
|
)
|
|
|
|
# Redis
|
|
redis_url: str = Field(
|
|
default="redis://localhost:6379",
|
|
env="REDIS_URL"
|
|
)
|
|
|
|
# Celery
|
|
celery_broker_url: str = Field(
|
|
default="redis://localhost:6379",
|
|
env="CELERY_BROKER_URL"
|
|
)
|
|
celery_result_backend: str = Field(
|
|
default="redis://localhost:6379",
|
|
env="CELERY_RESULT_BACKEND"
|
|
)
|
|
|
|
# Photo directories
|
|
photo_dirs: str = Field(
|
|
default="/photos",
|
|
env="PHOTO_DIRS"
|
|
)
|
|
|
|
# API settings
|
|
api_host: str = Field(default="0.0.0.0", env="API_HOST")
|
|
api_port: int = Field(default=8000, env="API_PORT")
|
|
|
|
# CORS — comma-separated list of allowed origins, or "*" for any.
|
|
# Same-origin requests (the normal case behind nginx / vite proxy)
|
|
# never trip CORS, so this is only for direct browser access from
|
|
# other origins (LAN IP, reverse proxy, dev tools).
|
|
allowed_origins: str = Field(default="*", env="ALLOWED_ORIGINS")
|
|
|
|
# Logging — accepts standard python levels (DEBUG, INFO, WARNING,
|
|
# ERROR, CRITICAL). Bumped from INFO when chasing a problem.
|
|
log_level: str = Field(default="INFO", env="LOG_LEVEL")
|
|
|
|
# Auth — JWT signing key. Set SECRET_KEY in .env for production.
|
|
# If unset, a deterministic fallback is used (acceptable for
|
|
# single-machine homelab deploys, but set a real key if the instance
|
|
# is network-exposed).
|
|
secret_key: str = Field(
|
|
default="mulita-dev-secret-change-me",
|
|
env="SECRET_KEY",
|
|
)
|
|
access_token_expire_minutes: int = Field(default=525600, env="ACCESS_TOKEN_EXPIRE_MINUTES") # 1 year
|
|
refresh_token_expire_days: int = Field(default=3650, env="REFRESH_TOKEN_EXPIRE_DAYS") # 10 years
|
|
|
|
# ── OIDC / Authentik single sign-on ────────────────────────────────
|
|
# Disabled by default; enable by setting OIDC_ENABLED=true and the
|
|
# issuer + client credentials. When enabled the login page shows a
|
|
# "Sign in with {label}" button alongside the username/password form.
|
|
oidc_enabled: bool = Field(default=False, env="OIDC_ENABLED")
|
|
oidc_issuer: Optional[str] = Field(default=None, env="OIDC_ISSUER")
|
|
oidc_client_id: Optional[str] = Field(default=None, env="OIDC_CLIENT_ID")
|
|
oidc_client_secret: Optional[str] = Field(default=None, env="OIDC_CLIENT_SECRET")
|
|
# Absolute URL the IdP redirects back to. Must match the Redirect URI
|
|
# configured on the Authentik side exactly.
|
|
oidc_redirect_uri: Optional[str] = Field(default=None, env="OIDC_REDIRECT_URI")
|
|
oidc_scopes: str = Field(default="openid profile email", env="OIDC_SCOPES")
|
|
oidc_provider_label: str = Field(default="Authentik", env="OIDC_PROVIDER_LABEL")
|
|
# When true, a successful OIDC login for a subject we've never seen
|
|
# auto-creates a local user + their /photos/{username} folder. When
|
|
# false, unknown subjects get 403 and must be pre-provisioned.
|
|
oidc_allow_signup: bool = Field(default=True, env="OIDC_ALLOW_SIGNUP")
|
|
# Comma-separated Authentik group names. Any group-claim match
|
|
# promotes the user to role=admin; otherwise role=user. Role is
|
|
# refreshed on every sign-in so removals demote automatically.
|
|
oidc_admin_groups: str = Field(default="", env="OIDC_ADMIN_GROUPS")
|
|
# Last-resort link step: if (issuer, sub) AND email fallback both
|
|
# miss, try matching the IdP's `preferred_username` claim against
|
|
# `users.username`. Safe in single-tenant setups where the IdP is
|
|
# the source of truth for usernames (homelab, family instance).
|
|
# Leave off in multi-tenant — a name collision would hand someone
|
|
# else's account to a new SSO user.
|
|
oidc_link_by_username: bool = Field(default=False, env="OIDC_LINK_BY_USERNAME")
|
|
# Starlette session cookie secret — only used to hold PKCE/state
|
|
# during the brief OIDC round-trip. Falls back to secret_key when
|
|
# unset.
|
|
session_secret: Optional[str] = Field(default=None, env="SESSION_SECRET")
|
|
|
|
@property
|
|
def oidc_admin_group_list(self) -> list[str]:
|
|
raw = (self.oidc_admin_groups or "").strip()
|
|
return [g.strip() for g in raw.split(",") if g.strip()]
|
|
|
|
@property
|
|
def effective_session_secret(self) -> str:
|
|
return self.session_secret or self.secret_key
|
|
|
|
@property
|
|
def cors_origins(self) -> list[str]:
|
|
"""Parse the ALLOWED_ORIGINS env var into a list. Accepts:
|
|
- "*" → wildcard (single-element list ["*"])
|
|
- "http://a.com,http://b.com" → split + strip
|
|
Empty entries are dropped.
|
|
"""
|
|
raw = (self.allowed_origins or "").strip()
|
|
if not raw or raw == "*":
|
|
return ["*"]
|
|
return [o.strip() for o in raw.split(",") if o.strip()]
|
|
|
|
# App configuration from YAML
|
|
_config: Optional[MulitaConfig] = None
|
|
|
|
@property
|
|
def config(self) -> MulitaConfig:
|
|
"""Load configuration from YAML file"""
|
|
if self._config is None:
|
|
config_path = Path("/app/config/mulita.yml")
|
|
if not config_path.exists():
|
|
config_path = Path("mulita.yml")
|
|
|
|
if config_path.exists():
|
|
with open(config_path, "r") as f:
|
|
config_data = yaml.safe_load(f)
|
|
self._config = MulitaConfig(**config_data)
|
|
else:
|
|
self._config = MulitaConfig()
|
|
|
|
return self._config
|
|
|
|
@property
|
|
def thumbnails(self) -> ThumbnailSettings:
|
|
return self.config.thumbnails
|
|
|
|
@property
|
|
def scanner(self) -> ScannerSettings:
|
|
return self.config.scanner
|
|
|
|
@property
|
|
def performance(self) -> PerformanceSettings:
|
|
return self.config.performance
|
|
|
|
class Config:
|
|
env_file = ".env"
|
|
case_sensitive = False
|
|
|
|
# Global settings instance
|
|
settings = Settings() |