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:
parent
d3f8a06d7a
commit
a6bb5f5b43
6 changed files with 114 additions and 106 deletions
|
|
@ -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 = 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 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
|
||||
|
||||
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")
|
||||
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}")
|
||||
|
||||
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:
|
||||
jellyfin_client = JellyfinClient()
|
||||
logger.info(" Jellyfin client initialized")
|
||||
except Exception as e:
|
||||
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:
|
||||
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…
Reference in a new issue