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