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).
This commit is contained in:
Developer
2026-06-26 18:02:59 +00:00
parent fe028b0e6f
commit b3b167c075
7 changed files with 301 additions and 40 deletions
@@ -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=[],
)
@@ -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,
),
],
)
@@ -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):
@@ -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=[],
)
@@ -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,
}
@@ -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()
+132 -4
View File
@@ -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") == []