fixes and improvements

This commit is contained in:
2026-05-06 16:33:02 +02:00
parent d8d80867ce
commit 5277f21577
20 changed files with 577 additions and 222 deletions
@@ -1,17 +1,23 @@
"""Dependency injection for FastAPI.
Provides singleton-like access to SSH and Jellyfin clients via FastAPI's
dependency system. Uses lru_cache so connections are reused across requests.
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.
"""
from __future__ import annotations
import logging
from functools import lru_cache
from typing import Any
from fastapi import Request
from media_library_viewer_api.clients.jellyfin import JellyfinClient
from media_library_viewer_api.clients.jellyseerr import JellyseerrClient
from media_library_viewer_api.clients.ssh import RemoteSSHClient
from media_library_viewer_api.services.known_hosts import ensure_known_host
from media_library_viewer_api.config import get_settings
from media_library_viewer_api.services.mail_queue import MailQueue, get_mail_queue as _get_mail_queue
from media_library_viewer_api.services.monitoring_poller import (
@@ -23,17 +29,100 @@ from media_library_viewer_api.services.settings_store import SettingsStore, get_
logger = logging.getLogger(__name__)
@lru_cache
def get_jellyfin_client() -> JellyfinClient:
"""Return a cached Jellyfin client."""
def _request_machine_id(request: Request | None) -> str | None:
if request is None:
return None
machine_id = request.query_params.get("machine_id")
return machine_id or None
@lru_cache(maxsize=32)
def _jellyfin_client_for(cache_key: tuple[str, str, str]) -> JellyfinClient:
machine_id, url, api_key = cache_key
logger.info("Creating Jellyfin client machine_id=%s url=%s", machine_id or "<default>", url.rstrip("/") or "<unset>")
return JellyfinClient(url, api_key)
@lru_cache(maxsize=32)
def _jellyseerr_client_for(cache_key: tuple[str, str]) -> JellyseerrClient | None:
machine_id, url = cache_key
if not url:
return None
settings = get_settings_store().get_machine_config(machine_id) if machine_id else None
api_key = (settings or {}).get("jellyseerr_api_key") if settings else ""
if not api_key:
return None
logger.info("Creating Jellyseerr client machine_id=%s url=%s", machine_id or "<default>", url.rstrip("/") or "<unset>")
return JellyseerrClient(url, api_key)
@lru_cache(maxsize=32)
def _ssh_client_for(cache_key: tuple[str, str, str, int, str, str | None, str | None]) -> RemoteSSHClient:
machine_id, host, username, port, key_filename, password, known_hosts_path = cache_key
logger.info(
"Creating SSH client machine_id=%s host=%s user=%s port=%s key=%s password=%s",
machine_id or "<default>",
host or "<unset>",
username or "<unset>",
port,
key_filename or "<unset>",
"set" if password else "missing",
)
client = RemoteSSHClient(
host=host,
username=username,
port=port,
key_filename=key_filename or None,
password=password or None,
known_hosts_path=known_hosts_path or None,
)
try:
client.connect()
except Exception:
logger.exception("Failed to establish SSH connection to %s", host or "<unset>")
raise
return client
def _resolve_machine(service: str, request: Request | None = None) -> dict[str, Any] | None:
store = get_settings_store()
machine_id = _request_machine_id(request)
if machine_id:
machine = store.get_machine(machine_id)
if machine and (service in machine.get("services", []) or service == "ssh"):
return machine
return machine
if service == "jellyfin":
machines = store.list_machines_for_service("jellyfin")
elif service == "jellyseerr":
machines = [m for m in store.list_machines_for_service("jellyfin") if m.get("jellyseerr_url")]
elif service == "ssh":
machines = store.list_machines_for_service("files") or store.list_machines_for_service("monitoring")
else:
machines = store.list_machines_for_service(service)
return machines[0] if machines else None
def get_jellyfin_client(request: Request = None) -> JellyfinClient:
"""Return a Jellyfin client for the selected machine or legacy env fallback."""
machine = get_settings_store().get_machine_config(_request_machine_id(request)) or _resolve_machine("jellyfin", request)
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)
settings = get_settings()
logger.info("Creating Jellyfin client for %s", settings.jellyfin_url.rstrip("/") or "<unset>")
return JellyfinClient(settings.jellyfin_url, settings.jellyfin_api_key)
if not settings.jellyfin_url or not settings.jellyfin_api_key:
raise RuntimeError("No Jellyfin machine is configured and JELLYFIN_URL/API_KEY are not set")
logger.info("Falling back to legacy Jellyfin env settings")
return _jellyfin_client_for(("legacy", settings.jellyfin_url, settings.jellyfin_api_key))
@lru_cache
def get_jellyseerr_client() -> JellyseerrClient | None:
def get_jellyseerr_client(request: Request = None) -> JellyseerrClient | None:
"""Return a cached Jellyseerr client when configured, otherwise None."""
machine = get_settings_store().get_machine_config(_request_machine_id(request)) or _resolve_machine("jellyseerr", request)
if machine and machine.get("jellyseerr_url") and machine.get("jellyseerr_api_key"):
return JellyseerrClient(machine["jellyseerr_url"], machine.get("jellyseerr_api_key") or "")
settings = get_settings()
if not settings.jellyseerr_url or not settings.jellyseerr_api_key:
logger.info(
@@ -42,16 +131,30 @@ def get_jellyseerr_client() -> JellyseerrClient | None:
"set" if settings.jellyseerr_api_key else "missing",
)
return None
logger.info("Creating Jellyseerr client for %s", settings.jellyseerr_url.rstrip("/") or "<unset>")
logger.info("Falling back to legacy Jellyseerr env settings")
return JellyseerrClient(settings.jellyseerr_url, settings.jellyseerr_api_key)
@lru_cache
def get_ssh_client() -> RemoteSSHClient:
"""Return a cached SSH client (connects on first use)."""
def get_ssh_client(request: Request = None) -> RemoteSSHClient:
"""Return a cached SSH client for the selected machine or legacy env fallback."""
machine = get_settings_store().get_machine_config(_request_machine_id(request)) or _resolve_machine("ssh", request)
if machine and machine.get("host") and machine.get("username") and machine.get("key_directory") and machine.get("key_name"):
known_hosts_path = get_settings().ssh_known_hosts_file
ensure_known_host(str(machine.get("host")), int(machine.get("port") or 22), known_hosts_path)
cache_key = (
machine["id"],
machine["host"],
machine["username"],
int(machine.get("port") or 22),
f"{machine.get('key_directory')}/{machine.get('key_name')}",
machine.get("password") or None,
str(known_hosts_path),
)
return _ssh_client_for(cache_key)
settings = get_settings()
logger.info(
"Creating SSH client host=%s user=%s port=%s key_dir=%s key_name=%s password=%s",
"Creating SSH client from legacy env host=%s user=%s port=%s key_dir=%s key_name=%s password=%s",
settings.ssh_host or "<unset>",
settings.ssh_username or "<unset>",
settings.ssh_port,
@@ -60,20 +163,9 @@ def get_ssh_client() -> RemoteSSHClient:
"set" if settings.ssh_password else "missing",
)
if not settings.ssh_key_path:
raise RuntimeError("SSH_KEY_DIRECTORY and SSH_KEY_NAME must be configured")
client = RemoteSSHClient(
host=settings.ssh_host,
username=settings.ssh_username,
port=settings.ssh_port,
key_filename=settings.ssh_key_path,
password=settings.ssh_password or None,
)
try:
client.connect()
except Exception:
logger.exception("Failed to establish SSH connection to %s", settings.ssh_host or "<unset>")
raise
return client
raise RuntimeError("No SSH machine is configured and SSH key settings must be configured")
ensure_known_host(settings.ssh_host, settings.ssh_port, settings.ssh_known_hosts_file)
return _ssh_client_for(("legacy", settings.ssh_host, settings.ssh_username, settings.ssh_port, settings.ssh_key_path, settings.ssh_password or None, str(settings.ssh_known_hosts_file)))
def get_mail_queue() -> MailQueue:
@@ -91,13 +183,16 @@ def get_settings_store() -> SettingsStore:
return _get_settings_store()
def get_user_id() -> str:
"""Return the configured Jellyfin user ID, or discover the first available user."""
def get_user_id(request: Request = None) -> str:
"""Return the configured Jellyfin user ID or discover the first available one."""
machine = get_settings_store().get_machine_config(_request_machine_id(request)) or _resolve_machine("jellyfin", request)
if machine and machine.get("jellyfin_user_id"):
return str(machine["jellyfin_user_id"])
settings = get_settings()
if settings.jellyfin_user_id:
return settings.jellyfin_user_id
client = get_jellyfin_client()
client = get_jellyfin_client(request)
users = client.users()
if not users:
raise RuntimeError("No Jellyfin users found and JELLYFIN_USER_ID not set")
raise RuntimeError("No Jellyfin users found and no machine/user id configured")
return users[0]["Id"]