"""Runtime, user-toggleable settings (persisted to disk). These are read live (unlike the env-var constants in config.py, which are fixed at startup), so the Settings UI can change them without a restart: - `allow_network` — whether StemDeck answers requests from other devices. - `max_duration_sec` — longest track accepted for processing. - `video_max_height` — max video resolution for MP4 export / YouTube pulls. Defaults fall back to the config.py constants (which honor their env vars), so nothing changes until the user overrides a value. """ from __future__ import annotations import json import logging import os import threading from app.core.config import DATA_DIR, MAX_DURATION_SEC, VIDEO_MAX_HEIGHT _log = logging.getLogger("stemdeck.settings") _SETTINGS_PATH = DATA_DIR / "settings.json" _LOCK = threading.RLock() _state: dict | None = None # whole settings dict, loaded lazily # Clamp bounds. Max track length is capped at 20 min (the product ceiling). _DURATION_MIN, _DURATION_MAX = 60, 1200 # 1 min .. 20 min _HEIGHT_MIN, _HEIGHT_MAX = 144, 2160 _PORT_MIN, _PORT_MAX = 1024, 65535 DEFAULT_PORT = 8000 def _default_allow_network() -> bool: # STEMDECK_ALLOW_NETWORK takes precedence when set explicitly. # Otherwise: desktop keeps network off (user opts in via UI toggle); # server/Docker deployments open it by default since network access is # the entire point of a headless deployment. env = os.environ.get("STEMDECK_ALLOW_NETWORK") if env is not None: return env.strip() == "1" return os.environ.get("STEMDECK_DESKTOP") != "1" def _load() -> dict: try: data = json.loads(_SETTINGS_PATH.read_text(encoding="utf-8")) if isinstance(data, dict): return data except FileNotFoundError: pass # no settings file yet — first run; use defaults except Exception: # Corrupt/unreadable file: fall back to defaults rather than crash. _log.warning("could not read settings from %s", _SETTINGS_PATH, exc_info=True) return {} def _ensure() -> dict: global _state if _state is None: _state = _load() return _state def _save() -> None: try: _SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True) _SETTINGS_PATH.write_text(json.dumps(_ensure()), encoding="utf-8") except Exception: # Persistence is best-effort (read-only FS, permissions): the in-memory # value still applies for this session, so don't fail the request. _log.warning("could not persist settings to %s", _SETTINGS_PATH, exc_info=True) def _num(v: object) -> int | None: return int(v) if isinstance(v, (int, float)) and not isinstance(v, bool) else None # ── allow_network ── def get_allow_network() -> bool: with _LOCK: v = _ensure().get("allow_network") return v if isinstance(v, bool) else _default_allow_network() def set_allow_network(value: bool) -> bool: with _LOCK: _ensure()["allow_network"] = bool(value) _save() return bool(value) # ── max_duration_sec ── def get_max_duration_sec() -> int: with _LOCK: v = _num(_ensure().get("max_duration_sec")) return max(_DURATION_MIN, min(_DURATION_MAX, v)) if v is not None else MAX_DURATION_SEC def set_max_duration_sec(value: int) -> int: with _LOCK: clamped = max(_DURATION_MIN, min(_DURATION_MAX, int(value))) _ensure()["max_duration_sec"] = clamped _save() return clamped # ── video_max_height ── def get_video_max_height() -> int: with _LOCK: v = _num(_ensure().get("video_max_height")) return max(_HEIGHT_MIN, min(_HEIGHT_MAX, v)) if v is not None else VIDEO_MAX_HEIGHT def set_video_max_height(value: int) -> int: with _LOCK: clamped = max(_HEIGHT_MIN, min(_HEIGHT_MAX, int(value))) _ensure()["video_max_height"] = clamped _save() return clamped # ── port ── # The preferred port the server binds on launch. The desktop launcher reads this # (default 8000) before spawning the backend; a self-hosted server's --port wins. # Changing it needs a restart — the socket is bound at startup. def get_port() -> int: with _LOCK: v = _num(_ensure().get("port")) return max(_PORT_MIN, min(_PORT_MAX, v)) if v is not None else DEFAULT_PORT def set_port(value: int) -> int: with _LOCK: clamped = max(_PORT_MIN, min(_PORT_MAX, int(value))) _ensure()["port"] = clamped _save() return clamped