Apply the Cin-1 / Cin-2 pattern from the download refactor PR to the media server engine PR before review. Cin-1 — explicit inheritance: - PlexClient, JellyfinClient, NavidromeClient, SoulSyncClient now explicitly inherit MediaServerClient instead of relying on structural typing alone. Pre-change a reader of plex_client.py had no way to know the class was supposed to satisfy the contract. - Removed the engine + registry re-exports from core/media_server/__init__.py to break the circular import that the inheritance change introduced (importing the package now triggered a chain that loaded clients before their base class resolved). Submodules import directly: from core.media_server.engine import MediaServerEngine, etc. - Conformance test now also asserts isinstance() / issubclass() against MediaServerClient — drift in any class fails at the test boundary instead of at runtime. Cin-2 — generic accessors + singleton: - engine.configured_clients() — replaces the legacy per-server `if X and X.is_connected(): clients[name] = X` chains in web_server.py. - engine.reload_config(name=None) — generic dispatch, so callers pass the server name instead of reaching for plex_client.reload_config() directly. - get_media_server_engine() / set_media_server_engine() singleton factory matching the get_metadata_engine() / get_download_orchestrator() shape. web_server.py boots via set_media_server_engine(...) so factory + global handle share state. - 7 new tests pin the accessors + singleton behaviour.
306 lines
13 KiB
Python
306 lines
13 KiB
Python
"""MediaServerEngine — central dispatch for media server operations.
|
|
|
|
Replaces the historic 33+ ``if active_server == 'plex' / 'jellyfin' /
|
|
'navidrome' / 'soulsync'`` chains in ``web_server.py``. Each
|
|
operation web_server.py used to dispatch by hand becomes a single
|
|
``engine.method()`` call here that:
|
|
|
|
1. Reads the ``server.active`` config to find the current target.
|
|
2. Looks up the registered client.
|
|
3. Calls the corresponding method (with safe per-server fallbacks
|
|
for methods that don't exist on every client — e.g. SoulSync
|
|
has no library-scan API).
|
|
|
|
Per-server client objects stay accessible via ``engine.client(name)``
|
|
so any caller that needs a Plex-specific method (e.g.
|
|
``set_music_library_by_name`` for the settings page) keeps working
|
|
through ``engine.client('plex').set_music_library_by_name(...)``.
|
|
|
|
Engine itself is constructed once during web_server.py init and
|
|
held as a module-level singleton, mirroring the existing pattern
|
|
for the per-server client globals.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Dict, List, 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:
|
|
"""Single entry point for cross-server library operations.
|
|
|
|
The engine knows which server is "active" via the
|
|
``server.active`` config + falls back to direct dispatch for
|
|
server-specific calls via ``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, just stash them in the registry's slots so
|
|
# registry.get(name) works.
|
|
for name, client in clients.items():
|
|
self.registry._instances[name] = client
|
|
# Mark any registered-but-not-supplied as failed init so
|
|
# active_client() returns None for them.
|
|
for name in self.registry._specs:
|
|
if name not in self.registry._instances:
|
|
self.registry._instances[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
|
|
|
|
# ------------------------------------------------------------------
|
|
# Direct client access (backward-compat for source-specific reaches)
|
|
# ------------------------------------------------------------------
|
|
|
|
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)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Cross-server dispatch — required methods (always present)
|
|
# ------------------------------------------------------------------
|
|
|
|
def is_connected(self) -> bool:
|
|
"""Active server's connection state. False if no active
|
|
client (registered but failed to initialize)."""
|
|
client = self.active_client()
|
|
if client is None:
|
|
return False
|
|
try:
|
|
return client.is_connected()
|
|
except Exception as exc:
|
|
logger.debug("%s is_connected raised: %s", self.active_server, exc)
|
|
return False
|
|
|
|
def ensure_connection(self) -> bool:
|
|
"""Re-auth or reconnect the active server. Returns True if
|
|
usable after the call."""
|
|
client = self.active_client()
|
|
if client is None:
|
|
return False
|
|
try:
|
|
return client.ensure_connection()
|
|
except Exception as exc:
|
|
logger.debug("%s ensure_connection raised: %s", self.active_server, exc)
|
|
return False
|
|
|
|
def get_all_artists(self) -> List[Any]:
|
|
"""Active server's full artist list. Empty list if not
|
|
connected or call fails."""
|
|
client = self.active_client()
|
|
if client is None:
|
|
return []
|
|
try:
|
|
return client.get_all_artists()
|
|
except Exception as exc:
|
|
logger.debug("%s get_all_artists raised: %s", self.active_server, exc)
|
|
return []
|
|
|
|
def get_all_album_ids(self) -> set:
|
|
"""Active server's album-ID set. Empty set if not connected
|
|
or call fails."""
|
|
client = self.active_client()
|
|
if client is None:
|
|
return set()
|
|
try:
|
|
return client.get_all_album_ids()
|
|
except Exception as exc:
|
|
logger.debug("%s get_all_album_ids raised: %s", self.active_server, exc)
|
|
return set()
|
|
|
|
# ------------------------------------------------------------------
|
|
# Optional methods — engine routes if the client implements them,
|
|
# returns a safe default otherwise (mirrors the legacy web_server.py
|
|
# branches that special-cased SoulSync / Navidrome).
|
|
# ------------------------------------------------------------------
|
|
|
|
def search_tracks(self, title: str, artist: str, limit: int = 15) -> List[Any]:
|
|
"""Search the active server's library. Returns empty list
|
|
for servers that don't implement search_tracks (SoulSync
|
|
standalone reads filesystem; no live search API)."""
|
|
client = self.active_client()
|
|
if client is None or not hasattr(client, 'search_tracks'):
|
|
return []
|
|
try:
|
|
return client.search_tracks(title, artist, limit)
|
|
except Exception as exc:
|
|
logger.debug("%s search_tracks raised: %s", self.active_server, exc)
|
|
return []
|
|
|
|
def trigger_library_scan(self) -> bool:
|
|
"""Trigger a server-side library scan. No-op (returns True)
|
|
for SoulSync standalone — filesystem walks happen in-process."""
|
|
client = self.active_client()
|
|
if client is None:
|
|
return False
|
|
if not hasattr(client, 'trigger_library_scan'):
|
|
return True
|
|
try:
|
|
return client.trigger_library_scan()
|
|
except Exception as exc:
|
|
logger.debug("%s trigger_library_scan raised: %s", self.active_server, exc)
|
|
return False
|
|
|
|
def is_library_scanning(self) -> bool:
|
|
"""True if the active server is currently scanning. Always
|
|
False for SoulSync standalone."""
|
|
client = self.active_client()
|
|
if client is None or not hasattr(client, 'is_library_scanning'):
|
|
return False
|
|
try:
|
|
return client.is_library_scanning()
|
|
except Exception as exc:
|
|
logger.debug("%s is_library_scanning raised: %s", self.active_server, exc)
|
|
return False
|
|
|
|
def get_library_stats(self) -> Dict[str, int]:
|
|
"""Counts of artists / albums / tracks. Default empty dict
|
|
if the server doesn't implement (SoulSync standalone)."""
|
|
client = self.active_client()
|
|
if client is None or not hasattr(client, 'get_library_stats'):
|
|
return {}
|
|
try:
|
|
return client.get_library_stats()
|
|
except Exception as exc:
|
|
logger.debug("%s get_library_stats raised: %s", self.active_server, exc)
|
|
return {}
|
|
|
|
def get_recently_added_albums(self, max_results: int = 400) -> List[Any]:
|
|
"""Recently-added albums view. Plex uses a different name;
|
|
engine routes to whichever method the active server has."""
|
|
client = self.active_client()
|
|
if client is None:
|
|
return []
|
|
# Plex uses recentlyAdded() on the music library object, not
|
|
# a top-level method. SoulSync, Jellyfin, Navidrome all
|
|
# expose get_recently_added_albums directly.
|
|
if hasattr(client, 'get_recently_added_albums'):
|
|
try:
|
|
return client.get_recently_added_albums(max_results)
|
|
except Exception as exc:
|
|
logger.debug(
|
|
"%s get_recently_added_albums raised: %s",
|
|
self.active_server, exc,
|
|
)
|
|
return []
|
|
return []
|
|
|
|
# ------------------------------------------------------------------
|
|
# Generic accessors — replace per-server attribute reaches in
|
|
# callers (Cin's standard from the download refactor).
|
|
# ------------------------------------------------------------------
|
|
|
|
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."""
|
|
result: Dict[str, MediaServerClient] = {}
|
|
for name, client in self.registry.all_clients():
|
|
try:
|
|
if not hasattr(client, 'is_connected') or client.is_connected():
|
|
result[name] = client
|
|
except Exception as exc:
|
|
logger.debug("%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
|