"""MediaServerEngine — central registry-backed access to media server clients. Honest scope: the engine OWNS the per-server client instances and exposes a small set of generic accessors so callers don't need per-server attribute reaches. Most actual cross-server dispatch in web_server.py (playlist add / remove / replace, per-server metadata sync, deep scan with server-specific cache strategies) is genuinely different per server and stays explicit in the call site — the engine just provides the canonical client lookup so those sites reach via ``engine.client(name)`` instead of separate globals. Surface: - ``client(name)`` / ``active_client()`` — name → client lookup - ``active_server`` — config-driven active server name - ``is_connected()`` — only cross-server dispatch with real callers today (dashboard status indicators); kept as the canonical example - ``configured_clients()`` — replaces the legacy per-server ``if X and X.is_connected()`` chains in web_server.py - ``reload_config(name=None)`` — generic dispatch instead of per-client reload calls Per-method engine wrappers for ``get_all_artists`` / ``search_tracks`` / ``trigger_library_scan`` / etc. were on an earlier draft but had no production callers — every consumer reaches the active client directly through ``sync_service._get_active_media_client()`` or ``engine.client(name)`` and calls the per-server method itself. Cut per the "no premature abstraction" standard. Engine is constructed once during web_server.py init and held as a process-wide singleton via ``set_media_server_engine`` / ``get_media_server_engine``, mirroring the metadata + download engine factory shape. """ from __future__ import annotations from typing import Any, Dict, Optional from utils.logging_config import get_logger from core.media_server.contract import MediaServerClient from core.media_server.registry import MediaServerRegistry, build_default_registry logger = get_logger("media_server.engine") class MediaServerEngine: """Registry-backed access to the per-server media clients. Owns the per-server client instances + a small set of generic accessors (``client(name)`` / ``active_client()`` / ``configured_clients()`` / ``reload_config(name)``) so call sites don't reach for separate per-server globals. The one cross-server dispatch wrapper kept on the engine — ``is_connected()`` — backs the dashboard status indicators that have multiple call sites; everything else dispatches per-server in the call site itself, reaching the relevant client through ``engine.client(name)``. """ def __init__( self, registry: Optional[MediaServerRegistry] = None, active_server_resolver=None, clients: Optional[Dict[str, Any]] = None, ) -> None: """Initialize the engine. Args: registry: Plugin registry. Defaults to the four built-in servers (Plex, Jellyfin, Navidrome, SoulSync). active_server_resolver: Callable returning the current active server name (e.g. ``'plex'``). Defaults to ``config_manager.get_active_media_server``. Tests inject a custom resolver to switch active server without touching real config. clients: Pre-built {name: client_instance} dict. When provided, the engine wraps these instances directly instead of asking the registry to construct fresh ones. web_server.py uses this so the engine shares the same client objects as the pre-existing global variables (no double-init). """ self.registry = registry if registry is not None else build_default_registry() if clients is not None: # Wrap pre-built instances (production case from web_server.py # init). Skip registry.initialize() — we already have the # instances, hand them off via the registry's public # set_instance(name, client) method so internal storage stays # encapsulated. for name, client in clients.items(): self.registry.set_instance(name, client) # Mark any registered-but-not-supplied as failed init so # active_client() returns None for them. for name in self.registry.names(): if self.registry.get(name) is None and name not in clients: self.registry.set_instance(name, None) else: self.registry.initialize() if active_server_resolver is None: from config.settings import config_manager active_server_resolver = config_manager.get_active_media_server self._resolve_active = active_server_resolver # ------------------------------------------------------------------ # Client lookup — generic accessors that replace per-server # attribute reaches in callers. # ------------------------------------------------------------------ def client(self, name: str) -> Optional[MediaServerClient]: """Return the client instance for the given server name, or None if it's not registered / failed to initialize. Used by callers that need a server-specific method beyond the contract surface.""" return self.registry.get(name) @property def active_server(self) -> str: """The currently-selected media server name.""" return self._resolve_active() def active_client(self) -> Optional[MediaServerClient]: """The client for the currently-active server.""" return self.registry.get(self.active_server) def is_connected(self) -> bool: """Active server's connection state. False if no active client (registered but failed to initialize). The dashboard status indicators + endpoint guards rely on this — the only cross-server dispatch wrapper kept on the engine because it actually has callers.""" client = self.active_client() if client is None: return False try: return client.is_connected() except Exception as exc: logger.warning("%s is_connected raised: %s", self.active_server, exc) return False def configured_clients(self) -> Dict[str, MediaServerClient]: """Return ``{name: client}`` for every server that's both registered AND reports ``is_connected() == True``. Replaces the legacy per-server `if X and X.is_connected(): ...` chains in web_server.py. ``is_connected`` is in REQUIRED_METHODS, so every client the registry yields here implements it — no hasattr guard needed. """ result: Dict[str, MediaServerClient] = {} for name, client in self.registry.all_clients(): try: if client.is_connected(): result[name] = client except Exception as exc: logger.warning("%s is_connected raised in configured_clients: %s", name, exc) return result def reload_config(self, name: Optional[str] = None) -> bool: """Reload config on a single server (or every server when ``name`` is None). Generic dispatch — caller passes the name instead of reaching for ``plex_client.reload_config()`` / ``jellyfin_client.reload_config()`` directly. Servers without a ``reload_config`` method are silently skipped. """ names = [name] if name else list(self.registry.names()) ok = True for n in names: client = self.client(n) if client is None or not hasattr(client, 'reload_config'): continue try: client.reload_config() except Exception as exc: logger.warning("%s reload_config failed: %s", n, exc) ok = False return ok # --------------------------------------------------------------------------- # Singleton accessor — mirrors the get_metadata_engine() / # get_download_orchestrator() pattern so callers that don't need a # custom registry use this instead of instantiating MediaServerEngine # directly. web_server.py constructs the singleton at startup and # installs it via ``set_media_server_engine`` so the factory + the # global handle share state. # --------------------------------------------------------------------------- _default_engine: Optional['MediaServerEngine'] = None def get_media_server_engine() -> 'MediaServerEngine': """Return (lazily creating) the process-wide MediaServerEngine singleton. Mirrors the ``get_metadata_engine()`` / ``get_download_orchestrator()`` shape.""" global _default_engine if _default_engine is None: _default_engine = MediaServerEngine() return _default_engine def set_media_server_engine(engine: Optional['MediaServerEngine']) -> None: """Set the process-wide singleton. Used by web_server.py at boot to install the engine it constructs (with the pre-built per-client instances) as the default for callers reaching via ``get_media_server_engine()``.""" global _default_engine _default_engine = engine