"""Configuration, read once from the environment. Every value here is documented in docs/design.md §10. Defaults match that table. The Portainer stack is the single source of truth in production; .env is used in development via `python -m mediashelf.cli`. """ from __future__ import annotations import os from dataclasses import dataclass, field def _b(name: str, default: bool) -> bool: v = os.environ.get(name) if v is None or v == "": return default return v.strip().lower() in ("1", "true", "yes", "on") def _i(name: str, default: int) -> int: v = os.environ.get(name) if v is None or v == "": return default try: return int(v) except ValueError: return default def _f(name: str, default: float) -> float: v = os.environ.get(name) if v is None or v == "": return default try: return float(v) except ValueError: return default def _s(name: str, default: str = "") -> str: v = os.environ.get(name) return default if v is None else v.strip() def _csv(name: str) -> list[str]: raw = _s(name) return [p.strip() for p in raw.split(",") if p.strip()] @dataclass(frozen=True) class ScoreConfig: """Constants for the reclaim score (§6).""" stale_horizon_days: int = 730 age_horizon_days: int = 1095 popular_at: int = 3 rejected_at: int = 2 solitude_at: int = 3 grace_days: int = 30 recent_days: int = 90 # Default weight profile, calibrated against the measured library (§6.1/§6.2). weights: dict[str, float] = field( default_factory=lambda: { "size": 0.28, "staleness": 0.24, "unpopularity": 0.22, "solitude": 0.10, "age": 0.10, "rejection": 0.06, } ) @classmethod def from_env(cls) -> "ScoreConfig": return cls( stale_horizon_days=_i("SCORE_STALE_HORIZON_DAYS", 730), age_horizon_days=_i("SCORE_AGE_HORIZON_DAYS", 1095), popular_at=_i("SCORE_POPULAR_AT", 3), rejected_at=_i("SCORE_REJECTED_AT", 2), solitude_at=_i("SCORE_SOLITUDE_AT", 3), grace_days=_i("SCORE_GRACE_DAYS", 30), recent_days=_i("SCORE_RECENT_DAYS", 90), ) @dataclass(frozen=True) class Config: secret_key: str = "dev-insecure" log_level: str = "INFO" tz: str = "America/Regina" plex_base_url: str = "" plex_token: str = "" plex_verify_ssl: bool = True plex_timeout_s: int = 30 plex_page_size: int = 500 plex_request_delay_ms: int = 0 tautulli_base_url: str = "" tautulli_api_key: str = "" tautulli_timeout_s: int = 30 tautulli_page_size: int = 1000 history_source: str = "auto" # auto | tautulli | plex session_merge_window_h: int = 6 completion_threshold: int = 85 abandon_ceiling: int = 15 database_path: str = "/data/mediashelf.db" scan_schedule_cron: str = "0 4 * * *" scan_full_sweep_cron: str = "0 3 * * 0" scan_on_startup: bool = False scan_lock_timeout_s: int = 7200 scheduler_enabled: bool = True keep_all_libraries: list[str] = field(default_factory=list) score: ScoreConfig = field(default_factory=ScoreConfig) @classmethod def from_env(cls) -> "Config": return cls( secret_key=_s("MEDIASHELF_SECRET_KEY", "dev-insecure"), log_level=_s("LOG_LEVEL", "INFO").upper(), tz=_s("TZ", "America/Regina"), plex_base_url=_s("PLEX_BASE_URL").rstrip("/"), plex_token=_s("PLEX_TOKEN"), plex_verify_ssl=_b("PLEX_VERIFY_SSL", True), plex_timeout_s=_i("PLEX_TIMEOUT_S", 30), plex_page_size=_i("PLEX_PAGE_SIZE", 500), plex_request_delay_ms=_i("PLEX_REQUEST_DELAY_MS", 0), tautulli_base_url=_s("TAUTULLI_BASE_URL").rstrip("/"), tautulli_api_key=_s("TAUTULLI_API_KEY"), tautulli_timeout_s=_i("TAUTULLI_TIMEOUT_S", 30), tautulli_page_size=_i("TAUTULLI_PAGE_SIZE", 1000), history_source=_s("HISTORY_SOURCE", "auto").lower(), session_merge_window_h=_i("SESSION_MERGE_WINDOW_H", 6), completion_threshold=_i("COMPLETION_THRESHOLD", 85), abandon_ceiling=_i("ABANDON_CEILING", 15), database_path=_s("DATABASE_PATH", "/data/mediashelf.db"), scan_schedule_cron=_s("SCAN_SCHEDULE_CRON", "0 4 * * *"), scan_full_sweep_cron=_s("SCAN_FULL_SWEEP_CRON", "0 3 * * 0"), scan_on_startup=_b("SCAN_ON_STARTUP", False), scan_lock_timeout_s=_i("SCAN_LOCK_TIMEOUT_S", 7200), scheduler_enabled=_b("SCHEDULER_ENABLED", True), keep_all_libraries=_csv("KEEP_ALL_LIBRARIES"), score=ScoreConfig.from_env(), ) # ── derived ────────────────────────────────────────────────────────── @property def tautulli_configured(self) -> bool: return bool(self.tautulli_base_url and self.tautulli_api_key) @property def plex_configured(self) -> bool: return bool(self.plex_base_url and self.plex_token) def redacted(self) -> dict: """Safe for API responses and logs. Never leaks a credential (§12).""" out = {} for k, v in self.__dict__.items(): if k in ("plex_token", "tautulli_api_key", "secret_key"): out[k] = "***" if v else "" elif k == "score": out[k] = v.__dict__ else: out[k] = v return out