Homelabtoolkit v2
This commit is contained in:
+193
-21
@@ -1,27 +1,111 @@
|
||||
"""Runtime settings store.
|
||||
|
||||
Configuration can come from two places: environment variables (the deploy-time
|
||||
defaults) and a JSON file written by the in-app Settings page. The file, when
|
||||
present, wins. ``load`` returns the effective settings; ``save`` persists the
|
||||
editable subset and returns the new effective settings.
|
||||
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
|
||||
|
||||
SETTINGS_FILE = Path(os.environ.get("SETTINGS_FILE", "cache/settings.json"))
|
||||
try:
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
except ImportError: # pragma: no cover - exercised via runtime status instead
|
||||
psycopg = None
|
||||
dict_row = None
|
||||
|
||||
FIELDS = (
|
||||
"emby_url",
|
||||
"emby_api_key",
|
||||
"navidrome_url",
|
||||
"navidrome_user",
|
||||
"navidrome_password",
|
||||
"music_root",
|
||||
)
|
||||
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:
|
||||
@@ -31,10 +115,30 @@ def env_defaults() -> dict:
|
||||
"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 {}
|
||||
@@ -42,22 +146,90 @@ def _read_file() -> dict:
|
||||
data = json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
return {k: str(v) for k, v in data.items() if k in FIELDS and v is not None}
|
||||
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 the saved file."""
|
||||
"""Effective settings: env defaults overlaid with persisted overrides."""
|
||||
values = env_defaults()
|
||||
values.update(_read_file())
|
||||
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_file()
|
||||
for key in FIELDS:
|
||||
current = _read_postgres() if using_postgres() else _read_file()
|
||||
for key in FIELD_SPECS:
|
||||
if key in updates and updates[key] is not None:
|
||||
current[key] = str(updates[key]).strip()
|
||||
SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
SETTINGS_FILE.write_text(json.dumps(current, indent=2), encoding="utf-8")
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user