From b3b167c075ee2f85379baf1075c95b377f03ae9c Mon Sep 17 00:00:00 2001 From: Developer Date: Fri, 26 Jun 2026 18:02:59 +0000 Subject: [PATCH] Backend: add backups + authentik service types, absorb jellyseerr (Slice 1) New service types: - backups: BackupsConfig(ingestion_label), no secrets, summary widget kind. Modeled as a service so it can be named/multi-instanced like others. - authentik: AuthentikConfig(base_url, timeout_seconds), api_token secret (required). Directory source for the upcoming Users tab. Jellyseerr absorption: - JellyfinConfig gains optional jellyseerr_url + jellyseerr_api_key fields. - integrations/jellyseerr.py deleted; registry entry removed. - clients/jellyseerr.py stays (JellyseerrClient still used by enrichment). - One-time idempotent migration in settings_store.ensure_defaults(): jellyseerr service rows merge into a paired Jellyfin (exactly-one merges; multiple picks first unpaired; none/all-paired drops with a logged warning). The api_key is decrypted from secrets before moving to config. Registry is now 8 types: alertmanager, authentik, backups, grafana, jellyfin, nextcloud, prometheus, ssh_tasks. Tests: registry count updated to 8, jellyseerr-absent assertion, new-type definition assertions, and migration tests (single-jellyfin merge, no- jellyfin drop, idempotency). 256 backend tests pass; ruff clean. Refs openspec/changes/services-as-hub-ia/ (spec R6, tasks slice 1). --- .../integrations/authentik.py | 35 +++++ .../integrations/backups.py | 48 +++++++ .../integrations/jellyfin.py | 11 +- .../integrations/jellyseerr.py | 33 ----- .../integrations/registry.py | 6 +- .../services/settings_store.py | 72 ++++++++++ backend/tests/test_services.py | 136 +++++++++++++++++- 7 files changed, 301 insertions(+), 40 deletions(-) create mode 100644 backend/src/media_library_viewer_api/integrations/authentik.py create mode 100644 backend/src/media_library_viewer_api/integrations/backups.py delete mode 100644 backend/src/media_library_viewer_api/integrations/jellyseerr.py diff --git a/backend/src/media_library_viewer_api/integrations/authentik.py b/backend/src/media_library_viewer_api/integrations/authentik.py new file mode 100644 index 0000000..bfb53a6 --- /dev/null +++ b/backend/src/media_library_viewer_api/integrations/authentik.py @@ -0,0 +1,35 @@ +"""Authentik service definition. + +Authentik is the user-directory source (replacing the Jellyfin-backed Users +page). Its directory API is queried via :class:`AuthentikClient` and surfaced +on the Authentik service page (Users + Messaging tabs). OIDC authentication +is unchanged -- this service type is for the directory, not SSO. +""" + +from __future__ import annotations + +from media_library_viewer_api.integrations.base import ( + SecretField, + ServiceBaseUrl, + ServiceConfigBase, + ServiceDefinition, +) + + +class AuthentikConfig(ServiceConfigBase): + """Non-secret Authentik connection config.""" + + base_url: ServiceBaseUrl + timeout_seconds: int = 10 + + +DEFINITION = ServiceDefinition( + service_type="authentik", + name="Authentik", + description="User directory and identity provider integration.", + config_model=AuthentikConfig, + secret_fields=[ + SecretField(key="api_token", label="API token", required=True), + ], + widget_kinds=[], +) diff --git a/backend/src/media_library_viewer_api/integrations/backups.py b/backend/src/media_library_viewer_api/integrations/backups.py new file mode 100644 index 0000000..08010a0 --- /dev/null +++ b/backend/src/media_library_viewer_api/integrations/backups.py @@ -0,0 +1,48 @@ +"""Backups service definition. + +Backups is modeled as a service type so it can be configured, named, and +multi-instanced like other services. Reports arrive via the existing REST +report endpoint; the ``ingestion_label`` disambiguates multi-instance +ingestion. +""" + +from __future__ import annotations + +from media_library_viewer_api.integrations.base import ( + ServiceConfigBase, + ServiceDefinition, + WidgetConfigBase, + widget_kind, +) + + +class BackupsConfig(ServiceConfigBase): + """Non-secret Backups connection config.""" + + ingestion_label: str = "default" + + +class BackupsSummaryWidgetConfig(WidgetConfigBase): + """Backup dashboard summary (jobs, runs, alerts).""" + + # No user-overridable fields; the widget reads the internal backup tables. + pass + + +DEFINITION = ServiceDefinition( + service_type="backups", + name="Backups", + description="Backup job monitoring, run history, and alerting.", + config_model=BackupsConfig, + secret_fields=[], + widget_kinds=[ + widget_kind( + kind="summary", + name="Summary", + description="Backup job summary and active alerts.", + model_cls=BackupsSummaryWidgetConfig, + default_config={}, + refresh_interval_ms=60_000, + ), + ], +) diff --git a/backend/src/media_library_viewer_api/integrations/jellyfin.py b/backend/src/media_library_viewer_api/integrations/jellyfin.py index efbab6b..4feba14 100644 --- a/backend/src/media_library_viewer_api/integrations/jellyfin.py +++ b/backend/src/media_library_viewer_api/integrations/jellyfin.py @@ -13,11 +13,20 @@ from media_library_viewer_api.integrations.base import ( class JellyfinConfig(ServiceConfigBase): - """Non-secret Jellyfin connection config.""" + """Non-secret Jellyfin connection config. + + The optional ``jellyseerr_url`` / ``jellyseerr_api_key`` fields carry the + paired Jellyseerr companion config, absorbed from the former standalone + ``jellyseerr`` service type (see OpenSpec change ``services-as-hub-ia``). + When both are set, the Jellyfin service page renders a Requests tab backed + by Jellyseerr. + """ base_url: ServiceBaseUrl user_id: str = "" timeout_seconds: int = 10 + jellyseerr_url: str = "" + jellyseerr_api_key: str = "" class JellyfinActivityWidgetConfig(WidgetConfigBase): diff --git a/backend/src/media_library_viewer_api/integrations/jellyseerr.py b/backend/src/media_library_viewer_api/integrations/jellyseerr.py deleted file mode 100644 index 1cf5d76..0000000 --- a/backend/src/media_library_viewer_api/integrations/jellyseerr.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Jellyseerr service definition. - -Jellyseerr is a companion to Jellyfin (request management). It is modeled as its -own service type so multiple Jellyseerr instances are supported independently of -Jellyfin. It provides no dashboard widgets today. -""" - -from __future__ import annotations - -from media_library_viewer_api.integrations.base import ( - SecretField, - ServiceBaseUrl, - ServiceConfigBase, - ServiceDefinition, -) - - -class JellyseerrConfig(ServiceConfigBase): - """Non-secret Jellyseerr connection config.""" - - base_url: ServiceBaseUrl - - -DEFINITION = ServiceDefinition( - service_type="jellyseerr", - name="Jellyseerr", - description="Request management companion to Jellyfin.", - config_model=JellyseerrConfig, - secret_fields=[ - SecretField(key="api_key", label="API key", required=True), - ], - widget_kinds=[], -) diff --git a/backend/src/media_library_viewer_api/integrations/registry.py b/backend/src/media_library_viewer_api/integrations/registry.py index a018c51..5d2c87f 100644 --- a/backend/src/media_library_viewer_api/integrations/registry.py +++ b/backend/src/media_library_viewer_api/integrations/registry.py @@ -7,10 +7,11 @@ There is no runtime plugin loading. from __future__ import annotations from media_library_viewer_api.integrations.alertmanager import DEFINITION as ALERTMANAGER +from media_library_viewer_api.integrations.authentik import DEFINITION as AUTHENTIK +from media_library_viewer_api.integrations.backups import DEFINITION as BACKUPS from media_library_viewer_api.integrations.base import ServiceDefinition, WidgetKind from media_library_viewer_api.integrations.grafana import DEFINITION as GRAFANA from media_library_viewer_api.integrations.jellyfin import DEFINITION as JELLYFIN -from media_library_viewer_api.integrations.jellyseerr import DEFINITION as JELLYSEERR from media_library_viewer_api.integrations.nextcloud import DEFINITION as NEXTCLOUD from media_library_viewer_api.integrations.prometheus import DEFINITION as PROMETHEUS from media_library_viewer_api.integrations.ssh_tasks import DEFINITION as SSH_TASKS @@ -20,9 +21,10 @@ SERVICE_DEFINITIONS: dict[str, ServiceDefinition] = { PROMETHEUS.service_type: PROMETHEUS, ALERTMANAGER.service_type: ALERTMANAGER, JELLYFIN.service_type: JELLYFIN, - JELLYSEERR.service_type: JELLYSEERR, NEXTCLOUD.service_type: NEXTCLOUD, SSH_TASKS.service_type: SSH_TASKS, + BACKUPS.service_type: BACKUPS, + AUTHENTIK.service_type: AUTHENTIK, } diff --git a/backend/src/media_library_viewer_api/services/settings_store.py b/backend/src/media_library_viewer_api/services/settings_store.py index e2021b2..9ebde32 100644 --- a/backend/src/media_library_viewer_api/services/settings_store.py +++ b/backend/src/media_library_viewer_api/services/settings_store.py @@ -8,6 +8,7 @@ in the same UI. from __future__ import annotations import json +import logging import sqlite3 import time import uuid @@ -19,6 +20,8 @@ import paramiko from media_library_viewer_api.models.widgets import _validate_config_keys +logger = logging.getLogger(__name__) + DEFAULT_SETTINGS_PATH = Path(".cache/media_library_viewer/settings.sqlite") LOCAL_MACHINE_ID = "local" DEFAULT_SERVICES = ["monitoring", "files"] @@ -415,6 +418,75 @@ class SettingsStore: row = conn.execute("SELECT COUNT(*) FROM monitoring_machines").fetchone() if not row or int(row[0]) == 0: self._seed_local_machine() + self._migrate_jellyseerr_into_jellyfin() + + def _migrate_jellyseerr_into_jellyfin(self) -> None: + """Absorb standalone ``jellyseerr`` services into their paired Jellyfin. + + Idempotent: once no ``jellyseerr`` rows remain the method is a no-op. + Pairing policy: exactly-one Jellyfin merges; multiple picks the first + Jellyfin whose ``jellyseerr_url`` is still empty; no Jellyfin or all + paired -> drop with a logged warning. + """ + from media_library_viewer_api.services.secrets import decrypt_value + + self.init_schema() + jellyseerr_rows: list[sqlite3.Row] = [] + with self.connect() as conn: + jellyseerr_rows = conn.execute( + "SELECT * FROM services WHERE service_type = 'jellyseerr' ORDER BY name ASC" + ).fetchall() + if not jellyseerr_rows: + return + + jellyfin_rows = self.list_services("jellyfin") + for js_row in jellyseerr_rows: + js_config = json.loads(js_row["config_json"] or "{}") + js_secrets = json.loads(js_row["secrets_json"] or "{}") + js_url = str(js_config.get("base_url", "")).strip() + js_api_key = str(js_secrets.get("api_key", "")).strip() + # Decrypt the api_key (secrets are stored encrypted; config is plaintext). + if js_api_key: + try: + js_api_key = decrypt_value(js_api_key) + except Exception: + logger.warning("could not decrypt jellyseerr api_key for %r", js_row["name"]) + js_api_key = "" + js_name = js_row["name"] + + target = None + if len(jellyfin_rows) == 1: + target = jellyfin_rows[0] + elif len(jellyfin_rows) > 1: + for jf in jellyfin_rows: + if not str(jf["config"].get("jellyseerr_url", "")).strip(): + target = jf + break + + if target: + merged_config = dict(target["config"]) + merged_config["jellyseerr_url"] = js_url + merged_config["jellyseerr_api_key"] = js_api_key + self.upsert_service( + { + "id": target["id"], + "service_type": "jellyfin", + "name": target["name"], + "config": merged_config, + "enabled": target["enabled"], + }, + secret_values={"api_key": str(target["secrets"].get("api_key", ""))}, + ) + logger.info("migrated jellyseerr service %r into jellyfin service %r", js_name, target["name"]) + else: + logger.warning( + "dropped unpaired jellyseerr service %r; reconfigure manually on the Jellyfin instance", + js_name, + ) + + with self.connect() as conn: + conn.execute("DELETE FROM services WHERE id = ?", (js_row["id"],)) + conn.commit() def list_machines(self) -> list[dict[str, Any]]: self.init_schema() diff --git a/backend/tests/test_services.py b/backend/tests/test_services.py index b041762..cfbe6ce 100644 --- a/backend/tests/test_services.py +++ b/backend/tests/test_services.py @@ -57,24 +57,55 @@ def client(tmp_path): # --------------------------------------------------------------------------- -def test_registry_contains_seven_service_types(): +def test_registry_contains_eight_service_types(): assert set(SERVICE_DEFINITIONS) == { "grafana", "prometheus", "alertmanager", "jellyfin", - "jellyseerr", "nextcloud", "ssh_tasks", + "backups", + "authentik", } +def test_jellyseerr_absorbed_into_jellyfin(): + """Jellyseerr is no longer its own service type (absorbed into Jellyfin).""" + assert "jellyseerr" not in SERVICE_DEFINITIONS + jellyfin_config = get_service_definition("jellyfin").config_schema["properties"] + assert "jellyseerr_url" in jellyfin_config + assert "jellyseerr_api_key" in jellyfin_config + + +def test_backups_service_definition(): + definition = get_service_definition("backups") + assert definition is not None + assert definition.secret_fields == [] + assert {wk.kind for wk in definition.widget_kinds} == {"summary"} + schema = definition.config_schema + assert "ingestion_label" in schema["properties"] + + +def test_authentik_service_definition(): + definition = get_service_definition("authentik") + assert definition is not None + assert {sf.key for sf in definition.secret_fields} == {"api_token"} + assert definition.secret_fields[0].required is True + assert definition.widget_kinds == [] + schema = definition.config_schema + assert "base_url" in schema["properties"] + assert "timeout_seconds" in schema["properties"] + + def test_definitions_declare_widget_kinds(): assert {wk.kind for wk in get_service_definition("grafana").widget_kinds} == {"link"} assert {wk.kind for wk in get_service_definition("prometheus").widget_kinds} == {"metric"} assert {wk.kind for wk in get_service_definition("alertmanager").widget_kinds} == {"active_alerts"} assert {wk.kind for wk in get_service_definition("jellyfin").widget_kinds} == {"activity"} assert get_service_definition("nextcloud").widget_kinds == [] + assert get_service_definition("authentik").widget_kinds == [] + assert {wk.kind for wk in get_service_definition("backups").widget_kinds} == {"summary"} assert {wk.kind for wk in get_service_definition("ssh_tasks").widget_kinds} == {"task_output"} @@ -139,9 +170,10 @@ def test_list_service_types(client): types = {item["service_type"] for item in response.json()} assert types == { "alertmanager", + "authentik", + "backups", "grafana", "jellyfin", - "jellyseerr", "nextcloud", "prometheus", "ssh_tasks", @@ -265,7 +297,7 @@ def test_service_base_url_requires_http_schema(bad_url): @pytest.mark.parametrize( - "service_type", ["grafana", "prometheus", "alertmanager", "jellyfin", "jellyseerr", "nextcloud"] + "service_type", ["grafana", "prometheus", "alertmanager", "jellyfin", "authentik", "nextcloud"] ) def test_service_base_url_accepts_absolute_urls(service_type): model = get_service_definition(service_type).config_model @@ -390,3 +422,99 @@ def test_record_and_list_service_task_runs(client): assert len(runs) == 1 assert runs[0]["status"] == "success" assert runs[0]["stdout_tail"] == "ok" + + +# --------------------------------------------------------------------------- +# Jellyseerr → Jellyfin migration (Slice 1.4 / 1.5) +# --------------------------------------------------------------------------- + + +def test_jellyseerr_migrates_into_single_jellyfin(tmp_path): + """A standalone jellyseerr service merges into the only jellyfin instance.""" + store = SettingsStore(tmp_path / "settings.sqlite") + store.ensure_defaults() + + jellyfin = store.upsert_service( + { + "service_type": "jellyfin", + "name": "Main Jellyfin", + "config": {"base_url": "https://jellyfin.example.com"}, + "enabled": True, + }, + secret_values={"api_key": "jf-key"}, + ) + store.upsert_service( + { + "service_type": "jellyseerr", + "name": "Main Jellyseerr", + "config": {"base_url": "https://jellyseerr.example.com"}, + "enabled": True, + }, + secret_values={"api_key": "js-key"}, + ) + + # Run migration via ensure_defaults (idempotent entry point). + store.ensure_defaults() + + # Jellyseerr row is gone. + assert store.list_services("jellyseerr") == [] + + # Jellyfin config gained the absorbed fields. + migrated = store.get_service(jellyfin["id"]) + assert migrated["config"]["jellyseerr_url"] == "https://jellyseerr.example.com" + assert migrated["config"]["jellyseerr_api_key"] == "js-key" + + +def test_jellyseerr_dropped_when_no_jellyfin(tmp_path): + """An unpaired jellyseerr (no jellyfin) is dropped with a warning, no crash.""" + store = SettingsStore(tmp_path / "settings.sqlite") + store.ensure_defaults() + + store.upsert_service( + { + "service_type": "jellyseerr", + "name": "Orphan Jellyseerr", + "config": {"base_url": "https://jellyseerr.example.com"}, + "enabled": True, + }, + secret_values={"api_key": "js-key"}, + ) + + store.ensure_defaults() + + assert store.list_services("jellyseerr") == [] + assert store.list_services("jellyfin") == [] + + +def test_jellyseerr_migration_is_idempotent(tmp_path): + """Running ensure_defaults twice does nothing the second time.""" + store = SettingsStore(tmp_path / "settings.sqlite") + store.ensure_defaults() + + store.upsert_service( + { + "service_type": "jellyfin", + "name": "JF", + "config": {"base_url": "https://jellyfin.example.com"}, + "enabled": True, + }, + secret_values={"api_key": "k"}, + ) + store.upsert_service( + { + "service_type": "jellyseerr", + "name": "JS", + "config": {"base_url": "https://jellyseerr.example.com"}, + "enabled": True, + }, + secret_values={"api_key": "k"}, + ) + + store.ensure_defaults() + first_jellyfin = store.list_services("jellyfin")[0] + first_url = first_jellyfin["config"]["jellyseerr_url"] + + store.ensure_defaults() # second run + second_jellyfin = store.list_services("jellyfin")[0] + assert second_jellyfin["config"]["jellyseerr_url"] == first_url + assert store.list_services("jellyseerr") == []