MS Cin/JohnBaumb honesty pass — drop dead wrappers, sync contract to reality

Pre-review audit found premature abstraction + lying docstrings.
Cut what isn't used, made the rest match what's actually shipped.

(1) Engine: dropped 7 cross-server dispatch wrappers that had ZERO
production callers (ensure_connection / get_all_artists /
get_all_album_ids / search_tracks / trigger_library_scan /
is_library_scanning / get_library_stats / get_recently_added_albums).
Every consumer reaches the active client directly via
sync_service._get_active_media_client() or engine.client(name).
Engine surface shrinks to client(name) / active_client() /
active_server / is_connected() (the one wrapper that has callers —
4 dashboard status sites) / configured_clients() / reload_config().
~150 lines deleted, 5 dead-method tests removed.

(2) Contract Protocol body trimmed to match REQUIRED_METHODS exactly
(is_connected, ensure_connection, get_all_artists, get_all_album_ids).
The other 5 methods that were declared in the Protocol
"required" section weren't actually required — Plex doesn't
implement get_recently_added_albums, Jellyfin doesn't implement
search_tracks, SoulSync doesn't implement most of them. Static
contract now matches runtime conformance test. Optional methods
moved to a KNOWN_PER_SERVER_METHODS data-only listing with audited
per-server coverage notes — discoverability without false promises.

(3) Engine module docstring + __init__.py docstring no longer
overclaim "33+ chains collapsed" — only 4 uniform-shape chains
were collapsed; ~18 server-specific chains stay explicit per the
"lift what's truly shared" standard. Phrasing now matches reality.

(4) types.py docstring claimed TrackInfo.from_jellyfin_dict and
TrackInfo.from_navidrome_dict exist as classmethods. They don't —
only from_plex_track / from_plex_playlist do. Jellyfin and Navidrome
construct TrackInfo inline at their call sites today. Docstring
now honest about that + flags the lift as a clean followup.

(5) Engine line 95 comment "backward-compat for source-specific
reaches" was misleading — there is no legacy alternative being
preserved; engine.client(name) IS the canonical access pattern.
Section header rewritten.

Tests: 2121 pass (was 2126; -5 dead-method pin tests).
This commit is contained in:
Broque Thomas 2026-05-05 22:36:05 -07:00
parent 99f527ecd1
commit 6489244bcc
5 changed files with 114 additions and 314 deletions

View file

@ -1,17 +1,23 @@
"""Media server engine — central dispatch for the per-server clients """Media server engine — central registry-backed access to the
(Plex, Jellyfin, Navidrome, SoulSync standalone). per-server clients (Plex, Jellyfin, Navidrome, SoulSync standalone).
Companion to the download engine refactor same architectural Companion to the download engine refactor same architectural
shape applied to the read-side of the library. The orchestrator shape applied to the read-side of the library. Pre-refactor
historically had 33+ ``if active_server == 'plex' / 'jellyfin' / web_server.py held four separate per-server globals
...`` dispatch sites in web_server.py. This package replaces those (``plex_client`` / ``jellyfin_client`` / ``navidrome_client`` /
with a single ``MediaServerEngine.method()`` call per operation. ``soulsync_library_client``) that every dispatch site reached
individually. This package replaces those globals with a single
engine that owns the client instances + a generic
``engine.client(name)`` accessor.
Per-server clients keep their protocol-specific work (Plex's The 18-or-so ``if active_server == 'plex' / 'jellyfin' / ...``
PlexAPI SDK, Jellyfin's REST endpoints, Navidrome's OpenSubsonic chains in web_server.py that do server-specific work (Plex raw
API, SoulSync's filesystem walk). The engine just routes by playlist API vs Jellyfin / Navidrome client methods returning
``active_server`` config + provides a uniform shape for the calls different shapes) stay explicit at the call site per the "lift
``web_server.py`` makes generically. what's truly shared" standard — but they reach the per-server
client through ``engine.client(name)`` rather than the legacy
globals. The four uniform-shape ``is_connected`` chains were the
only ones genuinely shared and are now ``engine.is_connected()``.
See ``docs/media-server-engine-refactor-plan.md`` for the full See ``docs/media-server-engine-refactor-plan.md`` for the full
phased plan. phased plan.

View file

@ -1,29 +1,25 @@
"""Canonical contract for media server clients. """Canonical contract for media server clients.
Narrow on purpose. Only declares the methods the orchestrator Narrow on purpose. Protocol body declares ONLY the methods every
dispatches generically across all servers. Server-specific extras registered client actually implements today keeps the static
(Plex's `set_music_library_by_name`, Jellyfin's user picker, contract honest. Server-specific extras (Plex's
Navidrome's music folder filter, SoulSync's filesystem rescan) ``set_music_library_by_name``, Jellyfin's user picker, Navidrome's
stay on the underlying client and are accessed through the music folder filter, SoulSync's filesystem rescan) and methods that
registry's typed accessor — same pattern as the download most-but-not-all servers implement (``search_tracks`` on Plex /
plugin contract. Navidrome but not Jellyfin; ``get_recently_added_albums`` on
Jellyfin / Navidrome / SoulSync but not Plex) stay off the Protocol
and are reached through ``engine.client(name)`` directly.
Every required method must be implemented by every registered The contract is a Protocol (structural typing) rather than an ABC
client. Optional methods have default no-op implementations so existing PlexClient / JellyfinClient / NavidromeClient /
servers without that capability (e.g. Navidrome's metadata
writeback stubs, SoulSync's playlist sync N/A) don't have to
declare a no-op explicitly.
The contract is a Protocol (structural typing) rather than an
ABC existing PlexClient / JellyfinClient / NavidromeClient /
SoulSyncClient grew the same shape independently because every SoulSyncClient grew the same shape independently because every
caller needed the same calls. This file just makes the implicit caller needed the same four calls. This file just makes that
contract explicit. implicit contract explicit + the conformance test pins it.
""" """
from __future__ import annotations from __future__ import annotations
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable from typing import Any, List, Protocol, runtime_checkable
@runtime_checkable @runtime_checkable
@ -31,14 +27,13 @@ class MediaServerClient(Protocol):
"""Structural contract every media server client must satisfy. """Structural contract every media server client must satisfy.
``runtime_checkable`` lets ``isinstance(client, MediaServerClient)`` ``runtime_checkable`` lets ``isinstance(client, MediaServerClient)``
work for the conformance test, but it ONLY checks method names work, but it ONLY checks method names not signatures. The
not signatures. The conformance test in conformance test in ``tests/media_server/test_conformance.py``
``tests/media_server/test_conformance.py`` does the deeper does the deeper class-level check via REQUIRED_METHODS.
signature check.
""" """
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Connection / lifecycle # Connection / lifecycle — required, every server implements
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def is_connected(self) -> bool: def is_connected(self) -> bool:
@ -53,7 +48,7 @@ class MediaServerClient(Protocol):
... ...
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Library reads (required — every server must support these) # Library reads — required, every server implements
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def get_all_artists(self) -> List[Any]: def get_all_artists(self) -> List[Any]:
@ -68,51 +63,12 @@ class MediaServerClient(Protocol):
format is server-native caller doesn't introspect.""" format is server-native caller doesn't introspect."""
... ...
def search_tracks(self, title: str, artist: str, limit: int = 15) -> List[Any]:
"""Search the server's library for tracks matching the title
+ artist. Used by playlist sync, download matching, and the
general search UI. Each item is a server-specific TrackInfo
wrapper."""
...
def get_recently_added_albums(self, max_results: int = 400) -> List[Any]:
"""Recently-added view — used by the Discover page +
watchlist scanner. Sorted by add timestamp descending."""
...
# ------------------------------------------------------------------
# Library writes — scan triggers
# ------------------------------------------------------------------
def trigger_library_scan(self) -> bool:
"""Ask the server to scan its music library. Some servers
(SoulSync standalone) walk the filesystem themselves; some
(Plex / Jellyfin / Navidrome) hit a server-side scan API."""
...
def is_library_scanning(self) -> bool:
"""True if a scan is currently running. Polled by the
scan-progress UI."""
...
def get_library_stats(self) -> Dict[str, int]:
"""Counts of artists / albums / tracks. Used by the
dashboard system-stats card."""
...
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Required + optional method names — used by the conformance tests to # Required method set — pinned by the conformance test. Mirrors the
# check structural conformance without the proxy weight of dataclasses. # Protocol body exactly so static + runtime contracts can't drift.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Conservative requirement set — only methods every one of the four
# servers actually implements today. Audited by the conformance test.
# Other methods (search_tracks, trigger_library_scan, etc.) exist on
# most servers but not all (e.g. SoulSync has no library scan API
# because it walks the filesystem directly). Phase B's engine
# adapters handle those with per-server fallback rather than forcing
# every client to declare a no-op stub.
REQUIRED_METHODS = { REQUIRED_METHODS = {
'is_connected', 'is_connected',
'ensure_connection', 'ensure_connection',
@ -120,29 +76,35 @@ REQUIRED_METHODS = {
'get_all_album_ids', 'get_all_album_ids',
} }
# Methods declared on the protocol but NOT enforced — the # Methods that exist on SOME servers but NOT all, listed here for
# conformance test does NOT fail if a client lacks one. Engine # discoverability. The conformance test does NOT enforce these. Callers
# adapters route around the gaps. Listed here so future contributors # that need one reach the per-server client directly via
# know what the engine expects to find when present. # ``engine.client(name).<method>`` rather than going through the engine,
OPTIONAL_METHODS = { # since the engine has no uniform safe-default that fits every method.
#
# Coverage today (audited 2026-05):
# search_tracks: Plex ✓, Navidrome ✓, Jellyfin ✗, SoulSync ✗
# get_recently_added_albums: Jellyfin ✓, Navidrome ✓, SoulSync ✓, Plex ✗ (uses recentlyAdded() on music library)
# trigger_library_scan / is_library_scanning: Plex ✓, Jellyfin ✓, Navidrome ✓, SoulSync ✗ (filesystem walks in-process)
# get_library_stats: Plex ✓, Jellyfin ✓, Navidrome ✓, SoulSync ✗
# create_playlist / update_playlist / get_all_playlists / etc: Plex ✓, Jellyfin ✓, Navidrome ✓, SoulSync ✗
# update_artist_*, update_album_poster, update_track_metadata: Plex ✓, Jellyfin partial, Navidrome stubs, SoulSync ✗
KNOWN_PER_SERVER_METHODS = (
'search_tracks', 'search_tracks',
'get_recently_added_albums', 'get_recently_added_albums',
'trigger_library_scan', 'trigger_library_scan',
'is_library_scanning', 'is_library_scanning',
'get_library_stats', 'get_library_stats',
# Playlist sync (Plex / Jellyfin / Navidrome implement; SoulSync no-op)
'create_playlist', 'create_playlist',
'update_playlist', 'update_playlist',
'copy_playlist', 'copy_playlist',
'get_all_playlists', 'get_all_playlists',
'get_playlist_by_name', 'get_playlist_by_name',
# Analytics
'get_play_history', 'get_play_history',
'get_track_play_counts', 'get_track_play_counts',
# Metadata writeback (Plex full support; Jellyfin partial; Navidrome stubs; SoulSync N/A)
'update_artist_genres', 'update_artist_genres',
'update_artist_poster', 'update_artist_poster',
'update_album_poster', 'update_album_poster',
'update_artist_biography', 'update_artist_biography',
'update_track_metadata', 'update_track_metadata',
} )

View file

@ -1,33 +1,40 @@
"""MediaServerEngine — central dispatch for media server operations. """MediaServerEngine — central registry-backed access to media server clients.
Replaces the *uniform-shape* dispatch chains in web_server.py Honest scope: the engine OWNS the per-server client instances and
(``is_connected``, ``get_all_artists``, etc. anything where every exposes a small set of generic accessors so callers don't need
server returns the same shape and the only branching was on per-server attribute reaches. Most actual cross-server dispatch in
``active_server == X``). Each such operation is now one web_server.py (playlist add / remove / replace, per-server metadata
``engine.method()`` call that: 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.
1. Reads the ``server.active`` config to find the current target. Surface:
2. Looks up the registered client. - ``client(name)`` / ``active_client()`` name client lookup
3. Calls the corresponding method (with safe per-server fallbacks - ``active_server`` config-driven active server name
for methods that don't exist on every client — e.g. SoulSync - ``is_connected()`` only cross-server dispatch with real callers
has no library-scan API). 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
Server-specific dispatch sites (Plex's raw playlist API, Jellyfin / Per-method engine wrappers for ``get_all_artists`` / ``search_tracks``
Navidrome client methods returning different shapes) stay explicit / ``trigger_library_scan`` / etc. were on an earlier draft but had no
in web_server.py per the "lift what's truly shared" standard. They production callers every consumer reaches the active client directly
reach individual clients via ``engine.client(name)`` rather than through ``sync_service._get_active_media_client()`` or
the per-server globals same generic-accessor pattern as the ``engine.client(name)`` and calls the per-server method itself. Cut
download orchestrator. per the "no premature abstraction" standard.
Engine itself is constructed once during web_server.py init and Engine is constructed once during web_server.py init and held as a
held as a process-wide singleton via process-wide singleton via ``set_media_server_engine`` /
``set_media_server_engine`` / ``get_media_server_engine``, mirroring ``get_media_server_engine``, mirroring the metadata + download
the metadata + download engine factory shape. engine factory shape.
""" """
from __future__ import annotations from __future__ import annotations
from typing import Any, Dict, List, Optional from typing import Any, Dict, Optional
from utils.logging_config import get_logger from utils.logging_config import get_logger
@ -92,7 +99,8 @@ class MediaServerEngine:
self._resolve_active = active_server_resolver self._resolve_active = active_server_resolver
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Direct client access (backward-compat for source-specific reaches) # Client lookup — generic accessors that replace per-server
# attribute reaches in callers.
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def client(self, name: str) -> Optional[MediaServerClient]: def client(self, name: str) -> Optional[MediaServerClient]:
@ -111,13 +119,12 @@ class MediaServerEngine:
"""The client for the currently-active server.""" """The client for the currently-active server."""
return self.registry.get(self.active_server) return self.registry.get(self.active_server)
# ------------------------------------------------------------------
# Cross-server dispatch — required methods (always present)
# ------------------------------------------------------------------
def is_connected(self) -> bool: def is_connected(self) -> bool:
"""Active server's connection state. False if no active """Active server's connection state. False if no active
client (registered but failed to initialize).""" 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() client = self.active_client()
if client is None: if client is None:
return False return False
@ -127,124 +134,6 @@ class MediaServerEngine:
logger.warning("%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:
"""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.warning("%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.warning("%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.warning("%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.warning("%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.warning("%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.warning("%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.warning("%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.warning(
"%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]: def configured_clients(self) -> Dict[str, MediaServerClient]:
"""Return ``{name: client}`` for every server that's both """Return ``{name: client}`` for every server that's both
registered AND reports ``is_connected() == True``. Replaces registered AND reports ``is_connected() == True``. Replaces

View file

@ -1,19 +1,21 @@
"""Shared dataclasses for the media-server contract. """Shared dataclasses for the media-server contract.
Plex / Jellyfin / Navidrome / SoulSync clients all surfaced near- Plex / Jellyfin / Navidrome clients all surfaced near-identical
identical ``XTrackInfo`` and ``XPlaylistInfo`` shapes (id, title, ``XTrackInfo`` and ``XPlaylistInfo`` shapes (id, title, artist,
artist, album, duration, track_number, year, optional rating) album, duration, track_number, year, optional rating) because every
because every consumer needed the same shape downstream. The consumer needed the same shape downstream. The per-server class
per-server class names were a copy-paste artifact, not a real names were a copy-paste artifact, not a real contract difference.
contract difference.
This module owns the canonical types. Per-server constructors live This module owns the canonical types. Plex's existing classmethod
on the unified class as classmethods (``TrackInfo.from_plex_track``, constructors (``TrackInfo.from_plex_track``,
``TrackInfo.from_jellyfin_dict``, ``TrackInfo.from_navidrome_dict``) ``PlaylistInfo.from_plex_playlist``) live here. Jellyfin and
same pattern Cin used for ``Album.from_spotify_dict`` etc. on the Navidrome currently construct ``TrackInfo`` inline at their call
metadata engine. Heavy server SDK types (``PlexTrack``, dict sites lifting those into matching ``from_jellyfin_dict`` /
shapes) imported under TYPE_CHECKING so this module stays ``from_navidrome_dict`` classmethods is a clean followup but isn't
import-light. needed for the dataclass unification this module ships.
Heavy server SDK types (``PlexTrack``) imported under TYPE_CHECKING
so this module stays import-light.
""" """
from __future__ import annotations from __future__ import annotations

View file

@ -107,20 +107,9 @@ def test_is_connected_routes_to_active_client(make_engine):
assert engine.is_connected() is True # follows plex assert engine.is_connected() is True # follows plex
def test_get_all_album_ids_returns_active_clients_set(make_engine): def test_engine_is_connected_returns_false_when_active_client_failed_to_init():
plex = _FakeClient('plex')
plex._album_ids = {'p-1', 'p-2'}
jelly = _FakeClient('jellyfin')
jelly._album_ids = {'j-1'}
engine = make_engine({'plex': plex, 'jellyfin': jelly}, active='plex')
assert engine.get_all_album_ids() == {'p-1', 'p-2'}
engine = make_engine({'plex': plex, 'jellyfin': jelly}, active='jellyfin')
assert engine.get_all_album_ids() == {'j-1'}
def test_engine_returns_safe_defaults_when_active_client_failed_to_init(make_engine):
"""When the active client failed to initialize (registry stored """When the active client failed to initialize (registry stored
None), the engine returns safe defaults instead of raising.""" None), the engine returns False instead of raising."""
registry = MediaServerRegistry() registry = MediaServerRegistry()
registry.register(ServerSpec( registry.register(ServerSpec(
name='broken', name='broken',
@ -131,65 +120,18 @@ def test_engine_returns_safe_defaults_when_active_client_failed_to_init(make_eng
engine = MediaServerEngine(registry=registry, active_server_resolver=lambda: 'broken') engine = MediaServerEngine(registry=registry, active_server_resolver=lambda: 'broken')
assert engine.is_connected() is False assert engine.is_connected() is False
assert engine.get_all_artists() == [] assert engine.active_client() is None
assert engine.get_all_album_ids() == set()
assert engine.search_tracks('t', 'a') == []
assert engine.trigger_library_scan() is False
assert engine.is_library_scanning() is False
def test_engine_swallows_per_method_exceptions(make_engine): def test_is_connected_swallows_exception_from_client(make_engine):
"""A method that raises must NOT propagate to the dispatch """If the client's is_connected raises, engine returns False
site engine returns the safe default instead, mirroring the instead of propagating dashboard status indicators stay
legacy web_server.py defensive try/except chains.""" responsive even if a server is misbehaving."""
plex = _FakeClient('plex') plex = _FakeClient('plex')
plex.is_connected = MagicMock(side_effect=RuntimeError("boom")) plex.is_connected = MagicMock(side_effect=RuntimeError("boom"))
plex.get_all_album_ids = MagicMock(side_effect=RuntimeError("boom"))
engine = make_engine({'plex': plex}, active='plex') engine = make_engine({'plex': plex}, active='plex')
assert engine.is_connected() is False assert engine.is_connected() is False
assert engine.get_all_album_ids() == set()
# ---------------------------------------------------------------------------
# Optional-method dispatch (engine returns safe default when missing)
# ---------------------------------------------------------------------------
class _MinimalClient:
"""Stand-in for SoulSync standalone — only the required methods,
NO optional methods. Used to assert engine routes around missing
optional methods with safe defaults."""
def is_connected(self): return True
def ensure_connection(self): return True
def get_all_artists(self): return []
def get_all_album_ids(self): return set()
def test_search_tracks_returns_empty_when_client_lacks_method(make_engine):
"""SoulSync standalone has no search_tracks — engine returns
[] instead of raising AttributeError."""
engine = make_engine({'soulsync': _MinimalClient()}, active='soulsync')
assert engine.search_tracks('t', 'a') == []
def test_trigger_library_scan_returns_true_when_client_lacks_method(make_engine):
"""SoulSync has no trigger_library_scan (filesystem walks
happen in-process). Engine no-ops with True so callers don't
treat it as a failure."""
engine = make_engine({'soulsync': _MinimalClient()}, active='soulsync')
assert engine.trigger_library_scan() is True
def test_is_library_scanning_returns_false_when_client_lacks_method(make_engine):
engine = make_engine({'soulsync': _MinimalClient()}, active='soulsync')
assert engine.is_library_scanning() is False
def test_get_library_stats_returns_empty_dict_when_client_lacks_method(make_engine):
engine = make_engine({'soulsync': _MinimalClient()}, active='soulsync')
assert engine.get_library_stats() == {}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -306,7 +248,7 @@ def test_engine_with_empty_clients_dict_is_safe_to_use():
full engine init raises preserves the resilience the per-server full engine init raises preserves the resilience the per-server
globals had pre-refactor (each one had its own try/except so engine globals had pre-refactor (each one had its own try/except so engine
failure didn't take down dispatch sites). Pin the contract: empty failure didn't take down dispatch sites). Pin the contract: empty
engine still answers safely on every method instead of raising.""" engine still answers safely on every accessor instead of raising."""
registry = MediaServerRegistry() registry = MediaServerRegistry()
registry.register(ServerSpec( registry.register(ServerSpec(
name='plex', factory=lambda: _FakeClient('plex'), display_name='Plex', name='plex', factory=lambda: _FakeClient('plex'), display_name='Plex',
@ -320,11 +262,10 @@ def test_engine_with_empty_clients_dict_is_safe_to_use():
# client(name) returns None for every server — engine doesn't crash. # client(name) returns None for every server — engine doesn't crash.
assert engine.client('plex') is None assert engine.client('plex') is None
assert engine.client('jellyfin') is None assert engine.client('jellyfin') is None
# Cross-server methods return safe defaults instead of raising. # Active-client lookup + is_connected gracefully handle the empty
# case without raising.
assert engine.active_client() is None
assert engine.is_connected() is False 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. # configured_clients() returns empty dict cleanly.
assert engine.configured_clients() == {} assert engine.configured_clients() == {}