"""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 "", url.rstrip("/") or "" ) 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 "", host or "", username or "", port, key_filename or "", "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 "") 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 "") 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 "", settings.ssh_username or "", settings.ssh_port, settings.ssh_key_directory or "", settings.ssh_key_name or "", "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"]