MS Cin-5: Drop per-server globals — engine owns the clients

Per-server web_server.py globals (plex_client / jellyfin_client /
navidrome_client / soulsync_library_client) are gone. The engine now
owns the per-server client instances; web_server.py constructs them
inline into the engine init and routes everything through
media_server_engine.client('<name>').

Multi-client consumers refactored to take the engine instead of
separate per-server kwargs:

- services/sync_service.py: PlaylistSyncService.__init__ now takes
  media_server_engine. Internal _get_active_media_client resolves the
  active server's client through self._engine.client(name) instead of
  the per-server self.X_client attributes.
- core/listening_stats_worker.py: ListeningStatsWorker takes
  media_server_engine. The plex/jellyfin/navidrome dispatch in _poll
  collapses to engine.client(active_server) (gated to those three
  servers — SoulSync standalone has no listening data).
- core/web_scan_manager.py: WebScanManager takes media_server_engine
  instead of the hand-keyed media_clients dict that drifted out of
  sync with the engine.
- core/discovery/sync.py: SyncDeps holds media_server_engine instead
  of plex_client / jellyfin_client. Playlist-image dispatch routes
  through engine.client(name).

Web_server.py:
- Per-server globals removed from the chained `= None` init line
  + their try/except construction blocks. Replaced with a
  _safe_init_media_client(factory, name) helper that captures
  per-server init failures + passes the resulting clients straight
  into the MediaServerEngine init dict.
- All construction sites (PlaylistSyncService, WebScanManager,
  ListeningStatsWorker, SyncDeps, library_check) updated to receive
  the engine instead of per-server clients.

Test fixtures (tests/discovery/test_discovery_sync.py) gain a
_FakeMediaServerEngine stub + the SyncDeps build helper passes
that instead of separate plex/jellyfin clients.
This commit is contained in:
Broque Thomas 2026-05-05 18:05:45 -07:00
parent d3f8a06d7a
commit a6bb5f5b43
6 changed files with 114 additions and 106 deletions

View file

@ -39,8 +39,7 @@ class SyncDeps:
"""Bundle of cross-cutting deps the sync worker needs.""" """Bundle of cross-cutting deps the sync worker needs."""
config_manager: Any config_manager: Any
sync_service: Any sync_service: Any
plex_client: Any media_server_engine: Any
jellyfin_client: Any
automation_engine: Any automation_engine: Any
run_async: Callable[..., Any] run_async: Callable[..., Any]
record_sync_history_start: Callable record_sync_history_start: Callable
@ -227,8 +226,9 @@ def run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None, p
# Check sync service components # Check sync service components
logger.info(f" spotify_client: {sync_service.spotify_client is not None}") logger.info(f" spotify_client: {sync_service.spotify_client is not None}")
logger.info(f" deps.plex_client: {sync_service.plex_client is not None}") _ms_engine = getattr(sync_service, '_engine', None)
logger.info(f" deps.jellyfin_client: {sync_service.jellyfin_client is not None}") logger.info(f" plex_client: {(_ms_engine.client('plex') if _ms_engine else None) is not None}")
logger.info(f" jellyfin_client: {(_ms_engine.client('jellyfin') if _ms_engine else None) is not None}")
# Check media server connection before starting # Check media server connection before starting
from config.settings import config_manager from config.settings import config_manager
@ -404,11 +404,12 @@ def run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None, p
try: try:
active_server = deps.config_manager.get_active_media_server() active_server = deps.config_manager.get_active_media_server()
logger.info(f"[PLAYLIST IMAGE] active_server={active_server}") logger.info(f"[PLAYLIST IMAGE] active_server={active_server}")
if active_server == 'plex' and deps.plex_client: _engine = deps.media_server_engine
ok = deps.plex_client.set_playlist_image(playlist_name, playlist_image_url) if active_server == 'plex' and _engine and _engine.client('plex'):
ok = _engine.client('plex').set_playlist_image(playlist_name, playlist_image_url)
logger.info(f"[PLAYLIST IMAGE] Plex upload result: {ok}") logger.info(f"[PLAYLIST IMAGE] Plex upload result: {ok}")
elif active_server in ('jellyfin', 'emby') and deps.jellyfin_client: elif active_server in ('jellyfin', 'emby') and _engine and _engine.client('jellyfin'):
ok = deps.jellyfin_client.set_playlist_image(playlist_name, playlist_image_url) ok = _engine.client('jellyfin').set_playlist_image(playlist_name, playlist_image_url)
logger.info(f"[PLAYLIST IMAGE] Jellyfin upload result: {ok}") logger.info(f"[PLAYLIST IMAGE] Jellyfin upload result: {ok}")
# Navidrome doesn't support custom playlist images # Navidrome doesn't support custom playlist images
except Exception as img_err: except Exception as img_err:

View file

@ -19,13 +19,17 @@ logger = get_logger("listening_stats_worker")
class ListeningStatsWorker: class ListeningStatsWorker:
"""Background worker that polls media servers for play data.""" """Background worker that polls media servers for play data."""
def __init__(self, database, config_manager, plex_client=None, def __init__(self, database, config_manager, media_server_engine=None):
jellyfin_client=None, navidrome_client=None): """Initialize the worker.
``media_server_engine`` owns the per-server clients (Plex /
Jellyfin / Navidrome). The worker resolves the active server's
client through ``self._engine.client(name)`` instead of holding
per-server kwargs.
"""
self.db = database self.db = database
self.config_manager = config_manager self.config_manager = config_manager
self.plex_client = plex_client self._engine = media_server_engine
self.jellyfin_client = jellyfin_client
self.navidrome_client = navidrome_client
# Worker state # Worker state
self.running = False self.running = False
@ -145,13 +149,11 @@ class ListeningStatsWorker:
logger.info(f"Polling {active_server} for listening data...") logger.info(f"Polling {active_server} for listening data...")
self.current_item = f"Polling {active_server}..." self.current_item = f"Polling {active_server}..."
client = None client = self._engine.client(active_server) if self._engine else None
if active_server == 'plex' and self.plex_client: # SoulSync standalone has no listening data; only the three
client = self.plex_client # streaming servers contribute. Mirror the legacy guard here.
elif active_server == 'jellyfin' and self.jellyfin_client: if active_server not in ('plex', 'jellyfin', 'navidrome'):
client = self.jellyfin_client client = None
elif active_server == 'navidrome' and self.navidrome_client:
client = self.navidrome_client
if not client: if not client:
logger.warning(f"No client available for active server: {active_server}") logger.warning(f"No client available for active server: {active_server}")

View file

@ -19,16 +19,21 @@ class WebScanManager:
- Progress tracking and status reporting - Progress tracking and status reporting
""" """
def __init__(self, media_clients, delay_seconds: int = 60): def __init__(self, media_server_engine, delay_seconds: int = 60):
""" """
Initialize the web scan manager. Initialize the web scan manager.
Args: Args:
media_clients: Dict containing plex_client, jellyfin_client, navidrome_client media_server_engine: MediaServerEngine that owns the per-server
clients. Replaces the legacy ``media_clients`` dict the
manager now resolves the active server's client through
``self._engine.client(name)`` instead of a hand-keyed
dict that drifted out of sync with the engine's source
of truth.
delay_seconds: Debounce delay in seconds (default 60s) delay_seconds: Debounce delay in seconds (default 60s)
""" """
self.delay = delay_seconds self.delay = delay_seconds
self.media_clients = media_clients self._engine = media_server_engine
self._timer = None self._timer = None
self._scan_in_progress = False self._scan_in_progress = False
self._downloads_during_scan = False self._downloads_during_scan = False
@ -44,29 +49,19 @@ class WebScanManager:
logger.info(f"WebScanManager initialized with {delay_seconds}s debounce delay") logger.info(f"WebScanManager initialized with {delay_seconds}s debounce delay")
def _get_active_media_client(self): def _get_active_media_client(self):
"""Get the active media client based on config settings""" """Get the active media client through the engine."""
try: try:
from config.settings import config_manager from config.settings import config_manager
active_server = config_manager.get_active_media_server() active_server = config_manager.get_active_media_server()
server_client_map = { if not self._engine:
'jellyfin': 'jellyfin_client', logger.error("Web scan manager has no engine reference")
'navidrome': 'navidrome_client', return None, None
'plex': 'plex_client',
'soulsync': 'soulsync_library_client',
}
# Try to get the configured active server client = self._engine.client(active_server)
if active_server in server_client_map: if client and hasattr(client, 'is_connected') and client.is_connected():
client_key = server_client_map[active_server] return client, active_server
client = self.media_clients.get(client_key) logger.warning(f"{(active_server or 'unknown').title()} client not connected — scan skipped")
if client and hasattr(client, 'is_connected') and client.is_connected():
return client, active_server
else:
logger.warning(f"{active_server.title()} client not connected — scan skipped")
return None, None
logger.error("No active media server configured for scanning")
return None, None return None, None
except Exception as e: except Exception as e:

View file

@ -44,17 +44,31 @@ class SyncProgress:
failed_tracks: int = 0 failed_tracks: int = 0
class PlaylistSyncService: class PlaylistSyncService:
def __init__(self, spotify_client: SpotifyClient, plex_client: PlexClient, soulseek_client: SoulseekClient, jellyfin_client: JellyfinClient = None, navidrome_client = None): def __init__(self, spotify_client: SpotifyClient, soulseek_client: SoulseekClient, media_server_engine=None):
"""Initialize the sync service.
``media_server_engine`` is the central MediaServerEngine that owns
the per-server clients (Plex / Jellyfin / Navidrome / SoulSync).
Replaces the legacy per-server kwargs (plex_client / jellyfin_client
/ navidrome_client) all media-server access now goes through
``self._engine.client(name)`` so swapping the active server doesn't
need a service rebuild.
"""
self.spotify_client = spotify_client self.spotify_client = spotify_client
self.plex_client = plex_client self._engine = media_server_engine
self.jellyfin_client = jellyfin_client
self.navidrome_client = navidrome_client
self.soulseek_client = soulseek_client self.soulseek_client = soulseek_client
self.progress_callbacks = {} # Playlist-specific progress callbacks self.progress_callbacks = {} # Playlist-specific progress callbacks
self.syncing_playlists = set() # Track multiple syncing playlists self.syncing_playlists = set() # Track multiple syncing playlists
self._cancelled = False self._cancelled = False
self.matching_engine = MusicMatchingEngine() self.matching_engine = MusicMatchingEngine()
def _media_client(self, name: str):
"""Resolve a per-server client through the engine, or None when the
engine isn't wired (defensive — every production path passes one)."""
if self._engine is None:
return None
return self._engine.client(name)
def _get_active_media_client(self, profile_id=None): def _get_active_media_client(self, profile_id=None):
"""Get the active media client based on config settings. """Get the active media client based on config settings.
@ -68,26 +82,27 @@ class PlaylistSyncService:
active_server = config_manager.get_active_media_server() active_server = config_manager.get_active_media_server()
if active_server == "jellyfin": if active_server == "jellyfin":
if not self.jellyfin_client: client = self._media_client('jellyfin')
if not client:
logger.error("Jellyfin client not provided to sync service") logger.error("Jellyfin client not provided to sync service")
return None, "jellyfin" return None, "jellyfin"
# Apply per-profile Jellyfin library if set
if profile_id: if profile_id:
self._apply_profile_library(profile_id, 'jellyfin', self.jellyfin_client) self._apply_profile_library(profile_id, 'jellyfin', client)
return self.jellyfin_client, "jellyfin" return client, "jellyfin"
elif active_server == "navidrome": elif active_server == "navidrome":
if not self.navidrome_client: client = self._media_client('navidrome')
if not client:
logger.error("Navidrome client not provided to sync service") logger.error("Navidrome client not provided to sync service")
return None, "navidrome" return None, "navidrome"
return self.navidrome_client, "navidrome" return client, "navidrome"
else: # Default to Plex else: # Default to Plex
# Apply per-profile Plex library if set client = self._media_client('plex')
if profile_id: if profile_id and client:
self._apply_profile_library(profile_id, 'plex', self.plex_client) self._apply_profile_library(profile_id, 'plex', client)
return self.plex_client, "plex" return client, "plex"
except Exception as e: except Exception as e:
logger.error(f"Error determining active media server: {e}") logger.error(f"Error determining active media server: {e}")
return self.plex_client, "plex" # Fallback to Plex return self._media_client('plex'), "plex" # Fallback to Plex
def _apply_profile_library(self, profile_id, server_type, client): def _apply_profile_library(self, profile_id, server_type, client):
"""Apply per-profile library selection to a media client if configured.""" """Apply per-profile library selection to a media client if configured."""

View file

@ -35,6 +35,15 @@ class _FakeMediaClient:
return self._connected return self._connected
class _FakeMediaServerEngine:
"""Stand-in for MediaServerEngine — only the bits SyncDeps needs."""
def __init__(self, plex=None, jellyfin=None, navidrome=None):
self._clients = {'plex': plex, 'jellyfin': jellyfin, 'navidrome': navidrome}
def client(self, name):
return self._clients.get(name)
class _FakeSyncService: class _FakeSyncService:
def __init__(self, *, media_client=None, server_type='plex', def __init__(self, *, media_client=None, server_type='plex',
sync_result=None, raise_on_sync=None, sync_result=None, raise_on_sync=None,
@ -44,8 +53,12 @@ class _FakeSyncService:
self._sync_result = sync_result or _FakeSyncResult() self._sync_result = sync_result or _FakeSyncResult()
self._raise_on_sync = raise_on_sync self._raise_on_sync = raise_on_sync
self.spotify_client = object() if spotify_client else None self.spotify_client = object() if spotify_client else None
self.plex_client = object() if plex_client else None # The sync_service exposes the engine so the discovery worker
self.jellyfin_client = object() if jellyfin_client else None # can introspect per-server clients via self._engine.client(name).
self._engine = _FakeMediaServerEngine(
plex=object() if plex_client else None,
jellyfin=object() if jellyfin_client else None,
)
self.progress_callback = None self.progress_callback = None
self.progress_playlist_name = None self.progress_playlist_name = None
self.cleared_callbacks = [] self.cleared_callbacks = []
@ -130,8 +143,10 @@ def _build_deps(
return ds.SyncDeps( return ds.SyncDeps(
config_manager=config or _FakeConfig(), config_manager=config or _FakeConfig(),
sync_service=sync_service or _FakeSyncService(media_client=_FakeMediaClient()), sync_service=sync_service or _FakeSyncService(media_client=_FakeMediaClient()),
plex_client=plex or _FakePlex(), media_server_engine=_FakeMediaServerEngine(
jellyfin_client=jellyfin or _FakeJellyfin(), plex=plex or _FakePlex(),
jellyfin=jellyfin or _FakeJellyfin(),
),
automation_engine=automation or _FakeAutomationEngine(), automation_engine=automation or _FakeAutomationEngine(),
run_async=run_async or _run_async_sync, run_async=run_async or _run_async_sync,
record_sync_history_start=record_sync_history_start or (lambda **kw: None), record_sync_history_start=record_sync_history_start or (lambda **kw: None),

View file

@ -570,7 +570,7 @@ IS_SHUTTING_DOWN = False
# Each client is initialized independently so one failure doesn't take down everything. # Each client is initialized independently so one failure doesn't take down everything.
# Previously, a single exception set ALL clients to None, breaking the entire app. # Previously, a single exception set ALL clients to None, breaking the entire app.
logger.info("Initializing SoulSync services for Web UI...") logger.info("Initializing SoulSync services for Web UI...")
spotify_client = plex_client = jellyfin_client = navidrome_client = soulsync_library_client = soulseek_client = tidal_client = matching_engine = sync_service = web_scan_manager = None spotify_client = soulseek_client = tidal_client = matching_engine = sync_service = web_scan_manager = None
try: try:
spotify_client = get_spotify_client() spotify_client = get_spotify_client()
@ -578,42 +578,31 @@ try:
except Exception as e: except Exception as e:
logger.error(f" Spotify client failed to initialize: {e}") logger.error(f" Spotify client failed to initialize: {e}")
try:
plex_client = PlexClient()
logger.info(" Plex client initialized")
except Exception as e:
logger.error(f" Plex client failed to initialize: {e}")
try: def _safe_init_media_client(factory, name):
jellyfin_client = JellyfinClient() """Build a media-server client, capturing per-server init failures
logger.info(" Jellyfin client initialized") so one broken server doesn't take the engine down with it."""
except Exception as e: try:
logger.error(f" Jellyfin client failed to initialize: {e}") instance = factory()
logger.info(f" {name} client initialized")
return instance
except Exception as exc:
logger.error(f" {name} client failed to initialize: {exc}")
return None
try:
navidrome_client = NavidromeClient()
logger.info(" Navidrome client initialized")
except Exception as e:
logger.error(f" Navidrome client failed to initialize: {e}")
try: # Build the MediaServerEngine. The engine OWNS the per-server client
from core.soulsync_client import SoulSyncClient # instances — no separate web_server.py globals (Cin's standard from
soulsync_library_client = SoulSyncClient() # the download refactor: drop redundant access paths). All callers go
logger.info(" SoulSync library client initialized") # through media_server_engine.client('<name>').
except Exception as e:
logger.error(f" SoulSync library client failed to initialize: {e}")
# Build the MediaServerEngine on top of the per-client globals above.
# Engine wraps the same instances — no double-init. Provides
# ``engine.method()`` dispatch in place of the historic
# ``if active_server == 'plex' / 'jellyfin' / ...`` chains.
try: try:
from core.media_server.engine import MediaServerEngine, set_media_server_engine from core.media_server.engine import MediaServerEngine, set_media_server_engine
from core.soulsync_client import SoulSyncClient
media_server_engine = MediaServerEngine(clients={ media_server_engine = MediaServerEngine(clients={
'plex': plex_client, 'plex': _safe_init_media_client(PlexClient, "Plex"),
'jellyfin': jellyfin_client, 'jellyfin': _safe_init_media_client(JellyfinClient, "Jellyfin"),
'navidrome': navidrome_client, 'navidrome': _safe_init_media_client(NavidromeClient, "Navidrome"),
'soulsync': soulsync_library_client, 'soulsync': _safe_init_media_client(SoulSyncClient, "SoulSync library"),
}) })
# Install as process-wide singleton so callers reaching via # Install as process-wide singleton so callers reaching via
# get_media_server_engine() see the same instance web_server.py # get_media_server_engine() see the same instance web_server.py
@ -644,7 +633,7 @@ except Exception as e:
logger.error(f" Matching engine failed to initialize: {e}") logger.error(f" Matching engine failed to initialize: {e}")
try: try:
sync_service = PlaylistSyncService(spotify_client, plex_client, soulseek_client, jellyfin_client, media_server_engine.client('navidrome')) sync_service = PlaylistSyncService(spotify_client, soulseek_client, media_server_engine=media_server_engine)
logger.info(" Playlist sync service initialized") logger.info(" Playlist sync service initialized")
except Exception as e: except Exception as e:
logger.error(f" Playlist sync service failed to initialize: {e}") logger.error(f" Playlist sync service failed to initialize: {e}")
@ -669,13 +658,7 @@ if soulseek_client:
# Initialize web scan manager for automatic post-download scanning # Initialize web scan manager for automatic post-download scanning
try: try:
media_clients = { web_scan_manager = WebScanManager(media_server_engine, delay_seconds=60)
'plex_client': plex_client,
'jellyfin_client': jellyfin_client,
'navidrome_client': navidrome_client,
'soulsync_library_client': soulsync_library_client,
}
web_scan_manager = WebScanManager(media_clients, delay_seconds=60)
logger.info(" Web scan manager initialized") logger.info(" Web scan manager initialized")
except Exception as e: except Exception as e:
logger.error(f" Web scan manager failed to initialize: {e}") logger.error(f" Web scan manager failed to initialize: {e}")
@ -6879,7 +6862,7 @@ def enhanced_search_library_check():
data = request.get_json() or {} data = request.get_json() or {}
result = _search_library_check.check_library_presence( result = _search_library_check.check_library_presence(
database=get_database(), database=get_database(),
plex_client=plex_client, plex_client=media_server_engine.client('plex') if media_server_engine else None,
config_manager=config_manager, config_manager=config_manager,
profile_id=get_current_profile_id(), profile_id=get_current_profile_id(),
albums=data.get('albums', []), albums=data.get('albums', []),
@ -23266,8 +23249,7 @@ def _build_sync_deps():
return _discovery_sync.SyncDeps( return _discovery_sync.SyncDeps(
config_manager=config_manager, config_manager=config_manager,
sync_service=sync_service, sync_service=sync_service,
plex_client=plex_client, media_server_engine=media_server_engine,
jellyfin_client=jellyfin_client,
automation_engine=automation_engine, automation_engine=automation_engine,
run_async=run_async, run_async=run_async,
record_sync_history_start=_record_sync_history_start, record_sync_history_start=_record_sync_history_start,
@ -32848,9 +32830,7 @@ try:
listening_stats_worker = ListeningStatsWorker( listening_stats_worker = ListeningStatsWorker(
database=listening_stats_db, database=listening_stats_db,
config_manager=config_manager, config_manager=config_manager,
plex_client=plex_client, media_server_engine=media_server_engine,
jellyfin_client=jellyfin_client,
navidrome_client=navidrome_client,
) )
listening_stats_worker.start() listening_stats_worker.start()
logger.info("Listening stats worker initialized and started") logger.info("Listening stats worker initialized and started")