fixes and improvements

This commit is contained in:
2026-05-06 21:25:12 +02:00
parent 5277f21577
commit b0d84399ab
12 changed files with 288 additions and 67 deletions
+3 -10
View File
@@ -72,10 +72,6 @@ SMTP_TIMEOUT=30
# Jellyfin, Jellyseerr, and SSH targets are now configured per machine in the app's Settings tab.
# The backend seeds a local machine automatically, so no global Jellyfin or SSH env vars are required.
#
# If you still want to keep a fallback SSH key available for legacy/manual use, provide a single key file path:
SSH_KEY_HOST_PATH=/absolute/path/to/id_ed25519
# The container-side secret path is fixed at /run/secrets/ssh_private_key.
# Authentik / OIDC
AUTH_ENABLED=true
OIDC_ISSUER_URL=https://authentik.example/application/o/media-library-viewer/
@@ -118,20 +114,17 @@ export VITE_OIDC_CLIENT_ID=manage
export VITE_OIDC_REDIRECT_URI=https://manage.example.com/
export VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/
# Optional: provide a single SSH private key file for remote machines configured later in Settings.
# Compose mounts it as a Docker secret, so you do not need to mount your whole ~/.ssh directory.
# If you prefer SSH agent forwarding, Paramiko will use SSH_AUTH_SOCK when you provide it.
export SSH_KEY_HOST_PATH=$HOME/.ssh/id_ed25519
docker compose up --build
```
2. After the API is running, open the app, go to **Settings**, and add machine entries:
- **Local**: monitors the API host itself without SSH.
- **SSH**: monitors another machine using a host, username, and a key file path stored in the container.
- **SSH**: monitors another machine using a host, username, and a private key pasted directly into the machine settings, with an optional passphrase.
3. Open **Monitoring** to see one section per configured machine. Each section uses its own collector state, disk path, metrics queries, and recent action history, which are populated automatically by the backend poller.
For local development, `docker compose -f docker-compose.dev.yml up --build` does not require an SSH key unless you configure remote SSH machines in the Settings tab.
## API Endpoints
- `GET /api/dashboard/counts` — Movie/series/episode totals
@@ -14,6 +14,7 @@ import logging
import posixpath
import shlex
from dataclasses import dataclass
from io import StringIO
from pathlib import Path
from typing import Any
@@ -41,6 +42,8 @@ class RemoteSSHClient:
username: str,
port: int = 22,
key_filename: str | None = None,
private_key: str | None = None,
private_key_passphrase: str | None = None,
password: str | None = None,
known_hosts_path: str | None = None,
timeout: int = 20,
@@ -51,6 +54,8 @@ class RemoteSSHClient:
self.username = username
self.port = port
self.key_filename = key_filename or None
self.private_key = private_key or None
self.private_key_passphrase = private_key_passphrase or None
self.password = password or None
self.known_hosts_path = known_hosts_path or None
self.timeout = timeout
@@ -70,17 +75,32 @@ class RemoteSSHClient:
if self.known_hosts_path and Path(self.known_hosts_path).is_file():
client.load_host_keys(self.known_hosts_path)
client.set_missing_host_key_policy(paramiko.RejectPolicy())
client.connect(
self.host,
port=self.port,
username=self.username,
key_filename=self.key_filename,
password=self.password,
timeout=self.timeout,
)
connect_kwargs: dict[str, Any] = {
"hostname": self.host,
"port": self.port,
"username": self.username,
"password": self.password,
"timeout": self.timeout,
}
if self.private_key:
connect_kwargs["pkey"] = self._load_private_key(self.private_key, self.private_key_passphrase)
else:
connect_kwargs["key_filename"] = self.key_filename
client.connect(**connect_kwargs)
self._client = client
return client
@staticmethod
def _load_private_key(private_key: str, passphrase: str | None = None) -> paramiko.PKey:
key_classes = [paramiko.Ed25519Key, paramiko.RSAKey, paramiko.ECDSAKey]
last_error: Exception | None = None
for key_class in key_classes:
try:
return key_class.from_private_key(StringIO(private_key), password=passphrase or None)
except Exception as exc: # pragma: no cover - try multiple algorithms
last_error = exc
raise RuntimeError("Unable to load SSH private key") from last_error
def close(self) -> None:
if self._client:
self._client.close()
@@ -57,22 +57,24 @@ def _jellyseerr_client_for(cache_key: tuple[str, str]) -> JellyseerrClient | Non
@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
def _ssh_client_for(cache_key: tuple[str, str, str, int, str, str | None, str | None, str | None]) -> RemoteSSHClient:
machine_id, host, username, port, key_filename, password, private_key, known_hosts_path = cache_key
logger.info(
"Creating SSH client machine_id=%s host=%s user=%s port=%s key=%s password=%s",
"Creating SSH client machine_id=%s host=%s user=%s port=%s key=%s password=%s private_key=%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",
)
client = RemoteSSHClient(
host=host,
username=username,
port=port,
key_filename=key_filename or None,
private_key=private_key or None,
password=password or None,
known_hosts_path=known_hosts_path or None,
)
@@ -137,17 +139,35 @@ def get_jellyseerr_client(request: Request = None) -> JellyseerrClient | None:
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"):
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 machine.get("host") and machine.get("username"):
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)
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')}",
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)
@@ -165,7 +185,7 @@ def get_ssh_client(request: Request = None) -> RemoteSSHClient:
if not settings.ssh_key_path:
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)))
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:
+2 -1
View File
@@ -13,7 +13,7 @@ from fastapi.middleware.cors import CORSMiddleware
from media_library_viewer_api.auth import require_jwt_auth, validate_auth_settings
from media_library_viewer_api.config import get_settings
from media_library_viewer_api.logging_utils import configure_logging, describe_settings
from media_library_viewer_api.routers import dashboard, monitoring, media, files, jobs, users
from media_library_viewer_api.routers import dashboard, monitoring, media, files, jobs, users, tasks
from media_library_viewer_api.routers.settings import router as settings_router
from media_library_viewer_api.dependencies import get_mail_queue, get_monitoring_poller, get_settings_store
from media_library_viewer_api.services.known_hosts import ensure_known_hosts_for_machines
@@ -98,6 +98,7 @@ app.include_router(media.router)
app.include_router(files.router)
app.include_router(jobs.router)
app.include_router(users.router)
app.include_router(tasks.router)
app.include_router(settings_router)