feat(widgets): add backend CRUD, registry, and default seeding

Introduce a closed, compile-time widget registry and backend CRUD for
dashboard widget instances.

- Add dashboard_widgets SQLite table in SettingsStore with CRUD helpers and
  default seeding (Jellyfin + Backups) on first install.
- Add Pydantic models with credential-key and secret-value rejection.
- Add widgets router: /api/widgets/sources, /types, /instances CRUD.
- Call ensure_defaults() in app lifespan so fresh installs seed defaults.
- Add backend tests covering registry, CRUD, validation, and seeding.
- Include SDD artifacts: exploration, proposal, spec, design, tasks.
This commit is contained in:
Developer
2026-06-19 20:07:47 +00:00
parent 24427b4869
commit 200d319fb0
13 changed files with 2894 additions and 6 deletions
@@ -18,6 +18,7 @@ from typing import Any
import paramiko
from media_library_viewer_api.config import get_settings
from media_library_viewer_api.models.widgets import _validate_config_keys
DEFAULT_SETTINGS_PATH = Path(".cache/media_library_viewer/settings.sqlite")
LOCAL_MACHINE_ID = "local"
@@ -163,6 +164,24 @@ class SettingsStore:
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_dashboard_shortcuts_type ON dashboard_shortcuts(shortcut_type)"
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS dashboard_widgets (
id TEXT PRIMARY KEY,
addon_id TEXT NOT NULL,
widget_type TEXT NOT NULL,
title TEXT NOT NULL,
config_json TEXT NOT NULL DEFAULT '{}',
enabled INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_sort ON dashboard_widgets(sort_order)"
)
conn.execute("""
CREATE TABLE IF NOT EXISTS backup_jobs (
id TEXT PRIMARY KEY,
@@ -353,12 +372,8 @@ class SettingsStore:
"notes": notes,
}
def ensure_defaults(self) -> None:
self.init_schema()
with self.connect() as conn:
row = conn.execute("SELECT COUNT(*) FROM monitoring_machines").fetchone()
if row and int(row[0]) > 0:
return
def _seed_local_machine(self) -> None:
"""Seed the default local machine if none exists."""
machine = _default_local_machine()
now = int(time.time())
config = {
@@ -401,6 +416,49 @@ class SettingsStore:
),
)
def _seed_dashboard_widgets(self) -> None:
"""Seed default dashboard widgets only when the table is empty."""
from media_library_viewer_api.widgets.registry import WIDGET_REGISTRY
self.init_schema()
with self.connect() as conn:
row = conn.execute("SELECT COUNT(*) FROM dashboard_widgets").fetchone()
if row and int(row[0]) > 0:
return
defaults = [
{
"id": "jellyfin-activity-default",
"addon_id": "core",
"widget_type": "jellyfin",
"title": "Jellyfin activity",
"config": {"machine_id": ""},
"enabled": True,
"sort_order": 0,
},
{
"id": "backups-summary-default",
"addon_id": "backups",
"widget_type": "backups",
"title": "Backups",
"config": {},
"enabled": True,
"sort_order": 1,
},
]
for widget in defaults:
info = WIDGET_REGISTRY.get(widget["widget_type"])
if not info or info["addon_id"] != widget["addon_id"]:
continue
self.upsert_widget(widget)
def ensure_defaults(self) -> None:
self.init_schema()
with self.connect() as conn:
row = conn.execute("SELECT COUNT(*) FROM monitoring_machines").fetchone()
if not row or int(row[0]) == 0:
self._seed_local_machine()
self._seed_dashboard_widgets()
def list_machines(self) -> list[dict[str, Any]]:
self.init_schema()
with self.connect() as conn:
@@ -1306,6 +1364,119 @@ class SettingsStore:
)
# ------------------------------------------------------------------
# Dashboard widgets
# ------------------------------------------------------------------
def _row_to_widget(self, row: sqlite3.Row) -> dict[str, Any]:
return {
"id": row["id"],
"addon_id": row["addon_id"],
"widget_type": row["widget_type"],
"title": row["title"],
"config": json.loads(row["config_json"] or "{}"),
"enabled": bool(row["enabled"]),
"sort_order": int(row["sort_order"]),
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
def _normalize_widget_payload(
self,
payload: dict[str, Any],
widget_id: str | None = None,
) -> dict[str, Any]:
current = self.get_widget(widget_id) if widget_id else None
widget_id = (
str(payload.get("id") or widget_id or uuid.uuid4().hex[:12]).strip()
or uuid.uuid4().hex[:12]
)
addon_id = str(payload.get("addon_id") or (current or {}).get("addon_id", "")).strip()
widget_type = str(
payload.get("widget_type") or (current or {}).get("widget_type", "")
).strip()
title = str(payload.get("title") or (current or {}).get("title", "") or "").strip()
config = payload.get("config", (current or {}).get("config", {}))
if not isinstance(config, dict):
config = {}
# Defense-in-depth: reject credential keys at the store layer too.
_validate_config_keys(config)
enabled = bool(payload.get("enabled", (current or {}).get("enabled", True)))
sort_order = int(payload.get("sort_order", (current or {}).get("sort_order", 0)) or 0)
return {
"id": widget_id,
"addon_id": addon_id,
"widget_type": widget_type,
"title": title,
"config": config,
"enabled": enabled,
"sort_order": sort_order,
}
def list_widgets(self) -> list[dict[str, Any]]:
self.init_schema()
with self.connect() as conn:
rows = conn.execute(
"SELECT * FROM dashboard_widgets ORDER BY sort_order ASC, created_at ASC"
).fetchall()
return [self._row_to_widget(row) for row in rows]
def get_widget(self, widget_id: str | None) -> dict[str, Any] | None:
if not widget_id:
return None
self.init_schema()
with self.connect() as conn:
row = conn.execute(
"SELECT * FROM dashboard_widgets WHERE id = ?", (widget_id,)
).fetchone()
return self._row_to_widget(row) if row else None
def upsert_widget(self, payload: dict[str, Any], widget_id: str | None = None) -> dict[str, Any]:
self.init_schema()
widget = self._normalize_widget_payload(payload, widget_id)
now = int(time.time())
with self.connect() as conn:
existing = conn.execute(
"SELECT created_at FROM dashboard_widgets WHERE id = ?",
(widget["id"],),
).fetchone()
created_at = int(existing[0]) if existing else now
conn.execute(
"""
INSERT INTO dashboard_widgets (
id, addon_id, widget_type, title, config_json, enabled,
sort_order, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
addon_id = excluded.addon_id,
widget_type = excluded.widget_type,
title = excluded.title,
config_json = excluded.config_json,
enabled = excluded.enabled,
sort_order = excluded.sort_order,
updated_at = excluded.updated_at
""",
(
widget["id"],
widget["addon_id"],
widget["widget_type"],
widget["title"],
json.dumps(widget["config"]),
1 if widget["enabled"] else 0,
widget["sort_order"],
created_at,
now,
),
)
return self.get_widget(widget["id"]) or widget
def delete_widget(self, widget_id: str) -> None:
self.init_schema()
with self.connect() as conn:
conn.execute("DELETE FROM dashboard_widgets WHERE id = ?", (widget_id,))
_store: SettingsStore | None = None