"""Application settings — single source of truth, sourced from environment. Nothing is hardcoded. All knobs come from env vars (or a local ``.env`` during development). Access the cached singleton via :func:`get_settings`. """ from functools import lru_cache from pathlib import Path from typing import Literal from pydantic import Field, SecretStr, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", extra="ignore", case_sensitive=False, ) # -- runtime ---------------------------------------------------------- environment: Literal["dev", "test", "prod"] = "dev" log_level: str = "INFO" log_json: bool = Field( default=False, description="Structured JSON logs (enable in prod).", ) # -- database --------------------------------------------------------- # Async driver required (asyncpg). Example: # postgresql+asyncpg://mcma:mcma@db:5432/mcma database_url: str = "postgresql+asyncpg://mcma:mcma@localhost:5432/mcma" db_echo: bool = False db_pool_size: int = 5 db_max_overflow: int = 10 # -- redis (cache + arq broker) -------------------------------------- redis_url: str = "redis://localhost:6379/0" # -- auth ------------------------------------------------------------- jwt_secret: SecretStr = SecretStr("change-me-in-prod") jwt_algorithm: str = "HS256" access_token_ttl_seconds: int = 60 * 15 # 15 min refresh_token_ttl_seconds: int = 60 * 60 * 24 * 30 # 30 days (offline-first) # -- media / storage -------------------------------------------------- media_path: Path = Path("/data/media") transcode_cache_path: Path = Path("/data/transcode-cache") max_parallel_downloads: int = 2 # -- external services (all optional; graceful degradation) ---------- ml_service_url: str | None = None acoustid_api_key: SecretStr | None = None musicbrainz_user_agent: str = "mcma-backend/0.1.0 ( https://github.com/your/repo )" youtube_cookies_path: Path | None = None @field_validator("database_url") @classmethod def _require_async_driver(cls, v: str) -> str: if "+asyncpg" not in v: raise ValueError("database_url must use the asyncpg driver: postgresql+asyncpg://") return v @property def is_prod(self) -> bool: return self.environment == "prod" @lru_cache def get_settings() -> Settings: """Cached settings singleton. Patch the cache in tests via ``get_settings.cache_clear()``.""" return Settings()