Files

236 lines
7.8 KiB
Python
Raw Permalink Normal View History

2026-06-08 00:01:55 +12:00
"""Runtime settings store.
2026-06-08 21:58:16 +12:00
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.
2026-06-08 00:01:55 +12:00
"""
from __future__ import annotations
import json
import os
2026-06-08 21:58:16 +12:00
from contextlib import contextmanager
2026-06-08 00:01:55 +12:00
from pathlib import Path
2026-06-08 21:58:16 +12:00
try:
import psycopg
from psycopg.rows import dict_row
except ImportError: # pragma: no cover - exercised via runtime status instead
psycopg = None
dict_row = None
2026-06-08 00:01:55 +12:00
2026-06-08 21:58:16 +12:00
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
2026-06-08 00:01:55 +12:00
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", ""),
2026-06-08 21:58:16 +12:00
"audiobookshelf_url": os.environ.get("AUDIOBOOKSHELF_URL", ""),
"audiobookshelf_token": os.environ.get("AUDIOBOOKSHELF_TOKEN", ""),
2026-06-08 00:01:55 +12:00
"music_root": os.environ.get("MUSIC_ROOT", r"\\Matt-htpc\d\Music"),
2026-06-08 21:58:16 +12:00
"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(),
2026-06-08 00:01:55 +12:00
}
2026-06-08 21:58:16 +12:00
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}
2026-06-08 00:01:55 +12:00
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 {}
2026-06-08 21:58:16 +12:00
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()
2026-06-08 00:01:55 +12:00
def load() -> dict:
2026-06-08 21:58:16 +12:00
"""Effective settings: env defaults overlaid with persisted overrides."""
2026-06-08 00:01:55 +12:00
values = env_defaults()
2026-06-08 21:58:16 +12:00
if using_postgres():
values.update(_read_postgres())
else:
values.update(_read_file())
2026-06-08 00:01:55 +12:00
return values
def save(updates: dict) -> dict:
"""Persist the editable subset of ``updates`` and return effective settings."""
2026-06-08 21:58:16 +12:00
current = _read_postgres() if using_postgres() else _read_file()
for key in FIELD_SPECS:
2026-06-08 00:01:55 +12:00
if key in updates and updates[key] is not None:
2026-06-08 21:58:16 +12:00
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)
2026-06-08 00:01:55 +12:00
return load()