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:
@@ -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):
|
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
|
base_url: ServiceBaseUrl
|
||||||
user_id: str = ""
|
user_id: str = ""
|
||||||
timeout_seconds: int = 10
|
timeout_seconds: int = 10
|
||||||
|
jellyseerr_url: str = ""
|
||||||
|
jellyseerr_api_key: str = ""
|
||||||
|
|
||||||
|
|
||||||
class JellyfinActivityWidgetConfig(WidgetConfigBase):
|
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 __future__ import annotations
|
||||||
|
|
||||||
from media_library_viewer_api.integrations.alertmanager import DEFINITION as ALERTMANAGER
|
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.base import ServiceDefinition, WidgetKind
|
||||||
from media_library_viewer_api.integrations.grafana import DEFINITION as GRAFANA
|
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.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.nextcloud import DEFINITION as NEXTCLOUD
|
||||||
from media_library_viewer_api.integrations.prometheus import DEFINITION as PROMETHEUS
|
from media_library_viewer_api.integrations.prometheus import DEFINITION as PROMETHEUS
|
||||||
from media_library_viewer_api.integrations.ssh_tasks import DEFINITION as SSH_TASKS
|
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,
|
PROMETHEUS.service_type: PROMETHEUS,
|
||||||
ALERTMANAGER.service_type: ALERTMANAGER,
|
ALERTMANAGER.service_type: ALERTMANAGER,
|
||||||
JELLYFIN.service_type: JELLYFIN,
|
JELLYFIN.service_type: JELLYFIN,
|
||||||
JELLYSEERR.service_type: JELLYSEERR,
|
|
||||||
NEXTCLOUD.service_type: NEXTCLOUD,
|
NEXTCLOUD.service_type: NEXTCLOUD,
|
||||||
SSH_TASKS.service_type: SSH_TASKS,
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
@@ -19,6 +20,8 @@ import paramiko
|
|||||||
|
|
||||||
from media_library_viewer_api.models.widgets import _validate_config_keys
|
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")
|
DEFAULT_SETTINGS_PATH = Path(".cache/media_library_viewer/settings.sqlite")
|
||||||
LOCAL_MACHINE_ID = "local"
|
LOCAL_MACHINE_ID = "local"
|
||||||
DEFAULT_SERVICES = ["monitoring", "files"]
|
DEFAULT_SERVICES = ["monitoring", "files"]
|
||||||
@@ -415,6 +418,75 @@ class SettingsStore:
|
|||||||
row = conn.execute("SELECT COUNT(*) FROM monitoring_machines").fetchone()
|
row = conn.execute("SELECT COUNT(*) FROM monitoring_machines").fetchone()
|
||||||
if not row or int(row[0]) == 0:
|
if not row or int(row[0]) == 0:
|
||||||
self._seed_local_machine()
|
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]]:
|
def list_machines(self) -> list[dict[str, Any]]:
|
||||||
self.init_schema()
|
self.init_schema()
|
||||||
|
|||||||
@@ -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) == {
|
assert set(SERVICE_DEFINITIONS) == {
|
||||||
"grafana",
|
"grafana",
|
||||||
"prometheus",
|
"prometheus",
|
||||||
"alertmanager",
|
"alertmanager",
|
||||||
"jellyfin",
|
"jellyfin",
|
||||||
"jellyseerr",
|
|
||||||
"nextcloud",
|
"nextcloud",
|
||||||
"ssh_tasks",
|
"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():
|
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("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("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("alertmanager").widget_kinds} == {"active_alerts"}
|
||||||
assert {wk.kind for wk in get_service_definition("jellyfin").widget_kinds} == {"activity"}
|
assert {wk.kind for wk in get_service_definition("jellyfin").widget_kinds} == {"activity"}
|
||||||
assert get_service_definition("nextcloud").widget_kinds == []
|
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"}
|
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()}
|
types = {item["service_type"] for item in response.json()}
|
||||||
assert types == {
|
assert types == {
|
||||||
"alertmanager",
|
"alertmanager",
|
||||||
|
"authentik",
|
||||||
|
"backups",
|
||||||
"grafana",
|
"grafana",
|
||||||
"jellyfin",
|
"jellyfin",
|
||||||
"jellyseerr",
|
|
||||||
"nextcloud",
|
"nextcloud",
|
||||||
"prometheus",
|
"prometheus",
|
||||||
"ssh_tasks",
|
"ssh_tasks",
|
||||||
@@ -265,7 +297,7 @@ def test_service_base_url_requires_http_schema(bad_url):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@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):
|
def test_service_base_url_accepts_absolute_urls(service_type):
|
||||||
model = get_service_definition(service_type).config_model
|
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 len(runs) == 1
|
||||||
assert runs[0]["status"] == "success"
|
assert runs[0]["status"] == "success"
|
||||||
assert runs[0]["stdout_tail"] == "ok"
|
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") == []
|
||||||
|
|||||||
Reference in New Issue
Block a user