Merge pull request 'feat(services): resolve Jellyfin/Jellyseerr from the service registry (backend)' (#11) from feat/service-registry-jellyfin-services-backend into main
This commit is contained in:
@@ -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,34 @@ 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 +288,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"]
|
||||
|
||||
@@ -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.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,
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user