01527ae4f0
Combine both branches into a single coherent branch: - Full mobile responsive parity (useIsMobile, MobileCardRow, SheetForm, .mobile-touch-target, mobile cards, SheetForm forms, 44px targets, dirty-state confirm, TablePagination, refetchIntervalInBackground). - Full services-as-hub IA (data-driven nav, service-page tab skeleton, new service types, Authentik directory + messaging, named dashboards, legacy routes 404, Observability split, Jellyseerr absorbed). Enhancement: service tabs now use mobile-parity primitives: - MediaTab: MobileCardRow below md (title/size/HDR/library/year) + TablePagination; DataTable at md+ (desktop branch preserved). - FilesTab: MobileCardRow below md (name/type/size/modified) + handleRowClick; DataTable at md+. - ServicePage: SheetForm branch below md (open-on-mount, sticky header + save bar, cancel navigates back to /services, dirty-state guard). - Dashboard: single-column + section anchors below md (from mobile-parity) + empty-state CTA (from services-hub). - App.tsx: useIsMobile() replaces inline matchMedia (from mobile-parity) + data-driven useNavItems (from services-hub). - Backup tables (BackupAlerts/Jobs/Runs) already have MobileCardRow from mobile-parity; JobsTab inherits mobile behavior through its sub-components. Conflict resolutions: - Backend: entirely from services-hub (mobile didn't touch it). - Deleted pages (Media/FileBrowser/Actions/Users/UsersPage/Applications/ ObservabilityPage/BackupsPage + hooks/useUsers + tests): kept deleted (services-hub deleted them; content moved into service tabs). - New service-tabs/*: from services-hub, enhanced with mobile patterns. - App.tsx: services-hub's data-driven nav + mobile-parity's useIsMobile. - Dashboard.tsx: merged (services-hub CTA + mobile-parity sections/anchors). - ServicePage.tsx: services-hub's tab skeleton + mobile-parity's SheetForm. - Primitives (useIsMobile/mobile-card/sheet-form/etc.): from mobile-parity. 117 frontend tests pass (mobile-parity's 122 - 5 deleted page tests + services-hub's new tab/dashboard tests); 271 backend tests pass; lint/ build green both sides.
280 lines
11 KiB
Python
280 lines
11 KiB
Python
"""Dependency injection for FastAPI.
|
|
|
|
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
|
|
|
|
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.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
|
|
|
|
|
|
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
|
|
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 _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.
|
|
"""
|
|
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)
|
|
if service is None:
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="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 HTTPException(
|
|
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)
|
|
|
|
|
|
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 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),
|
|
)
|
|
)
|
|
|
|
|
|
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()
|
|
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 HTTPException(
|
|
status_code=503,
|
|
detail="No Jellyfin users found and no user_id configured on the service",
|
|
)
|
|
return users[0]["Id"]
|