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.
pull/497/head
Broque Thomas 2 weeks ago
parent d3f8a06d7a
commit a6bb5f5b43

@ -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:

@ -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}")

@ -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:

@ -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."""

@ -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),

@ -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('<name>').
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")

Loading…
Cancel
Save