"""Runtime settings store. Settings can come from three places: 1. environment variables (deploy-time defaults) 2. a legacy JSON file (local/dev compatibility and migration source) 3. PostgreSQL when ``DATABASE_URL`` is configured When PostgreSQL is available, settings are persisted there and any legacy JSON settings are migrated on startup. The JSON file remains as a fallback for non-database local runs. """ from __future__ import annotations import json import os from contextlib import contextmanager from pathlib import Path try: import psycopg from psycopg.rows import dict_row except ImportError: # pragma: no cover - exercised via runtime status instead psycopg = None dict_row = None from services import emby_tasks as emby_tasks_service SETTINGS_FILE = Path(os.environ.get("SETTINGS_FILE", "cache/settings.json")) DATABASE_URL = os.environ.get("DATABASE_URL", "").strip() FIELD_SPECS = { "emby_url": "string", "emby_api_key": "string", "navidrome_url": "string", "navidrome_user": "string", "navidrome_password": "string", "audiobookshelf_url": "string", "audiobookshelf_token": "string", "music_root": "string", "homescreen_db_path": "string", "tmdb_api_key": "string", "deploy_nas_host": "string", "deploy_nas_user": "string", "deploy_nas_password": "string", "deploy_remote_app_dir": "string", "deploy_music_host_path": "string", "preroll_enabled": "bool", "preroll_active_dir": "string", "preroll_inactive_dir": "string", "preroll_state_file": "string", "preroll_weekday": "int", "preroll_time": "string", "emby_tasks": "object", } FIELD_DEFAULTS = { "preroll_enabled": False, "preroll_weekday": 0, } SCHEMA = """ CREATE TABLE IF NOT EXISTS app_settings ( key TEXT PRIMARY KEY, value_json JSONB NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); """ def _env(name: str, legacy_name: str | None = None, default: str = "") -> str: if os.environ.get(name) is not None: return os.environ[name] if legacy_name and os.environ.get(legacy_name) is not None: return os.environ[legacy_name] return default def _coerce_value(field: str, value): kind = FIELD_SPECS[field] if kind == "bool": if isinstance(value, bool): return value return str(value).strip().lower() in {"1", "true", "yes", "on"} if kind == "int": try: return int(value) except (TypeError, ValueError): return FIELD_DEFAULTS.get(field, 0) if kind == "object": if field == "emby_tasks": return emby_tasks_service.normalize_settings(value) return value if isinstance(value, dict) else {} if value is None: return "" return str(value) def using_postgres() -> bool: return bool(DATABASE_URL and psycopg is not None) @contextmanager def postgres_connect(): if not using_postgres(): raise RuntimeError("PostgreSQL settings store is not configured.") with psycopg.connect(DATABASE_URL, row_factory=dict_row) as conn: yield conn def env_defaults() -> dict: return { "emby_url": os.environ.get("EMBY_URL", "http://10.0.0.2:8096"), "emby_api_key": os.environ.get("EMBY_API_KEY", ""), "navidrome_url": os.environ.get("NAVIDROME_URL", "http://10.0.0.2:4533"), "navidrome_user": os.environ.get("NAVIDROME_USER", ""), "navidrome_password": os.environ.get("NAVIDROME_PASSWORD", ""), "audiobookshelf_url": os.environ.get("AUDIOBOOKSHELF_URL", ""), "audiobookshelf_token": os.environ.get("AUDIOBOOKSHELF_TOKEN", ""), "music_root": os.environ.get("MUSIC_ROOT", r"\\Matt-htpc\d\Music"), "homescreen_db_path": os.environ.get("HOMESCREEN_DB_PATH", ""), "tmdb_api_key": os.environ.get("TMDB_API_KEY", ""), "deploy_nas_host": os.environ.get("DEPLOY_NAS_HOST", ""), "deploy_nas_user": os.environ.get("DEPLOY_NAS_USER", ""), "deploy_nas_password": os.environ.get("DEPLOY_NAS_PASSWORD", ""), "deploy_remote_app_dir": os.environ.get("DEPLOY_REMOTE_APP_DIR", "/share/Docker/homelabtoolkit"), "deploy_music_host_path": os.environ.get("DEPLOY_MUSIC_HOST_PATH", "/share/Music"), "preroll_enabled": _coerce_value("preroll_enabled", _env("PREROLL_ENABLED", default="false")), "preroll_active_dir": _env("PREROLL_ACTIVE_DIR", "ACTIVE_DIR", "/media/Prerolls"), "preroll_inactive_dir": _env("PREROLL_INACTIVE_DIR", "INACTIVE_DIR", "/media/Prerolls - Not Active"), "preroll_state_file": _env("PREROLL_STATE_FILE", "STATE_FILE", "cache/preroll-state.json"), "preroll_weekday": _coerce_value("preroll_weekday", _env("PREROLL_WEEKDAY", "ROTATE_WEEKDAY", "0")), "preroll_time": _env("PREROLL_TIME", "SCHEDULE_TIME", "02:00"), "emby_tasks": emby_tasks_service.default_settings(), } def _normalize_payload(data: dict) -> dict: return {k: _coerce_value(k, v) for k, v in data.items() if k in FIELD_SPECS and v is not None} def _read_file() -> dict: if not SETTINGS_FILE.exists(): return {} try: data = json.loads(SETTINGS_FILE.read_text(encoding="utf-8")) except (OSError, ValueError): return {} return _normalize_payload(data if isinstance(data, dict) else {}) def _write_file(values: dict) -> None: SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True) SETTINGS_FILE.write_text(json.dumps(values, indent=2), encoding="utf-8") def _ensure_postgres_schema() -> None: with postgres_connect() as conn: with conn.cursor() as cur: cur.execute(SCHEMA) conn.commit() def _read_postgres() -> dict: with postgres_connect() as conn: with conn.cursor() as cur: cur.execute("SELECT key, value_json FROM app_settings") rows = cur.fetchall() data: dict[str, object] = {} for row in rows: data[row["key"]] = row["value_json"] return _normalize_payload(data) def _write_postgres(values: dict) -> None: payload = _normalize_payload(values) with postgres_connect() as conn: with conn.cursor() as cur: for key, value in payload.items(): cur.execute( """ INSERT INTO app_settings (key, value_json, updated_at) VALUES (%s, %s::jsonb, NOW()) ON CONFLICT (key) DO UPDATE SET value_json = EXCLUDED.value_json, updated_at = NOW() """, (key, json.dumps(value)), ) conn.commit() def _migrate_file_to_postgres() -> None: file_values = _read_file() if not file_values: return current_db = _read_postgres() merged = dict(current_db) for key, value in file_values.items(): if key not in merged or merged[key] in ("", {}, 0, False): merged[key] = value _write_postgres(merged) def init_store() -> None: if using_postgres(): _ensure_postgres_schema() _migrate_file_to_postgres() def load() -> dict: """Effective settings: env defaults overlaid with persisted overrides.""" values = env_defaults() if using_postgres(): values.update(_read_postgres()) else: values.update(_read_file()) return values def save(updates: dict) -> dict: """Persist the editable subset of ``updates`` and return effective settings.""" current = _read_postgres() if using_postgres() else _read_file() for key in FIELD_SPECS: if key in updates and updates[key] is not None: value = updates[key] if FIELD_SPECS[key] == "string": current[key] = str(value).strip() else: current[key] = _coerce_value(key, value) if using_postgres(): _ensure_postgres_schema() _write_postgres(current) else: _write_file(current) return load()