diff --git a/core/discovery/sync.py b/core/discovery/sync.py index 3c602fb9..9d92b938 100644 --- a/core/discovery/sync.py +++ b/core/discovery/sync.py @@ -39,8 +39,7 @@ class SyncDeps: """Bundle of cross-cutting deps the sync worker needs.""" config_manager: Any sync_service: Any - plex_client: Any - jellyfin_client: Any + media_server_engine: Any automation_engine: Any run_async: Callable[..., Any] 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 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}") - logger.info(f" deps.jellyfin_client: {sync_service.jellyfin_client is not None}") + _ms_engine = getattr(sync_service, '_engine', 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 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: active_server = deps.config_manager.get_active_media_server() logger.info(f"[PLAYLIST IMAGE] active_server={active_server}") - if active_server == 'plex' and deps.plex_client: - ok = deps.plex_client.set_playlist_image(playlist_name, playlist_image_url) + _engine = deps.media_server_engine + 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}") - elif active_server in ('jellyfin', 'emby') and deps.jellyfin_client: - ok = deps.jellyfin_client.set_playlist_image(playlist_name, playlist_image_url) + elif active_server in ('jellyfin', 'emby') and _engine and _engine.client('jellyfin'): + ok = _engine.client('jellyfin').set_playlist_image(playlist_name, playlist_image_url) logger.info(f"[PLAYLIST IMAGE] Jellyfin upload result: {ok}") # Navidrome doesn't support custom playlist images except Exception as img_err: diff --git a/core/listening_stats_worker.py b/core/listening_stats_worker.py index 72bdaf16..ec70e1fd 100644 --- a/core/listening_stats_worker.py +++ b/core/listening_stats_worker.py @@ -19,13 +19,17 @@ logger = get_logger("listening_stats_worker") class ListeningStatsWorker: """Background worker that polls media servers for play data.""" - def __init__(self, database, config_manager, plex_client=None, - jellyfin_client=None, navidrome_client=None): + def __init__(self, database, config_manager, media_server_engine=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.config_manager = config_manager - self.plex_client = plex_client - self.jellyfin_client = jellyfin_client - self.navidrome_client = navidrome_client + self._engine = media_server_engine # Worker state self.running = False @@ -145,13 +149,11 @@ class ListeningStatsWorker: logger.info(f"Polling {active_server} for listening data...") self.current_item = f"Polling {active_server}..." - client = None - if active_server == 'plex' and self.plex_client: - client = self.plex_client - elif active_server == 'jellyfin' and self.jellyfin_client: - client = self.jellyfin_client - elif active_server == 'navidrome' and self.navidrome_client: - client = self.navidrome_client + client = self._engine.client(active_server) if self._engine else None + # SoulSync standalone has no listening data; only the three + # streaming servers contribute. Mirror the legacy guard here. + if active_server not in ('plex', 'jellyfin', 'navidrome'): + client = None if not client: logger.warning(f"No client available for active server: {active_server}") diff --git a/core/web_scan_manager.py b/core/web_scan_manager.py index 810490ce..813fc834 100644 --- a/core/web_scan_manager.py +++ b/core/web_scan_manager.py @@ -19,16 +19,21 @@ class WebScanManager: - 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. 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) """ self.delay = delay_seconds - self.media_clients = media_clients + self._engine = media_server_engine self._timer = None self._scan_in_progress = False self._downloads_during_scan = False @@ -44,29 +49,19 @@ class WebScanManager: logger.info(f"WebScanManager initialized with {delay_seconds}s debounce delay") def _get_active_media_client(self): - """Get the active media client based on config settings""" + """Get the active media client through the engine.""" try: from config.settings import config_manager active_server = config_manager.get_active_media_server() - server_client_map = { - 'jellyfin': 'jellyfin_client', - 'navidrome': 'navidrome_client', - 'plex': 'plex_client', - 'soulsync': 'soulsync_library_client', - } - - # Try to get the configured active server - if active_server in server_client_map: - client_key = server_client_map[active_server] - client = self.media_clients.get(client_key) - 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 + if not self._engine: + logger.error("Web scan manager has no engine reference") + return None, None - logger.error("No active media server configured for scanning") + client = self._engine.client(active_server) + if client and hasattr(client, 'is_connected') and client.is_connected(): + return client, active_server + logger.warning(f"{(active_server or 'unknown').title()} client not connected — scan skipped") return None, None except Exception as e: diff --git a/services/sync_service.py b/services/sync_service.py index 4582a5cf..119aff87 100644 --- a/services/sync_service.py +++ b/services/sync_service.py @@ -44,17 +44,31 @@ class SyncProgress: failed_tracks: int = 0 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.plex_client = plex_client - self.jellyfin_client = jellyfin_client - self.navidrome_client = navidrome_client + self._engine = media_server_engine self.soulseek_client = soulseek_client self.progress_callbacks = {} # Playlist-specific progress callbacks self.syncing_playlists = set() # Track multiple syncing playlists self._cancelled = False 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): """Get the active media client based on config settings. @@ -68,26 +82,27 @@ class PlaylistSyncService: active_server = config_manager.get_active_media_server() 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") return None, "jellyfin" - # Apply per-profile Jellyfin library if set if profile_id: - self._apply_profile_library(profile_id, 'jellyfin', self.jellyfin_client) - return self.jellyfin_client, "jellyfin" + self._apply_profile_library(profile_id, 'jellyfin', client) + return client, "jellyfin" 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") return None, "navidrome" - return self.navidrome_client, "navidrome" + return client, "navidrome" else: # Default to Plex - # Apply per-profile Plex library if set - if profile_id: - self._apply_profile_library(profile_id, 'plex', self.plex_client) - return self.plex_client, "plex" + client = self._media_client('plex') + if profile_id and client: + self._apply_profile_library(profile_id, 'plex', client) + return client, "plex" except Exception as 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): """Apply per-profile library selection to a media client if configured.""" diff --git a/tests/discovery/test_discovery_sync.py b/tests/discovery/test_discovery_sync.py index f8fa3119..8cd9eee0 100644 --- a/tests/discovery/test_discovery_sync.py +++ b/tests/discovery/test_discovery_sync.py @@ -35,6 +35,15 @@ class _FakeMediaClient: 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: def __init__(self, *, media_client=None, server_type='plex', sync_result=None, raise_on_sync=None, @@ -44,8 +53,12 @@ class _FakeSyncService: self._sync_result = sync_result or _FakeSyncResult() self._raise_on_sync = raise_on_sync self.spotify_client = object() if spotify_client else None - self.plex_client = object() if plex_client else None - self.jellyfin_client = object() if jellyfin_client else None + # The sync_service exposes the engine so the discovery worker + # 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_playlist_name = None self.cleared_callbacks = [] @@ -130,8 +143,10 @@ def _build_deps( return ds.SyncDeps( config_manager=config or _FakeConfig(), sync_service=sync_service or _FakeSyncService(media_client=_FakeMediaClient()), - plex_client=plex or _FakePlex(), - jellyfin_client=jellyfin or _FakeJellyfin(), + media_server_engine=_FakeMediaServerEngine( + plex=plex or _FakePlex(), + jellyfin=jellyfin or _FakeJellyfin(), + ), automation_engine=automation or _FakeAutomationEngine(), run_async=run_async or _run_async_sync, record_sync_history_start=record_sync_history_start or (lambda **kw: None), diff --git a/web_server.py b/web_server.py index 0c554964..3ac6a69e 100644 --- a/web_server.py +++ b/web_server.py @@ -570,7 +570,7 @@ IS_SHUTTING_DOWN = False # 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. 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: spotify_client = get_spotify_client() @@ -578,42 +578,31 @@ try: except Exception as 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: - jellyfin_client = JellyfinClient() - logger.info(" Jellyfin client initialized") -except Exception as e: - logger.error(f" Jellyfin client failed to initialize: {e}") -try: - navidrome_client = NavidromeClient() - logger.info(" Navidrome client initialized") -except Exception as e: - logger.error(f" Navidrome client failed to initialize: {e}") +def _safe_init_media_client(factory, name): + """Build a media-server client, capturing per-server init failures + so one broken server doesn't take the engine down with it.""" + try: + 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: - from core.soulsync_client import SoulSyncClient - soulsync_library_client = SoulSyncClient() - logger.info(" SoulSync library client initialized") -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. +# Build the MediaServerEngine. The engine OWNS the per-server client +# instances — no separate web_server.py globals (Cin's standard from +# the download refactor: drop redundant access paths). All callers go +# through media_server_engine.client(''). try: from core.media_server.engine import MediaServerEngine, set_media_server_engine + from core.soulsync_client import SoulSyncClient media_server_engine = MediaServerEngine(clients={ - 'plex': plex_client, - 'jellyfin': jellyfin_client, - 'navidrome': navidrome_client, - 'soulsync': soulsync_library_client, + 'plex': _safe_init_media_client(PlexClient, "Plex"), + 'jellyfin': _safe_init_media_client(JellyfinClient, "Jellyfin"), + 'navidrome': _safe_init_media_client(NavidromeClient, "Navidrome"), + 'soulsync': _safe_init_media_client(SoulSyncClient, "SoulSync library"), }) # Install as process-wide singleton so callers reaching via # 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}") 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") except Exception as 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 try: - media_clients = { - '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) + web_scan_manager = WebScanManager(media_server_engine, delay_seconds=60) logger.info(" Web scan manager initialized") except Exception as 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 {} result = _search_library_check.check_library_presence( database=get_database(), - plex_client=plex_client, + plex_client=media_server_engine.client('plex') if media_server_engine else None, config_manager=config_manager, profile_id=get_current_profile_id(), albums=data.get('albums', []), @@ -23266,8 +23249,7 @@ def _build_sync_deps(): return _discovery_sync.SyncDeps( config_manager=config_manager, sync_service=sync_service, - plex_client=plex_client, - jellyfin_client=jellyfin_client, + media_server_engine=media_server_engine, automation_engine=automation_engine, run_async=run_async, record_sync_history_start=_record_sync_history_start, @@ -32848,9 +32830,7 @@ try: listening_stats_worker = ListeningStatsWorker( database=listening_stats_db, config_manager=config_manager, - plex_client=plex_client, - jellyfin_client=jellyfin_client, - navidrome_client=navidrome_client, + media_server_engine=media_server_engine, ) listening_stats_worker.start() logger.info("Listening stats worker initialized and started")