08a3b616f6
The 2026-06-16/17 observability update externalised metrics to
Prometheus + node_exporter + Grafana, but the legacy Manage-side
SSH-scraping monitor was never removed. It duplicated the new stack,
ran SSH df on every machine every 300s, and fed nothing (its UI was
deleted in e2ad731). This slice decommissions the duplication.
Removed (backend):
- services/monitoring_poller.py (MonitoringPoller) — entire file
- services/monitoring_actions.py (disk_space, run_machine_operation,
poll_machine_snapshot, build_machine_client) — entire file;
run_machine_operation had only 2 callers (the poller + /disk), both gone
- tests/test_monitoring_actions.py
- endpoints: POST /api/monitoring/poller, GET /machines/{id}/actions,
GET /disk (and the now-dead _resolve_machine helper)
- lifespan wiring (main.py), dependency wrapper (dependencies.py),
poller.start()/kick() from machine save (routers/settings.py)
- SettingsStore: monitoring_machine_actions table CREATE + 2 indexes +
record/list/prune_machine_actions methods; DROP TABLE IF EXISTS on
startup cleans existing DBs (user-approved)
- config knobs: monitoring_poll_interval_seconds,
monitoring_poll_initial_delay_seconds, monitoring_action_retention_days
- test_api.py: TestMonitoring._ensure_machine + test_disk
Kept (fits the new model): /machines, /prometheus-targets, /alerts,
/alertmanager-status, /alertmanager-webhook; the disk_usage JOB template
(manual on-demand, not monitoring); node_exporter_* machine fields
(they point Prometheus at the right host).
Gate: backend pytest 173 passed; ruff clean.
269 lines
11 KiB
Python
269 lines
11 KiB
Python
"""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.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from functools import lru_cache
|
|
from typing import Any
|
|
|
|
from fastapi import HTTPException, 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.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
|
|
from media_library_viewer_api.services.settings_store import get_settings_store as _get_settings_store
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
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, 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:
|
|
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."""
|
|
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."
|
|
)
|
|
|
|
|
|
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
|
|
|
|
|
|
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 = 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 RuntimeError("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),
|
|
)
|
|
)
|
|
|
|
|
|
def get_mail_queue() -> MailQueue:
|
|
"""Return the singleton background email queue."""
|
|
return _get_mail_queue()
|
|
|
|
|
|
def get_settings_store() -> SettingsStore:
|
|
"""Return the singleton persistent settings store."""
|
|
return _get_settings_store()
|
|
|
|
|
|
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"])
|
|
client = get_jellyfin_client(request)
|
|
users = client.users()
|
|
if not users:
|
|
raise RuntimeError("No Jellyfin users found and no machine/user id configured")
|
|
return users[0]["Id"]
|