refactor: unify SSH machines as services

This commit is contained in:
Developer
2026-07-14 20:58:46 +00:00
parent fe90feb1b7
commit 37533dd219
42 changed files with 3103 additions and 4960 deletions
@@ -1,12 +1,12 @@
"""Dependency injection for FastAPI.
Provides access to service-specific Jellyfin/Jellyseerr clients and
machine-specific SSH clients via FastAPI's request context.
remote-machine 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.
- SSH/Files transport is selected with an enabled ``remote_machine`` ``service_id``.
"""
from __future__ import annotations
@@ -18,9 +18,7 @@ from typing import Any
from fastapi import HTTPException, Request
from media_library_viewer_api.clients.jellyfin import JellyfinClient
from media_library_viewer_api.clients.local import LocalCommandClient
from media_library_viewer_api.clients.ssh import RemoteSSHClient
from media_library_viewer_api.config import get_settings
from media_library_viewer_api.services.mail_queue import MailQueue
from media_library_viewer_api.services.mail_queue import get_mail_queue as _get_mail_queue
from media_library_viewer_api.services.settings_store import SettingsStore
@@ -29,11 +27,11 @@ from media_library_viewer_api.services.settings_store import get_settings_store
logger = logging.getLogger(__name__)
def _request_machine_id(request: Request | None) -> str | None:
def _request_remote_machine_service_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
service_id = request.query_params.get("service_id")
return service_id or None
def _request_jellyfin_service_id(request: Request | None) -> str | None:
@@ -80,87 +78,10 @@ def _jellyfin_client_for(cache_key: tuple[str, str, str]) -> JellyfinClient:
return JellyfinClient(url, api_key)
@lru_cache(maxsize=32)
def _ssh_client_for(
cache_key: tuple[str, str, str, int, str, str | None, str | None, str | None, str | None],
) -> RemoteSSHClient:
machine_id, host, username, port, key_filename, password, private_key, private_key_passphrase, known_hosts_path = (
cache_key
)
logger.info(
"Creating SSH client machine_id=%s host=%s user=%s port=%s key=%s password=%s private_key=%s passphrase=%s",
machine_id or "<default>",
host or "<unset>",
username or "<unset>",
port,
key_filename or "<unset>",
"set" if password else "missing",
"set" if private_key else "missing",
"set" if private_key_passphrase else "missing",
)
client = RemoteSSHClient(
host=host,
username=username,
port=port,
key_filename=key_filename or None,
private_key=private_key or None,
private_key_passphrase=private_key_passphrase or None,
password=password or None,
known_hosts_path=known_hosts_path or None,
)
try:
client.connect()
except RuntimeError as exc:
message = str(exc)
lowered = message.lower()
logger.exception("Failed to establish SSH connection to %s", host or "<unset>")
if "banner" in lowered:
raise HTTPException(
status_code=502,
detail=(
f"SSH banner not received from {host}:{port}. "
"Confirm the host, port, and firewall; the backend could not complete the SSH handshake."
),
) from exc
if "authentication failed" in lowered or "no authentication methods available" in lowered:
raise HTTPException(
status_code=401,
detail=(
f"SSH authentication failed for {host}:{port}. "
"Check the selected key, passphrase, username, or password."
),
) from exc
raise HTTPException(status_code=502, detail=message) from exc
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:
"""Resolve an SSH/Files machine for the given transport service.
Jellyfin/Jellyseerr are resolved against the service registry, not here.
"""
def get_jellyfin_client(request: Request) -> JellyfinClient:
"""Return a Jellyfin client for the selected enabled service instance."""
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 == "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 Jellyfin service instance."""
store = get_settings_store()
service_id = _request_jellyfin_service_id(request)
service = _service_record(store, "jellyfin", service_id)
service = _service_record(store, "jellyfin", _request_jellyfin_service_id(request))
if service is None:
raise HTTPException(
status_code=503,
@@ -173,83 +94,25 @@ def get_jellyfin_client(request: Request = None) -> JellyfinClient:
status_code=503,
detail="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)
return _jellyfin_client_for((service["id"], base_url, api_key))
def _ssh_client_from_machine_config(machine: dict[str, Any], store: SettingsStore | None = None) -> RemoteSSHClient:
"""Build a RemoteSSHClient from a machine config dict."""
store = store or get_settings_store()
known_hosts_path = get_settings().ssh_known_hosts_file
key_data = None
key_passphrase = None
ssh_key_id = str(machine.get("ssh_key_id") or "").strip()
if ssh_key_id:
ssh_key = store.get_ssh_key(ssh_key_id)
if ssh_key:
key_data = ssh_key.get("private_key") or None
key_passphrase = ssh_key.get("passphrase") or None
if not key_data and machine.get("ssh_private_key"):
key_data = machine.get("ssh_private_key") or None
key_passphrase = machine.get("ssh_private_key_passphrase") or None
cache_key = (
machine["id"],
machine["host"],
machine["username"],
int(machine.get("port") or 22),
f"{machine.get('key_directory')}/{machine.get('key_name')}"
if machine.get("key_directory") and machine.get("key_name")
else "",
machine.get("password") or None,
key_data,
key_passphrase,
str(known_hosts_path),
)
return _ssh_client_for(cache_key)
def get_ssh_client(request: Request) -> RemoteSSHClient:
"""Return SSH transport for the requested enabled remote-machine service."""
from media_library_viewer_api.services.task_runner import build_ssh_client
from media_library_viewer_api.widgets.sources import build_service_record
def get_ssh_client(request: Request = None):
"""Return a command client for the selected machine or legacy env fallback."""
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:
machine_ref = _resolve_machine("ssh", request)
machine = store.get_machine_config(machine_ref["id"]) if machine_ref else None
if machine and str(machine.get("mode") or "local").strip().lower() == "local":
logger.info("Creating LocalCommandClient machine_id=%s", machine["id"])
return LocalCommandClient()
if machine and machine.get("host") and machine.get("username"):
return _ssh_client_from_machine_config(machine, store)
settings = get_settings()
logger.info(
"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,
settings.ssh_key_directory or "<unset>",
settings.ssh_key_name or "<unset>",
"set" if settings.ssh_password else "missing",
)
if not settings.ssh_key_path:
raise HTTPException(
status_code=503,
detail="No SSH machine is configured and SSH key settings must be configured",
)
return _ssh_client_for(
(
"legacy",
settings.ssh_host,
settings.ssh_username,
settings.ssh_port,
settings.ssh_key_path,
settings.ssh_password or None,
None,
None,
str(settings.ssh_known_hosts_file),
)
)
service_id = _request_remote_machine_service_id(request)
if not service_id:
raise HTTPException(status_code=400, detail="service_id is required for remote file and job operations")
row = store.get_service(service_id)
if not row or row.get("service_type") != "remote_machine" or not row.get("enabled", True):
raise HTTPException(status_code=404, detail="Enabled remote machine service not found")
try:
return build_ssh_client(store, build_service_record(store, row))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
def get_mail_queue() -> MailQueue:
@@ -262,7 +125,7 @@ def get_settings_store() -> SettingsStore:
return _get_settings_store()
def get_user_id(request: Request = None) -> str:
def get_user_id(request: Request) -> str:
"""Return the Jellyfin user Id, resolving a configured username if needed.
The service ``user_id`` config field accepts either the internal Jellyfin Id