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:
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user