236 lines
7.8 KiB
Python
236 lines
7.8 KiB
Python
"""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()
|