From 8ff735d644f793c439aadb8d46c5025aa43b072d Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 23 Jun 2026 11:43:33 +0000 Subject: [PATCH 1/2] feat(services): resolve Jellyfin/Jellyseerr from the service registry (backend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 4b backend half. Jellyfin and Jellyseerr clients are now resolved from service instances instead of machine-level app config. - Add jellyseerr service definition (6 service types total); add user_id to the Jellyfin service config. - dependencies.py: jellyfin_service_id query param + _service_record (decrypt-on-read); get_jellyfin_client / get_jellyseerr_client / get_user_id resolve against the service registry (first enabled instance as fallback). - SSH/Files transport (get_ssh_client) unchanged; still uses machine_id. - Update service-registry tests for 6 types. Selection model: split params — ?jellyfin_service_id= for Jellyfin/Jellyseerr, ?machine_id= for SSH/Files. Frontend threading follows in the next PR. Verification: backend ruff clean, pytest 222 passed; frontend green (unchanged). --- .../media_library_viewer_api/dependencies.py | 111 ++++++++++++------ .../integrations/jellyseerr.py | 32 +++++ .../integrations/registry.py | 2 + backend/tests/test_services.py | 10 +- .../service-registry/apply-progress.md | 45 ++++++- 5 files changed, 155 insertions(+), 45 deletions(-) create mode 100644 backend/src/media_library_viewer_api/integrations/jellyseerr.py diff --git a/backend/src/media_library_viewer_api/dependencies.py b/backend/src/media_library_viewer_api/dependencies.py index 892ed44..7bfd263 100644 --- a/backend/src/media_library_viewer_api/dependencies.py +++ b/backend/src/media_library_viewer_api/dependencies.py @@ -1,9 +1,12 @@ """Dependency injection for FastAPI. -Provides access to machine-specific Jellyfin/SSH clients via FastAPI's request -context. The selected machine can be chosen with a ``machine_id`` query -parameter; otherwise the backend falls back to the first enabled machine that -matches the requested service. +Provides access to service-specific Jellyfin/Jellyseerr clients and +machine-specific SSH clients via FastAPI's request context. + +- Jellyfin/Jellyseerr are selected with a ``jellyfin_service_id`` query + parameter (resolved against the service registry); the backend falls back to + the first enabled ``jellyfin``/``jellyseerr`` service instance. +- SSH/Files transport is selected with ``machine_id`` as before. """ from __future__ import annotations @@ -34,6 +37,41 @@ def _request_machine_id(request: Request | None) -> str | None: return machine_id or None +def _request_jellyfin_service_id(request: Request | None) -> str | None: + if request is None: + return None + service_id = request.query_params.get("jellyfin_service_id") + return service_id or None + + +def _service_record(store: SettingsStore, service_type: str, service_id: str | None) -> dict[str, Any] | None: + """Return a service row for a type, preferring the requested id. + + The row carries an in-memory decrypted ``secrets`` dict. Returns None if no + enabled instance of the type exists. + """ + from media_library_viewer_api.services.secrets import decrypt_secrets + + row = None + if service_id: + candidate = store.get_service(service_id) + if candidate and candidate.get("service_type") == service_type and candidate.get("enabled", True): + row = candidate + if row is None: + instances = [s for s in store.list_services(service_type) if s.get("enabled", True)] + row = instances[0] if instances else None + if row is None: + return None + decrypted = {} + blob = row.get("secrets") or {} + if blob: + try: + decrypted = decrypt_secrets(blob) + except Exception: + logger.exception("Failed to decrypt service secrets service_id=%s", row.get("id")) + return {**row, "secrets": decrypted} + + @lru_cache(maxsize=32) def _jellyfin_client_for(cache_key: tuple[str, str, str]) -> JellyfinClient: machine_id, url, api_key = cache_key @@ -135,37 +173,38 @@ def _resolve_machine(service: str, request: Request | None = None) -> dict[str, def get_jellyfin_client(request: Request = None) -> JellyfinClient: - """Return a Jellyfin client for the selected machine.""" + """Return a Jellyfin client for the selected Jellyfin service instance.""" store = get_settings_store() - machine_id = _request_machine_id(request) - machine = store.get_machine_config(machine_id) if machine_id else None - if machine is None: - resolved = _resolve_machine("jellyfin", request) - if resolved: - machine = store.get_machine_config(resolved["id"]) - if machine and machine.get("jellyfin_url") and machine.get("jellyfin_api_key"): - cache_key = (machine["id"], machine["jellyfin_url"], machine.get("jellyfin_api_key") or "") - return _jellyfin_client_for(cache_key) - - raise RuntimeError( - "No Jellyfin machine is configured. Add a machine with jellyfin_url and jellyfin_api_key in Settings." - ) + service_id = _request_jellyfin_service_id(request) + service = _service_record(store, "jellyfin", service_id) + if service is None: + raise RuntimeError( + "No Jellyfin service is configured. Add a Jellyfin service on the Services page." + ) + base_url = str(service.get("config", {}).get("base_url") or "") + api_key = str(service.get("secrets", {}).get("api_key") or "") + if not base_url or not api_key: + raise RuntimeError( + "Jellyfin service is missing base_url or api_key. Edit it on the Services page." + ) + cache_key = (service["id"], base_url, api_key) + return _jellyfin_client_for(cache_key) def get_jellyseerr_client(request: Request = None) -> JellyseerrClient | None: """Return a cached Jellyseerr client when configured, otherwise None.""" store = get_settings_store() - machine_id = _request_machine_id(request) - machine = store.get_machine_config(machine_id) if machine_id else None - if machine is None: - resolved = _resolve_machine("jellyseerr", request) - if resolved: - machine = store.get_machine_config(resolved["id"]) - if machine and machine.get("jellyseerr_url") and machine.get("jellyseerr_api_key"): - return JellyseerrClient(machine["jellyseerr_url"], machine.get("jellyseerr_api_key") or "") - - logger.info("Jellyseerr client not configured (no machine with jellyseerr_url and jellyseerr_api_key)") - return None + service_id = _request_jellyfin_service_id(request) + service = _service_record(store, "jellyseerr", service_id) + if service is None: + logger.info("Jellyseerr client not configured (no jellyseerr service)") + return None + base_url = str(service.get("config", {}).get("base_url") or "") + api_key = str(service.get("secrets", {}).get("api_key") or "") + if not base_url or not api_key: + logger.info("Jellyseerr service is missing base_url or api_key") + return None + return JellyseerrClient(base_url, api_key) def _ssh_client_from_machine_config(machine: dict[str, Any], store: SettingsStore | None = None) -> RemoteSSHClient: @@ -253,16 +292,12 @@ def get_settings_store() -> SettingsStore: def get_user_id(request: Request = None) -> str: """Return the configured Jellyfin user ID or discover the first available one.""" store = get_settings_store() - machine_id = _request_machine_id(request) - machine = store.get_machine_config(machine_id) if machine_id else None - if machine is None: - resolved = _resolve_machine("jellyfin", request) - if resolved: - machine = store.get_machine_config(resolved["id"]) - if machine and machine.get("jellyfin_user_id"): - return str(machine["jellyfin_user_id"]) + service_id = _request_jellyfin_service_id(request) + service = _service_record(store, "jellyfin", service_id) + if service and service.get("config", {}).get("user_id"): + return str(service["config"]["user_id"]) client = get_jellyfin_client(request) users = client.users() if not users: - raise RuntimeError("No Jellyfin users found and no machine/user id configured") + raise RuntimeError("No Jellyfin users found and no user_id configured on the service") return users[0]["Id"] diff --git a/backend/src/media_library_viewer_api/integrations/jellyseerr.py b/backend/src/media_library_viewer_api/integrations/jellyseerr.py new file mode 100644 index 0000000..cea0b8b --- /dev/null +++ b/backend/src/media_library_viewer_api/integrations/jellyseerr.py @@ -0,0 +1,32 @@ +"""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, + ServiceConfigBase, + ServiceDefinition, +) + + +class JellyseerrConfig(ServiceConfigBase): + """Non-secret Jellyseerr connection config.""" + + base_url: str + + +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 199a2fe..6aaf946 100644 --- a/backend/src/media_library_viewer_api/integrations/registry.py +++ b/backend/src/media_library_viewer_api/integrations/registry.py @@ -9,6 +9,7 @@ from __future__ import annotations 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 @@ -17,6 +18,7 @@ SERVICE_DEFINITIONS: dict[str, ServiceDefinition] = { GRAFANA.service_type: GRAFANA, PROMETHEUS.service_type: PROMETHEUS, JELLYFIN.service_type: JELLYFIN, + JELLYSEERR.service_type: JELLYSEERR, NEXTCLOUD.service_type: NEXTCLOUD, SSH_TASKS.service_type: SSH_TASKS, } diff --git a/backend/tests/test_services.py b/backend/tests/test_services.py index d5295a6..6abbcd6 100644 --- a/backend/tests/test_services.py +++ b/backend/tests/test_services.py @@ -61,6 +61,7 @@ def test_registry_contains_five_service_types(): "grafana", "prometheus", "jellyfin", + "jellyseerr", "nextcloud", "ssh_tasks", } @@ -133,7 +134,14 @@ def test_list_service_types(client): response = client.get("/api/services/types") assert response.status_code == 200 types = {item["service_type"] for item in response.json()} - assert types == {"grafana", "prometheus", "jellyfin", "nextcloud", "ssh_tasks"} + assert types == { + "grafana", + "jellyfin", + "jellyseerr", + "nextcloud", + "prometheus", + "ssh_tasks", + } def test_service_type_includes_secret_and_widget_metadata(client): diff --git a/openspec/changes/service-registry/apply-progress.md b/openspec/changes/service-registry/apply-progress.md index b06ae0b..536a31e 100644 --- a/openspec/changes/service-registry/apply-progress.md +++ b/openspec/changes/service-registry/apply-progress.md @@ -57,10 +57,43 @@ npm run build # success npm run test # 70 passed ``` -## Remaining work +## Slice 4b — Jellyfin/Jellyseerr → services migration (in progress) -- Slice 4b: add `jellyseerr` service definition; rewire `dependencies.py` - Jellyfin/Jellyseerr resolution to the service registry; migrate the - Media/Users/Files/Dashboard selector from machine_id to service instance; - remove machine-level Jellyfin/Jellyseerr fields from `settings_store.py`, - `routers/settings.py`, and the Settings UI. +### Completed (backend, this PR) + +- [x] Added `jellyseerr` service definition (`integrations/jellyseerr.py`) and + registered it (6 service types total). +- [x] Added `user_id` to the Jellyfin service config. +- [x] `dependencies.py`: new `_request_jellyfin_service_id` + `_service_record` + (decrypt-on-read). Rewrote `get_jellyfin_client`, `get_jellyseerr_client`, + and `get_user_id` to resolve against the service registry via the + `jellyfin_service_id` query param (first enabled instance as fallback). +- [x] SSH/Files transport (`get_ssh_client`) unchanged — still uses + `machine_id`. +- [x] Updated service-registry tests for 6 types. + +### Selection model (decided) + +Split query params: `?jellyfin_service_id=` selects the Jellyfin/Jellyseerr +instance; `?machine_id=` selects SSH/Files transport. Pages that need both pass +both. + +### Remaining (frontend, next PR) + +- Thread `jellyfinServiceId` through Media / Applications / Dashboard / Users: + list `jellyfin` service instances instead of `useMonitoringSettings()` + Jellyfin machines; pass `jellyfin_service_id` to Jellyfin API calls. +- Files page keeps `machine_id`. +- Settings UI: remove machine-level Jellyfin/Jellyseerr fields. +- Remove machine app fields from `settings_store.py` + `routers/settings.py` + once the UI no longer writes them. + +### Verification (backend half) + +```bash +cd backend +.venv/bin/ruff check . # clean +PYTHONPATH=src .venv/bin/python -m pytest # 222 passed +cd ../frontend +npm run lint && npm run build && npm run test # green (unchanged) +``` From 5eb49be697738fa090671362f683498e19d8aad5 Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 23 Jun 2026 11:48:25 +0000 Subject: [PATCH 2/2] style(dependencies): apply formatter to dependencies rewrite --- backend/src/media_library_viewer_api/dependencies.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/backend/src/media_library_viewer_api/dependencies.py b/backend/src/media_library_viewer_api/dependencies.py index 7bfd263..9ccc256 100644 --- a/backend/src/media_library_viewer_api/dependencies.py +++ b/backend/src/media_library_viewer_api/dependencies.py @@ -178,15 +178,11 @@ def get_jellyfin_client(request: Request = None) -> JellyfinClient: service_id = _request_jellyfin_service_id(request) service = _service_record(store, "jellyfin", service_id) if service is None: - raise RuntimeError( - "No Jellyfin service is configured. Add a Jellyfin service on the Services page." - ) + raise RuntimeError("No Jellyfin service is configured. Add a Jellyfin service on the Services page.") base_url = str(service.get("config", {}).get("base_url") or "") api_key = str(service.get("secrets", {}).get("api_key") or "") if not base_url or not api_key: - raise RuntimeError( - "Jellyfin service is missing base_url or api_key. Edit it on the Services page." - ) + raise RuntimeError("Jellyfin service is missing base_url or api_key. Edit it on the Services page.") cache_key = (service["id"], base_url, api_key) return _jellyfin_client_for(cache_key)