feat(services): resolve Jellyfin/Jellyseerr from the service registry (backend)

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).
This commit is contained in:
Developer
2026-06-23 11:43:33 +00:00
parent d998e6ab0c
commit 8ff735d644
5 changed files with 155 additions and 45 deletions
@@ -1,9 +1,12 @@
"""Dependency injection for FastAPI. """Dependency injection for FastAPI.
Provides access to machine-specific Jellyfin/SSH clients via FastAPI's request Provides access to service-specific Jellyfin/Jellyseerr clients and
context. The selected machine can be chosen with a ``machine_id`` query machine-specific SSH clients via FastAPI's request context.
parameter; otherwise the backend falls back to the first enabled machine that
matches the requested service. - 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 from __future__ import annotations
@@ -34,6 +37,41 @@ def _request_machine_id(request: Request | None) -> str | None:
return machine_id or 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) @lru_cache(maxsize=32)
def _jellyfin_client_for(cache_key: tuple[str, str, str]) -> JellyfinClient: def _jellyfin_client_for(cache_key: tuple[str, str, str]) -> JellyfinClient:
machine_id, url, api_key = cache_key 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: 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() store = get_settings_store()
machine_id = _request_machine_id(request) service_id = _request_jellyfin_service_id(request)
machine = store.get_machine_config(machine_id) if machine_id else None service = _service_record(store, "jellyfin", service_id)
if machine is None: if service is None:
resolved = _resolve_machine("jellyfin", request) raise RuntimeError(
if resolved: "No Jellyfin service is configured. Add a Jellyfin service on the Services page."
machine = store.get_machine_config(resolved["id"]) )
if machine and machine.get("jellyfin_url") and machine.get("jellyfin_api_key"): base_url = str(service.get("config", {}).get("base_url") or "")
cache_key = (machine["id"], machine["jellyfin_url"], machine.get("jellyfin_api_key") or "") api_key = str(service.get("secrets", {}).get("api_key") or "")
return _jellyfin_client_for(cache_key) if not base_url or not api_key:
raise RuntimeError(
raise RuntimeError( "Jellyfin service is missing base_url or api_key. Edit it on the Services page."
"No Jellyfin machine is configured. Add a machine with jellyfin_url and jellyfin_api_key in Settings." )
) cache_key = (service["id"], base_url, api_key)
return _jellyfin_client_for(cache_key)
def get_jellyseerr_client(request: Request = None) -> JellyseerrClient | None: def get_jellyseerr_client(request: Request = None) -> JellyseerrClient | None:
"""Return a cached Jellyseerr client when configured, otherwise None.""" """Return a cached Jellyseerr client when configured, otherwise None."""
store = get_settings_store() store = get_settings_store()
machine_id = _request_machine_id(request) service_id = _request_jellyfin_service_id(request)
machine = store.get_machine_config(machine_id) if machine_id else None service = _service_record(store, "jellyseerr", service_id)
if machine is None: if service is None:
resolved = _resolve_machine("jellyseerr", request) logger.info("Jellyseerr client not configured (no jellyseerr service)")
if resolved: return None
machine = store.get_machine_config(resolved["id"]) base_url = str(service.get("config", {}).get("base_url") or "")
if machine and machine.get("jellyseerr_url") and machine.get("jellyseerr_api_key"): api_key = str(service.get("secrets", {}).get("api_key") or "")
return JellyseerrClient(machine["jellyseerr_url"], machine.get("jellyseerr_api_key") or "") if not base_url or not api_key:
logger.info("Jellyseerr service is missing base_url or api_key")
logger.info("Jellyseerr client not configured (no machine with jellyseerr_url and jellyseerr_api_key)") return None
return None return JellyseerrClient(base_url, api_key)
def _ssh_client_from_machine_config(machine: dict[str, Any], store: SettingsStore | None = None) -> RemoteSSHClient: 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: def get_user_id(request: Request = None) -> str:
"""Return the configured Jellyfin user ID or discover the first available one.""" """Return the configured Jellyfin user ID or discover the first available one."""
store = get_settings_store() store = get_settings_store()
machine_id = _request_machine_id(request) service_id = _request_jellyfin_service_id(request)
machine = store.get_machine_config(machine_id) if machine_id else None service = _service_record(store, "jellyfin", service_id)
if machine is None: if service and service.get("config", {}).get("user_id"):
resolved = _resolve_machine("jellyfin", request) return str(service["config"]["user_id"])
if resolved:
machine = store.get_machine_config(resolved["id"])
if machine and machine.get("jellyfin_user_id"):
return str(machine["jellyfin_user_id"])
client = get_jellyfin_client(request) client = get_jellyfin_client(request)
users = client.users() users = client.users()
if not 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"] return users[0]["Id"]
@@ -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=[],
)
@@ -9,6 +9,7 @@ from __future__ import annotations
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
@@ -17,6 +18,7 @@ SERVICE_DEFINITIONS: dict[str, ServiceDefinition] = {
GRAFANA.service_type: GRAFANA, GRAFANA.service_type: GRAFANA,
PROMETHEUS.service_type: PROMETHEUS, PROMETHEUS.service_type: PROMETHEUS,
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,
} }
+9 -1
View File
@@ -61,6 +61,7 @@ def test_registry_contains_five_service_types():
"grafana", "grafana",
"prometheus", "prometheus",
"jellyfin", "jellyfin",
"jellyseerr",
"nextcloud", "nextcloud",
"ssh_tasks", "ssh_tasks",
} }
@@ -133,7 +134,14 @@ def test_list_service_types(client):
response = client.get("/api/services/types") response = client.get("/api/services/types")
assert response.status_code == 200 assert response.status_code == 200
types = {item["service_type"] for item in response.json()} 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): def test_service_type_includes_secret_and_widget_metadata(client):
@@ -57,10 +57,43 @@ npm run build # success
npm run test # 70 passed npm run test # 70 passed
``` ```
## Remaining work ## Slice 4b — Jellyfin/Jellyseerr → services migration (in progress)
- Slice 4b: add `jellyseerr` service definition; rewire `dependencies.py` ### Completed (backend, this PR)
Jellyfin/Jellyseerr resolution to the service registry; migrate the
Media/Users/Files/Dashboard selector from machine_id to service instance; - [x] Added `jellyseerr` service definition (`integrations/jellyseerr.py`) and
remove machine-level Jellyfin/Jellyseerr fields from `settings_store.py`, registered it (6 service types total).
`routers/settings.py`, and the Settings UI. - [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)
```