MS pre-review polish — encapsulation + visibility + tests

Five tightening passes anticipating Cin / JohnBaumb's review nits:

(1) Engine no longer reaches into ``registry._instances`` private
attr. New public ``MediaServerRegistry.set_instance(name, client)``
method — engine constructor calls it for the ``clients=`` pre-built
case so internal storage stays encapsulated.

(2) Engine module docstring no longer overclaims. Originally said it
"Replaces the historic 33+ if/elif chains" — but only the four
uniform-shape ``is_connected`` chains were collapsed. The 19 chains
that do server-specific work (Plex raw API vs Jellyfin / Navidrome
client methods returning different shapes) stay explicit per the
"lift what's truly shared" standard. Docstring rewritten to say
exactly that.

(3) Per-method exception swallows upgraded from ``logger.debug`` to
``logger.warning``. Returning safe defaults stays the right behavior
for a read-side engine (Plex offline shouldn't crash the app), but
silent debug-level swallowing made debugging hard — JohnBaumb pushed
the download engine to surface real errors. Same treatment here:
default still safe, but the warning tells you Plex is down.

(4) ``_safe_init_media_client`` in web_server.py now logs the
exception type + traceback. Broad ``except Exception`` is still
intentional (any failure means that one server can't be used; the
others stay up) but the boot log now distinguishes config errors
(ConnectionError, AuthenticationError) from import / dependency
failures.

(5) Two new tests pin the encapsulation + fallback contracts:
- ``test_engine_with_empty_clients_dict_is_safe_to_use`` — empty
  engine returns safe defaults on every method, doesn't raise.
- ``test_engine_uses_registry_set_instance_not_private_attr`` — spy
  on registry.set_instance verifies engine uses the public method.
This commit is contained in:
Broque Thomas 2026-05-05 21:04:14 -07:00
parent f230c93890
commit 860f9a0a8c
4 changed files with 115 additions and 26 deletions

View file

@ -1,9 +1,10 @@
"""MediaServerEngine — central dispatch for media server operations. """MediaServerEngine — central dispatch for media server operations.
Replaces the historic 33+ ``if active_server == 'plex' / 'jellyfin' / Replaces the *uniform-shape* dispatch chains in web_server.py
'navidrome' / 'soulsync'`` chains in ``web_server.py``. Each (``is_connected``, ``get_all_artists``, etc. anything where every
operation web_server.py used to dispatch by hand becomes a single server returns the same shape and the only branching was on
``engine.method()`` call here that: ``active_server == X``). Each such operation is now one
``engine.method()`` call that:
1. Reads the ``server.active`` config to find the current target. 1. Reads the ``server.active`` config to find the current target.
2. Looks up the registered client. 2. Looks up the registered client.
@ -11,14 +12,17 @@ operation web_server.py used to dispatch by hand becomes a single
for methods that don't exist on every client — e.g. SoulSync for methods that don't exist on every client — e.g. SoulSync
has no library-scan API). has no library-scan API).
Per-server client objects stay accessible via ``engine.client(name)`` Server-specific dispatch sites (Plex's raw playlist API, Jellyfin /
so any caller that needs a Plex-specific method (e.g. Navidrome client methods returning different shapes) stay explicit
``set_music_library_by_name`` for the settings page) keeps working in web_server.py per the "lift what's truly shared" standard. They
through ``engine.client('plex').set_music_library_by_name(...)``. reach individual clients via ``engine.client(name)`` rather than
the per-server globals same generic-accessor pattern as the
download orchestrator.
Engine itself is constructed once during web_server.py init and Engine itself is constructed once during web_server.py init and
held as a module-level singleton, mirroring the existing pattern held as a process-wide singleton via
for the per-server client globals. ``set_media_server_engine`` / ``get_media_server_engine``, mirroring
the metadata + download engine factory shape.
""" """
from __future__ import annotations from __future__ import annotations
@ -69,15 +73,16 @@ class MediaServerEngine:
if clients is not None: if clients is not None:
# Wrap pre-built instances (production case from web_server.py # Wrap pre-built instances (production case from web_server.py
# init). Skip registry.initialize() — we already have the # init). Skip registry.initialize() — we already have the
# instances, just stash them in the registry's slots so # instances, hand them off via the registry's public
# registry.get(name) works. # set_instance(name, client) method so internal storage stays
# encapsulated.
for name, client in clients.items(): for name, client in clients.items():
self.registry._instances[name] = client self.registry.set_instance(name, client)
# Mark any registered-but-not-supplied as failed init so # Mark any registered-but-not-supplied as failed init so
# active_client() returns None for them. # active_client() returns None for them.
for name in self.registry._specs: for name in self.registry.names():
if name not in self.registry._instances: if self.registry.get(name) is None and name not in clients:
self.registry._instances[name] = None self.registry.set_instance(name, None)
else: else:
self.registry.initialize() self.registry.initialize()
@ -119,7 +124,7 @@ class MediaServerEngine:
try: try:
return client.is_connected() return client.is_connected()
except Exception as exc: except Exception as exc:
logger.debug("%s is_connected raised: %s", self.active_server, exc) logger.warning("%s is_connected raised: %s", self.active_server, exc)
return False return False
def ensure_connection(self) -> bool: def ensure_connection(self) -> bool:
@ -131,7 +136,7 @@ class MediaServerEngine:
try: try:
return client.ensure_connection() return client.ensure_connection()
except Exception as exc: except Exception as exc:
logger.debug("%s ensure_connection raised: %s", self.active_server, exc) logger.warning("%s ensure_connection raised: %s", self.active_server, exc)
return False return False
def get_all_artists(self) -> List[Any]: def get_all_artists(self) -> List[Any]:
@ -143,7 +148,7 @@ class MediaServerEngine:
try: try:
return client.get_all_artists() return client.get_all_artists()
except Exception as exc: except Exception as exc:
logger.debug("%s get_all_artists raised: %s", self.active_server, exc) logger.warning("%s get_all_artists raised: %s", self.active_server, exc)
return [] return []
def get_all_album_ids(self) -> set: def get_all_album_ids(self) -> set:
@ -155,7 +160,7 @@ class MediaServerEngine:
try: try:
return client.get_all_album_ids() return client.get_all_album_ids()
except Exception as exc: except Exception as exc:
logger.debug("%s get_all_album_ids raised: %s", self.active_server, exc) logger.warning("%s get_all_album_ids raised: %s", self.active_server, exc)
return set() return set()
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -174,7 +179,7 @@ class MediaServerEngine:
try: try:
return client.search_tracks(title, artist, limit) return client.search_tracks(title, artist, limit)
except Exception as exc: except Exception as exc:
logger.debug("%s search_tracks raised: %s", self.active_server, exc) logger.warning("%s search_tracks raised: %s", self.active_server, exc)
return [] return []
def trigger_library_scan(self) -> bool: def trigger_library_scan(self) -> bool:
@ -188,7 +193,7 @@ class MediaServerEngine:
try: try:
return client.trigger_library_scan() return client.trigger_library_scan()
except Exception as exc: except Exception as exc:
logger.debug("%s trigger_library_scan raised: %s", self.active_server, exc) logger.warning("%s trigger_library_scan raised: %s", self.active_server, exc)
return False return False
def is_library_scanning(self) -> bool: def is_library_scanning(self) -> bool:
@ -200,7 +205,7 @@ class MediaServerEngine:
try: try:
return client.is_library_scanning() return client.is_library_scanning()
except Exception as exc: except Exception as exc:
logger.debug("%s is_library_scanning raised: %s", self.active_server, exc) logger.warning("%s is_library_scanning raised: %s", self.active_server, exc)
return False return False
def get_library_stats(self) -> Dict[str, int]: def get_library_stats(self) -> Dict[str, int]:
@ -212,7 +217,7 @@ class MediaServerEngine:
try: try:
return client.get_library_stats() return client.get_library_stats()
except Exception as exc: except Exception as exc:
logger.debug("%s get_library_stats raised: %s", self.active_server, exc) logger.warning("%s get_library_stats raised: %s", self.active_server, exc)
return {} return {}
def get_recently_added_albums(self, max_results: int = 400) -> List[Any]: def get_recently_added_albums(self, max_results: int = 400) -> List[Any]:

View file

@ -73,6 +73,15 @@ class MediaServerRegistry:
self._init_failures.append(spec.display_name) self._init_failures.append(spec.display_name)
self._instances[spec.name] = None self._instances[spec.name] = None
def set_instance(self, name: str, instance: Optional[MediaServerClient]) -> None:
"""Stash a pre-built client instance into the registry without
running the spec's factory. Used by callers (web_server.py at
boot) that already constructed the per-server clients and want
the engine to wrap those exact instances rather than build new
ones. Replaces the old pattern of reaching into ``_instances``
directly from the engine."""
self._instances[name] = instance
@property @property
def init_failures(self) -> List[str]: def init_failures(self) -> List[str]:
return list(self._init_failures) return list(self._init_failures)

View file

@ -294,3 +294,68 @@ def test_get_media_server_engine_returns_set_singleton(make_engine):
assert get_media_server_engine() is engine assert get_media_server_engine() is engine
finally: finally:
set_media_server_engine(None) set_media_server_engine(None)
# ---------------------------------------------------------------------------
# Empty-engine fallback (web_server.py boot resilience)
# ---------------------------------------------------------------------------
def test_engine_with_empty_clients_dict_is_safe_to_use():
"""web_server.py falls back to ``MediaServerEngine(clients={})`` if
full engine init raises preserves the resilience the per-server
globals had pre-refactor (each one had its own try/except so engine
failure didn't take down dispatch sites). Pin the contract: empty
engine still answers safely on every method instead of raising."""
registry = MediaServerRegistry()
registry.register(ServerSpec(
name='plex', factory=lambda: _FakeClient('plex'), display_name='Plex',
))
engine = MediaServerEngine(
registry=registry,
clients={}, # No pre-built clients passed.
active_server_resolver=lambda: 'plex',
)
# client(name) returns None for every server — engine doesn't crash.
assert engine.client('plex') is None
assert engine.client('jellyfin') is None
# Cross-server methods return safe defaults instead of raising.
assert engine.is_connected() is False
assert engine.get_all_artists() == []
assert engine.get_all_album_ids() == set()
assert engine.search_tracks('t', 'a') == []
# configured_clients() returns empty dict cleanly.
assert engine.configured_clients() == {}
# ---------------------------------------------------------------------------
# Registry encapsulation (engine reaches via public set_instance,
# not the private _instances dict)
# ---------------------------------------------------------------------------
def test_engine_uses_registry_set_instance_not_private_attr():
"""Engine constructor with ``clients=`` must hand instances off
via the registry's public ``set_instance(name, client)`` method
rather than reaching into ``registry._instances`` directly. Pin
by giving the registry a ``set_instance`` spy and verifying the
engine calls it."""
plex = _FakeClient('plex')
registry = MediaServerRegistry()
registry.register(ServerSpec(
name='plex', factory=lambda: _FakeClient('plex'), display_name='Plex',
))
set_instance_calls = []
original = registry.set_instance
def _spy(name, client):
set_instance_calls.append((name, client))
original(name, client)
registry.set_instance = _spy
MediaServerEngine(
registry=registry,
clients={'plex': plex},
active_server_resolver=lambda: 'plex',
)
assert ('plex', plex) in set_instance_calls

View file

@ -581,13 +581,23 @@ except Exception as e:
def _safe_init_media_client(factory, name): def _safe_init_media_client(factory, name):
"""Build a media-server client, capturing per-server init failures """Build a media-server client, capturing per-server init failures
so one broken server doesn't take the engine down with it.""" so one broken server doesn't take the engine down with it.
Logs the exception class so the boot log distinguishes config errors
(ConnectionError, AuthenticationError) from genuine import / dependency
failures the broad ``Exception`` catch is intentional (any failure
on this code path means the user can't use that server, and we want
the other three to keep working) but the log makes the cause
diagnosable."""
try: try:
instance = factory() instance = factory()
logger.info(f" {name} client initialized") logger.info(f" {name} client initialized")
return instance return instance
except Exception as exc: except Exception as exc:
logger.error(f" {name} client failed to initialize: {exc}") logger.error(
f" {name} client failed to initialize: {type(exc).__name__}: {exc}",
exc_info=True,
)
return None return None