From 61ba3a15de03ba8e665f284759f1365e792431e1 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Mon, 4 May 2026 23:23:32 -0700 Subject: [PATCH] =?UTF-8?q?Cin-6:=20Rename=20soulseek=5Fclient=20global=20?= =?UTF-8?q?=E2=86=92=20download=5Forchestrator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The global handle in web_server.py was named soulseek_client for historical reasons but the type has long been DownloadOrchestrator, not SoulseekClient. Renamed the global plus every parameter/attribute that carried the legacy name. - web_server.py: global var renamed; all 99 references updated. - api/, core/downloads/*, core/search/*, core/streaming/*, services/sync_service.py: parameter names, dataclass fields, and init() arg names renamed. - Test fixtures (CandidatesDeps, MasterDeps, SearchDeps, etc.) and the _build_deps helpers updated accordingly. The core.soulseek_client module path and SoulseekClient class name (the actual soulseek-only client) are unchanged — only the orchestrator handle renamed. Module imports of TrackResult/AlbumResult/DownloadStatus from core.soulseek_client preserved. --- api/downloads.py | 4 +- api/request.py | 2 +- api/system.py | 2 +- core/connection_test.py | 14 +- core/debug_info.py | 14 +- core/download_orchestrator.py | 6 +- core/downloads/cancel.py | 14 +- core/downloads/candidates.py | 12 +- core/downloads/master.py | 4 +- core/downloads/monitor.py | 18 +- core/downloads/post_processing.py | 4 +- core/downloads/task_worker.py | 8 +- core/downloads/validation.py | 14 +- core/downloads/wishlist_failed.py | 12 +- core/search/basic.py | 4 +- core/search/orchestrator.py | 6 +- core/search/stream.py | 6 +- core/streaming/prepare.py | 12 +- services/sync_service.py | 6 +- tests/downloads/test_downloads_candidates.py | 24 +-- tests/downloads/test_downloads_master.py | 2 +- .../test_downloads_post_processing.py | 6 +- tests/downloads/test_downloads_task_worker.py | 2 +- tests/search/test_search_orchestrator.py | 8 +- tests/search/test_search_stream.py | 12 +- tests/streaming/test_prepare.py | 6 +- web_server.py | 201 +++++++++--------- webui/static/helper.js | 1 + 28 files changed, 212 insertions(+), 212 deletions(-) diff --git a/api/downloads.py b/api/downloads.py index 2f087874..69a9846d 100644 --- a/api/downloads.py +++ b/api/downloads.py @@ -121,7 +121,7 @@ def register_routes(bp): try: from utils.async_helpers import run_async - soulseek = current_app.soulsync.get("soulseek_client") + soulseek = current_app.soulsync.get("download_orchestrator") if not soulseek: return api_error("NOT_AVAILABLE", "Soulseek client not configured.", 503) @@ -138,7 +138,7 @@ def register_routes(bp): """Cancel all active downloads and clear completed ones.""" try: from utils.async_helpers import run_async - soulseek = current_app.soulsync.get("soulseek_client") + soulseek = current_app.soulsync.get("download_orchestrator") if not soulseek: return api_error("NOT_AVAILABLE", "Soulseek client not configured.", 503) diff --git a/api/request.py b/api/request.py index 820c6fc3..56c2d8ef 100644 --- a/api/request.py +++ b/api/request.py @@ -104,7 +104,7 @@ def _run_search_and_download(request_id, query, notify_url): if request_id in _pending_requests: _pending_requests[request_id]['status'] = 'searching' - soulseek = current_app._get_current_object().soulsync.get('soulseek_client') + soulseek = current_app._get_current_object().soulsync.get('download_orchestrator') if not soulseek: with _requests_lock: if request_id in _pending_requests: diff --git a/api/system.py b/api/system.py index 290da76e..d561cc19 100644 --- a/api/system.py +++ b/api/system.py @@ -26,7 +26,7 @@ def register_routes(bp): spotify = ctx.get("spotify_client") spotify_ok = bool(spotify and spotify.is_authenticated()) - soulseek = ctx.get("soulseek_client") + soulseek = ctx.get("download_orchestrator") soulseek_ok = bool(soulseek) hydrabase = ctx.get("hydrabase_client") diff --git a/core/connection_test.py b/core/connection_test.py index 8b905264..4064bab5 100644 --- a/core/connection_test.py +++ b/core/connection_test.py @@ -1,6 +1,6 @@ """Service connection test — lifted from web_server.py. -The function body is byte-identical to the original. soulseek_client, +The function body is byte-identical to the original. download_orchestrator, qobuz_enrichment_worker, hydrabase_client, docker_resolve_url, and docker_resolve_path are injected at runtime because they live in web_server.py and are constructed there. @@ -27,7 +27,7 @@ def _get_metadata_fallback_source(): # Injected at runtime via init(). -soulseek_client = None +download_orchestrator = None qobuz_enrichment_worker = None hydrabase_client = None docker_resolve_url = None @@ -35,16 +35,16 @@ docker_resolve_path = None def init( - soulseek_client_obj, + download_orchestrator_obj, qobuz_worker, hydrabase_client_obj, docker_resolve_url_fn, docker_resolve_path_fn, ): """Bind web_server-side helpers/globals so the lifted body can resolve them.""" - global soulseek_client, qobuz_enrichment_worker, hydrabase_client + global download_orchestrator, qobuz_enrichment_worker, hydrabase_client global docker_resolve_url, docker_resolve_path - soulseek_client = soulseek_client_obj + download_orchestrator = download_orchestrator_obj qobuz_enrichment_worker = qobuz_worker hydrabase_client = hydrabase_client_obj docker_resolve_url = docker_resolve_url_fn @@ -177,13 +177,13 @@ def run_service_test(service, test_config): else: return False, f"Output folder not found: {transfer_path}" elif service == "soulseek": - if soulseek_client is None: + if download_orchestrator is None: return False, "Download orchestrator failed to initialize. Check server logs for startup errors." # Test the orchestrator's configured download source (not just Soulseek) download_mode = config_manager.get('download_source.mode', 'hybrid') - if run_async(soulseek_client.check_connection()): + if run_async(download_orchestrator.check_connection()): # Success message based on active mode mode_messages = { 'soulseek': "Successfully connected to Soulseek network via slskd.", diff --git a/core/debug_info.py b/core/debug_info.py index 2af9d31a..7674422a 100644 --- a/core/debug_info.py +++ b/core/debug_info.py @@ -64,7 +64,7 @@ download_batches = None sync_states = None youtube_playlist_states = None tidal_discovery_states = None -soulseek_client = None +download_orchestrator = None _log_path = None _log_dir = None app = None @@ -80,7 +80,7 @@ def init( sync_states_dict, youtube_playlist_states_dict, tidal_discovery_states_dict, - soulseek_client_obj, + download_orchestrator_obj, log_path, log_dir, flask_app, @@ -90,7 +90,7 @@ def init( """Bind shared state/helpers from web_server.""" global SOULSYNC_VERSION, _DIRECT_RUN, _status_cache, qobuz_enrichment_worker global download_batches, sync_states, youtube_playlist_states - global tidal_discovery_states, soulseek_client, _log_path, _log_dir + global tidal_discovery_states, download_orchestrator, _log_path, _log_dir global app, get_database, _get_tidal_client SOULSYNC_VERSION = soulsync_version _DIRECT_RUN = direct_run @@ -100,7 +100,7 @@ def init( sync_states = sync_states_dict youtube_playlist_states = youtube_playlist_states_dict tidal_discovery_states = tidal_discovery_states_dict - soulseek_client = soulseek_client_obj + download_orchestrator = download_orchestrator_obj _log_path = log_path _log_dir = log_dir app = flask_app @@ -292,9 +292,9 @@ def get_debug_info(): # Download client init failures info['download_client_failures'] = [] - if soulseek_client and hasattr(soulseek_client, '_init_failures'): - info['download_client_failures'] = soulseek_client._init_failures - elif not soulseek_client: + if download_orchestrator and hasattr(download_orchestrator, '_init_failures'): + info['download_client_failures'] = download_orchestrator._init_failures + elif not download_orchestrator: info['download_client_failures'] = ['ALL (orchestrator failed to initialize)'] # API rate monitor — current calls/min, 24h totals, peaks, rate limit events diff --git a/core/download_orchestrator.py b/core/download_orchestrator.py index 88ccfc90..45c16d4e 100644 --- a/core/download_orchestrator.py +++ b/core/download_orchestrator.py @@ -547,10 +547,8 @@ class DownloadOrchestrator: # Singleton accessor — mirrors Cin's metadata engine pattern # (``get_metadata_engine()``). Callers that don't need a custom # registry use this instead of instantiating DownloadOrchestrator -# directly. Currently web_server.py constructs the singleton at -# startup and exposes it via the legacy ``soulseek_client`` global; -# this factory exists for new callers + future migration of that -# global to a more honestly-named ``download_orchestrator``. +# directly. web_server.py constructs the singleton at startup and +# exposes it via the ``download_orchestrator`` global. # --------------------------------------------------------------------------- _default_orchestrator: Optional['DownloadOrchestrator'] = None diff --git a/core/downloads/cancel.py b/core/downloads/cancel.py index 6adecbc9..9536eda4 100644 --- a/core/downloads/cancel.py +++ b/core/downloads/cancel.py @@ -42,31 +42,31 @@ _TERMINAL_STATUSES = { } -def cancel_single_download(soulseek_client, run_async: Callable, +def cancel_single_download(download_orchestrator, run_async: Callable, download_id: str, username: str) -> bool: """Cancel one specific slskd download (with `remove=True`).""" - return run_async(soulseek_client.cancel_download(download_id, username, remove=True)) + return run_async(download_orchestrator.cancel_download(download_id, username, remove=True)) -def cancel_all_active(soulseek_client, run_async: Callable, +def cancel_all_active(download_orchestrator, run_async: Callable, sweep_callback: Callable[[], None]) -> tuple[bool, str]: """Cancel every active slskd download, clear the resulting ones, sweep dirs. Returns `(success, message)` so the route can map to the right HTTP shape. """ - cancel_success = run_async(soulseek_client.cancel_all_downloads()) + cancel_success = run_async(download_orchestrator.cancel_all_downloads()) if not cancel_success: return False, "Failed to cancel active downloads." - run_async(soulseek_client.clear_all_completed_downloads()) + run_async(download_orchestrator.clear_all_completed_downloads()) sweep_callback() return True, "All downloads cancelled and cleared." -def clear_finished_active(soulseek_client, run_async: Callable, +def clear_finished_active(download_orchestrator, run_async: Callable, sweep_callback: Callable[[], None]) -> bool: """Clear all terminal transfers from slskd, sweep dirs on success.""" - success = run_async(soulseek_client.clear_all_completed_downloads()) + success = run_async(download_orchestrator.clear_all_completed_downloads()) if success: sweep_callback() return success diff --git a/core/downloads/candidates.py b/core/downloads/candidates.py index 9a578e10..cb2e6ee8 100644 --- a/core/downloads/candidates.py +++ b/core/downloads/candidates.py @@ -25,7 +25,7 @@ and notifies the lifecycle via `on_download_completed(success=False)` so the worker slot frees up. Lifted verbatim from web_server.py. Wide dependency surface -(soulseek_client, spotify_client, lifecycle callback, context-key helper, +(download_orchestrator, spotify_client, lifecycle callback, context-key helper, status updater, DB) all injected via `CandidatesDeps`. """ @@ -49,7 +49,7 @@ logger = logging.getLogger(__name__) @dataclass class CandidatesDeps: """Bundle of cross-cutting deps the candidate-fallback logic needs.""" - soulseek_client: Any + download_orchestrator: Any spotify_client: Any run_async: Callable[..., Any] get_database: Callable[[], Any] @@ -209,7 +209,7 @@ def attempt_download_with_candidates(task_id, candidates, track, batch_id=None, # Initiate download logger.info(f"[Modal Worker] Starting download: {username} / {os.path.basename(filename)}") - download_id = deps.run_async(deps.soulseek_client.download(username, filename, size)) + download_id = deps.run_async(deps.download_orchestrator.download(username, filename, size)) if download_id: # Store context for post-processing with complete Spotify metadata (GUI PARITY) @@ -330,7 +330,7 @@ def attempt_download_with_candidates(task_id, candidates, track, batch_id=None, logger.warning(f"[Modal Worker] Task {task_id} cancelled after download {download_id} started - attempting to cancel download") # Try to cancel the download immediately try: - deps.run_async(deps.soulseek_client.cancel_download(download_id, username, remove=True)) + deps.run_async(deps.download_orchestrator.cancel_download(download_id, username, remove=True)) logger.warning(f"Successfully cancelled active download {download_id}") except Exception as cancel_error: logger.error(f"Failed to cancel active download {download_id}: {cancel_error}") @@ -340,8 +340,8 @@ def attempt_download_with_candidates(task_id, candidates, track, batch_id=None, deps.on_download_completed(batch_id, task_id, success=False) return False - # Store download information - use real download ID from soulseek_client - # CRITICAL FIX: Trust the download ID returned by soulseek_client.download() + # Store download information - use real download ID from download_orchestrator + # CRITICAL FIX: Trust the download ID returned by download_orchestrator.download() download_tasks[task_id]['download_id'] = download_id download_tasks[task_id]['username'] = username diff --git a/core/downloads/master.py b/core/downloads/master.py index ff073637..5291f23b 100644 --- a/core/downloads/master.py +++ b/core/downloads/master.py @@ -42,7 +42,7 @@ logger = logging.getLogger(__name__) class MasterDeps: """Bundle of cross-cutting deps the master worker needs.""" config_manager: Any - soulseek_client: Any + download_orchestrator: Any run_async: Callable[..., Any] mb_worker: Any mb_release_cache: dict @@ -372,7 +372,7 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma _sr.info(f"[Album Pre-flight] Searching for '{artist_name} {album_name}'") logger.info(f"[Album Pre-flight] Searching Soulseek for complete album: '{artist_name} - {album_name}'") - slsk = deps.soulseek_client.client('soulseek') if hasattr(deps.soulseek_client, 'client') else deps.soulseek_client + slsk = deps.download_orchestrator.client('soulseek') if hasattr(deps.download_orchestrator, 'client') else deps.download_orchestrator # Try multiple query variations (banned keywords in artist/album name can return 0 results) album_queries = [f"{artist_name} {album_name}"] diff --git a/core/downloads/monitor.py b/core/downloads/monitor.py index c66ad504..b28200e9 100644 --- a/core/downloads/monitor.py +++ b/core/downloads/monitor.py @@ -33,7 +33,7 @@ _run_post_processing_worker = None _start_next_batch_of_downloads = None _orphaned_download_keys = None missing_download_executor = None -soulseek_client = None +download_orchestrator = None def init( @@ -44,12 +44,12 @@ def init( start_next_batch_of_downloads, orphaned_download_keys, missing_download_executor_obj, - soulseek_client_obj, + download_orchestrator_obj, ): """Bind web_server-side helpers/globals so the class body can resolve them.""" global _make_context_key, _on_download_completed, _download_track_worker global _run_post_processing_worker, _start_next_batch_of_downloads - global _orphaned_download_keys, missing_download_executor, soulseek_client + global _orphaned_download_keys, missing_download_executor, download_orchestrator _make_context_key = make_context_key _on_download_completed = on_download_completed _download_track_worker = download_track_worker @@ -57,7 +57,7 @@ def init( _start_next_batch_of_downloads = start_next_batch_of_downloads _orphaned_download_keys = orphaned_download_keys missing_download_executor = missing_download_executor_obj - soulseek_client = soulseek_client_obj + download_orchestrator = download_orchestrator_obj class WebUIDownloadMonitor: @@ -199,7 +199,7 @@ class WebUIDownloadMonitor: if op[0] == 'cancel_download': _, download_id, username = op logger.debug(f"[Deferred] Cancelling download: {download_id} from {username}") - run_async(soulseek_client.cancel_download(download_id, username, remove=True)) + run_async(download_orchestrator.cancel_download(download_id, username, remove=True)) logger.debug(f"[Deferred] Successfully cancelled download {download_id}") elif op[0] == 'cleanup_orphan': _, context_key = op @@ -254,9 +254,9 @@ class WebUIDownloadMonitor: # Get Soulseek downloads from API transfers_data = None - _slsk = soulseek_client.client('soulseek') if soulseek_client and hasattr(soulseek_client, 'client') else None + _slsk = download_orchestrator.client('soulseek') if download_orchestrator and hasattr(download_orchestrator, 'client') else None if soulseek_active and _slsk and _slsk.base_url: - transfers_data = run_async(soulseek_client._make_request('GET', 'transfers/downloads')) + transfers_data = run_async(download_orchestrator._make_request('GET', 'transfers/downloads')) if transfers_data: for user_data in transfers_data: username = user_data.get('username', 'Unknown') @@ -271,9 +271,9 @@ class WebUIDownloadMonitor: # cross-source aggregation, no per-source iteration. try: all_downloads = [] - if soulseek_client and hasattr(soulseek_client, 'engine'): + if download_orchestrator and hasattr(download_orchestrator, 'engine'): try: - all_downloads = run_async(soulseek_client.engine.get_all_downloads()) + all_downloads = run_async(download_orchestrator.engine.get_all_downloads()) except Exception: pass for download in all_downloads: diff --git a/core/downloads/post_processing.py b/core/downloads/post_processing.py index 4a3df9d2..1b2d04d2 100644 --- a/core/downloads/post_processing.py +++ b/core/downloads/post_processing.py @@ -54,7 +54,7 @@ class PostProcessDeps: always live (no caching of pre-init Spotify clients etc). """ config_manager: Any - soulseek_client: Any + download_orchestrator: Any run_async: Callable docker_resolve_path: Callable[[str], str] extract_filename: Callable[[str], str] @@ -196,7 +196,7 @@ def run_post_processing_worker(task_id: str, batch_id: str, deps: PostProcessDep # Query the download orchestrator for the status which contains the real file path # CRITICAL FIX: Use the actual download_id designated by the client, not the internal task_id actual_download_id = task.get('download_id') or task_id - status = deps.run_async(deps.soulseek_client.get_download_status(actual_download_id)) + status = deps.run_async(deps.download_orchestrator.get_download_status(actual_download_id)) if status and status.file_path: real_path = status.file_path if os.path.exists(real_path): diff --git a/core/downloads/task_worker.py b/core/downloads/task_worker.py index 4bd77e3d..0cbb569c 100644 --- a/core/downloads/task_worker.py +++ b/core/downloads/task_worker.py @@ -34,7 +34,7 @@ logger = logging.getLogger(__name__) @dataclass class TaskWorkerDeps: """Bundle of cross-cutting deps the per-task download worker needs.""" - soulseek_client: Any + download_orchestrator: Any matching_engine: Any run_async: Callable try_source_reuse: Callable # (task_id, batch_id, track) -> bool @@ -210,7 +210,7 @@ def download_track_worker(task_id: str, batch_id: Optional[str], deps: TaskWorke try: # Perform search with timeout - tracks_result, _ = deps.run_async(deps.soulseek_client.search(query, timeout=30)) + tracks_result, _ = deps.run_async(deps.download_orchestrator.search(query, timeout=30)) logger.debug(f"Search completed for task {task_id}, got {len(tracks_result) if tracks_result else 0} results") # CRITICAL: Check cancellation immediately after search returns @@ -272,9 +272,9 @@ def download_track_worker(task_id: str, batch_id: Optional[str], deps: TaskWorke # === HYBRID FALLBACK: If primary source failed, try remaining sources directly === # The orchestrator's hybrid search stops at the first source with results, even if # those results all fail quality filtering. Try remaining sources individually. - if getattr(deps.soulseek_client, 'mode', '') == 'hybrid': + if getattr(deps.download_orchestrator, 'mode', '') == 'hybrid': try: - orch = deps.soulseek_client + orch = deps.download_orchestrator hybrid_order = getattr(orch, 'hybrid_order', None) or [] if not hybrid_order: primary = getattr(orch, 'hybrid_primary', 'soulseek') diff --git a/core/downloads/validation.py b/core/downloads/validation.py index 5f8fced7..2fd64e26 100644 --- a/core/downloads/validation.py +++ b/core/downloads/validation.py @@ -1,7 +1,7 @@ """Soulseek/streaming candidate validation — lifted from web_server.py. Body is byte-identical to the original. ``matching_engine`` and -``soulseek_client`` are injected via init() because both are +``download_orchestrator`` are injected via init() because both are constructed in web_server.py and referenced by name throughout the body. """ @@ -14,14 +14,14 @@ logger = logging.getLogger(__name__) # Injected at runtime via init(). matching_engine = None -soulseek_client = None +download_orchestrator = None -def init(matching_engine_obj, soulseek_client_obj): +def init(matching_engine_obj, download_orchestrator_obj): """Bind the matching engine and download orchestrator from web_server.""" - global matching_engine, soulseek_client + global matching_engine, download_orchestrator matching_engine = matching_engine_obj - soulseek_client = soulseek_client_obj + download_orchestrator = download_orchestrator_obj def get_valid_candidates(results, spotify_track, query): @@ -151,8 +151,8 @@ def get_valid_candidates(results, spotify_track, query): quality_filtered_candidates = initial_candidates else: # Filter by user's quality profile before artist verification (Soulseek only) - # Use existing soulseek_client to avoid re-initializing (which accesses download_path filesystem) - quality_filtered_candidates = soulseek_client.client('soulseek').filter_results_by_quality_preference(initial_candidates) + # Use existing download_orchestrator to avoid re-initializing (which accesses download_path filesystem) + quality_filtered_candidates = download_orchestrator.client('soulseek').filter_results_by_quality_preference(initial_candidates) # IMPORTANT: Respect empty results from quality filter # If user has strict quality requirements (e.g., FLAC-only with fallback disabled), diff --git a/core/downloads/wishlist_failed.py b/core/downloads/wishlist_failed.py index f3e7d936..fcf2ce93 100644 --- a/core/downloads/wishlist_failed.py +++ b/core/downloads/wishlist_failed.py @@ -2,7 +2,7 @@ Body is byte-identical to the original. Wishlist helpers are direct imports from core.wishlist.*; runtime state comes from -core.runtime_state; automation_engine, soulseek_client, and the +core.runtime_state; automation_engine, download_orchestrator, and the sweep helper are injected via init() because they are constructed in web_server.py. """ @@ -29,15 +29,15 @@ logger = logging.getLogger(__name__) # Injected at runtime via init(). automation_engine = None -soulseek_client = None +download_orchestrator = None _sweep_empty_download_directories = None -def init(engine, soulseek_client_obj, sweep_fn): +def init(engine, download_orchestrator_obj, sweep_fn): """Bind shared singletons + the sweep helper from web_server.""" - global automation_engine, soulseek_client, _sweep_empty_download_directories + global automation_engine, download_orchestrator, _sweep_empty_download_directories automation_engine = engine - soulseek_client = soulseek_client_obj + download_orchestrator = download_orchestrator_obj _sweep_empty_download_directories = sweep_fn @@ -185,7 +185,7 @@ def _process_failed_tracks_to_wishlist_exact(batch_id): # Auto-cleanup: Clear completed downloads from slskd try: logger.info(f"[Auto-Cleanup] Clearing completed downloads from slskd after batch {batch_id}") - run_async(soulseek_client.clear_all_completed_downloads()) + run_async(download_orchestrator.clear_all_completed_downloads()) logger.info("[Auto-Cleanup] Completed downloads cleared from slskd") except Exception as cleanup_error: logger.warning(f"[Auto-Cleanup] Failed to clear completed downloads: {cleanup_error}") diff --git a/core/search/basic.py b/core/search/basic.py index 75ddd575..7c70eb29 100644 --- a/core/search/basic.py +++ b/core/search/basic.py @@ -14,7 +14,7 @@ logger = logging.getLogger(__name__) def run_basic_soulseek_search( query: str, - soulseek_client, + download_orchestrator, run_async: Callable, ) -> list[dict]: """Search Soulseek for `query`, normalize albums + tracks to one sorted list. @@ -22,7 +22,7 @@ def run_basic_soulseek_search( Returns dicts with `result_type` set to "album" or "track" and sorted by `quality_score` descending. Empty list on any failure (caller logs). """ - tracks, albums = run_async(soulseek_client.search(query)) + tracks, albums = run_async(download_orchestrator.search(query)) processed_albums = [] for album in albums: diff --git a/core/search/orchestrator.py b/core/search/orchestrator.py index ea8eb5af..da934759 100644 --- a/core/search/orchestrator.py +++ b/core/search/orchestrator.py @@ -49,7 +49,7 @@ class SearchDeps: spotify_client: Any hydrabase_client: Any hydrabase_worker: Any - soulseek_client: Any + download_orchestrator: Any fix_artist_image_url: Callable[[Optional[str]], Optional[str]] is_hydrabase_active: Callable[[], bool] get_metadata_fallback_source: Callable[[], str] @@ -291,9 +291,9 @@ def run_enhanced_search(query: str, requested_source: str, deps: SearchDeps) -> def resolve_youtube_videos_client(deps: SearchDeps): """Return the YouTube download client (used for music-video search) via the orchestrator's generic accessor, or None when unavailable.""" - if not deps.soulseek_client or not hasattr(deps.soulseek_client, 'client'): + if not deps.download_orchestrator or not hasattr(deps.download_orchestrator, 'client'): return None - return deps.soulseek_client.client('youtube') + return deps.download_orchestrator.client('youtube') def stream_youtube_videos(query: str, youtube_client, run_async: Callable) -> Iterator[str]: diff --git a/core/search/stream.py b/core/search/stream.py index 3b0293de..c8af3f65 100644 --- a/core/search/stream.py +++ b/core/search/stream.py @@ -96,7 +96,7 @@ def stream_search_track( album_name: Optional[str], duration_ms: int, config_manager, - soulseek_client, + download_orchestrator, matching_engine, run_async: Callable, ) -> Optional[dict]: @@ -120,7 +120,7 @@ def stream_search_track( # Map mode name to canonical registry name (legacy 'deezer_dl' # alias resolves to 'deezer' via the orchestrator's registry). - stream_client = soulseek_client.client(effective_mode) + stream_client = download_orchestrator.client(effective_mode) use_direct_client = stream_client is not None max_peer_queue = config_manager.get('soulseek.max_peer_queue', 0) or 0 @@ -131,7 +131,7 @@ def stream_search_track( if use_direct_client: tracks_result, _ = run_async(stream_client.search(query, timeout=15)) else: - tracks_result, _ = run_async(soulseek_client.search(query, timeout=15)) + tracks_result, _ = run_async(download_orchestrator.search(query, timeout=15)) if not tracks_result: logger.info(f"No results for query '{query}', trying next...") diff --git a/core/streaming/prepare.py b/core/streaming/prepare.py index 44977c66..b22b763d 100644 --- a/core/streaming/prepare.py +++ b/core/streaming/prepare.py @@ -7,9 +7,9 @@ it in the local Stream/ folder for the browser audio player. 1. Reset stream state to 'loading' with the new track info. 2. Clear any prior file from the Stream/ folder (only one stream lives there at a time). -3. Spin up a fresh asyncio event loop and `soulseek_client.download()` +3. Spin up a fresh asyncio event loop and `download_orchestrator.download()` the track. -4. Poll `soulseek_client.get_all_downloads()` every 1.5 s to track +4. Poll `download_orchestrator.get_all_downloads()` every 1.5 s to track progress, with separate handling for queued vs actively downloading states. Queue timeout = 15 s; overall timeout = 60 s. 5. On completion (state ~ 'succeeded' or progress >= 100% AND bytes @@ -45,7 +45,7 @@ logger = logging.getLogger(__name__) class PrepareStreamDeps: """Bundle of cross-cutting deps the stream-prep worker needs.""" config_manager: Any - soulseek_client: Any + download_orchestrator: Any stream_lock: Any # threading.Lock project_root: str # absolute path to web_server.py's directory docker_resolve_path: Callable[[str], str] @@ -112,7 +112,7 @@ def prepare_stream_task(track_data, deps: PrepareStreamDeps): asyncio.set_event_loop(loop) try: - download_result = loop.run_until_complete(deps.soulseek_client.download( + download_result = loop.run_until_complete(deps.download_orchestrator.download( track_data.get('username'), track_data.get('filename'), track_data.get('size', 0) @@ -144,7 +144,7 @@ def prepare_stream_task(track_data, deps: PrepareStreamDeps): try: # Use orchestrator's get_all_downloads() which works for both sources - all_downloads = loop.run_until_complete(deps.soulseek_client.get_all_downloads()) + all_downloads = loop.run_until_complete(deps.download_orchestrator.get_all_downloads()) download_status = deps.find_streaming_download_in_all_downloads(all_downloads, track_data) if download_status: @@ -252,7 +252,7 @@ def prepare_stream_task(track_data, deps: PrepareStreamDeps): download_id = download_status.get('id', '') if download_id and track_data.get('username'): success = loop.run_until_complete( - deps.soulseek_client.signal_download_completion( + deps.download_orchestrator.signal_download_completion( download_id, track_data.get('username'), remove=True) ) if success: diff --git a/services/sync_service.py b/services/sync_service.py index 4582a5cf..c4331a81 100644 --- a/services/sync_service.py +++ b/services/sync_service.py @@ -44,12 +44,12 @@ 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, plex_client: PlexClient, download_orchestrator: SoulseekClient, jellyfin_client: JellyfinClient = None, navidrome_client = None): self.spotify_client = spotify_client self.plex_client = plex_client self.jellyfin_client = jellyfin_client self.navidrome_client = navidrome_client - self.soulseek_client = soulseek_client + self.download_orchestrator = download_orchestrator self.progress_callbacks = {} # Playlist-specific progress callbacks self.syncing_playlists = set() # Track multiple syncing playlists self._cancelled = False @@ -635,7 +635,7 @@ class PlaylistSyncService: query = self.matching_engine.generate_download_query(match_result.spotify_track) logger.info(f"Attempting to download: {query}") - download_id = await self.soulseek_client.search_and_download_best(query, expected_track=match_result.spotify_track) + download_id = await self.download_orchestrator.search_and_download_best(query, expected_track=match_result.spotify_track) if download_id: downloaded_count += 1 diff --git a/tests/downloads/test_downloads_candidates.py b/tests/downloads/test_downloads_candidates.py index bddf3df4..19471384 100644 --- a/tests/downloads/test_downloads_candidates.py +++ b/tests/downloads/test_downloads_candidates.py @@ -101,7 +101,7 @@ def _build_deps( on_complete=None, ): deps = dc.CandidatesDeps( - soulseek_client=soulseek or _FakeSoulseek(), + download_orchestrator=soulseek or _FakeSoulseek(), spotify_client=spotify or _FakeSpotify(), run_async=_run_async, get_database=lambda: db or _FakeDB(), @@ -136,7 +136,7 @@ def test_first_candidate_starts_download_and_returns_true(): result = dc.attempt_download_with_candidates("t1", candidates, track, batch_id="b1", deps=deps) assert result is True - assert deps.soulseek_client.download_calls == [("user1", "best.flac", 1000)] + assert deps.download_orchestrator.download_calls == [("user1", "best.flac", 1000)] assert download_tasks["t1"]["download_id"] == "dl-1" assert "user1::best.flac" in matched_downloads_context @@ -155,7 +155,7 @@ def test_candidates_tried_in_confidence_order(): dc.attempt_download_with_candidates("t2", candidates, track, batch_id=None, deps=deps) # First call should be the highest-confidence one - assert deps.soulseek_client.download_calls[0][1] == "high.flac" + assert deps.download_orchestrator.download_calls[0][1] == "high.flac" # --------------------------------------------------------------------------- @@ -175,8 +175,8 @@ def test_already_tried_source_skipped(): dc.attempt_download_with_candidates("t3", candidates, track, batch_id=None, deps=deps) # First candidate skipped (already used), second one tried - assert len(deps.soulseek_client.download_calls) == 1 - assert deps.soulseek_client.download_calls[0][1] == "fresh.flac" + assert len(deps.download_orchestrator.download_calls) == 1 + assert deps.download_orchestrator.download_calls[0][1] == "fresh.flac" # --------------------------------------------------------------------------- @@ -196,7 +196,7 @@ def test_blacklisted_source_skipped(): dc.attempt_download_with_candidates("t4", candidates, track, batch_id=None, deps=deps) - assert deps.soulseek_client.download_calls[0][1] == "ok.flac" + assert deps.download_orchestrator.download_calls[0][1] == "ok.flac" # --------------------------------------------------------------------------- @@ -213,7 +213,7 @@ def test_cancellation_before_attempt_returns_false(): result = dc.attempt_download_with_candidates("t5", candidates, track, batch_id=None, deps=deps) assert result is False - assert deps.soulseek_client.download_calls == [] + assert deps.download_orchestrator.download_calls == [] def test_task_deleted_returns_false(): @@ -238,7 +238,7 @@ def test_active_download_id_skips_new_download(): dc.attempt_download_with_candidates("t6", candidates, track, batch_id=None, deps=deps) # Both candidates skipped (download_id already present) - assert deps.soulseek_client.download_calls == [] + assert deps.download_orchestrator.download_calls == [] def test_cancellation_after_download_starts_calls_cancel_and_lifecycle(): @@ -266,7 +266,7 @@ def test_cancellation_after_download_starts_calls_cancel_and_lifecycle(): assert result is False # cancel_download was called for the in-flight transfer - assert deps.soulseek_client.cancel_calls + assert deps.download_orchestrator.cancel_calls # on_download_completed fired with success=False to free the worker slot assert completion_calls == [("b7", "t7", False)] @@ -276,7 +276,7 @@ def test_cancellation_after_download_starts_calls_cancel_and_lifecycle(): # --------------------------------------------------------------------------- def test_all_candidates_failed_returns_false(): - """If soulseek_client.download returns None (failure) for all candidates, returns False.""" + """If download_orchestrator.download returns None (failure) for all candidates, returns False.""" soulseek = _FakeSoulseek(download_id=None) deps = _build_deps(soulseek=soulseek) _seed_task("t8") @@ -411,5 +411,5 @@ def test_candidates_with_equal_confidence_both_tried(): dc.attempt_download_with_candidates("t13", candidates, track, batch_id=None, deps=deps) # First one wins — second never tried because download succeeded - assert len(deps.soulseek_client.download_calls) == 1 - assert deps.soulseek_client.download_calls[0][1] == "a.flac" + assert len(deps.download_orchestrator.download_calls) == 1 + assert deps.download_orchestrator.download_calls[0][1] == "a.flac" diff --git a/tests/downloads/test_downloads_master.py b/tests/downloads/test_downloads_master.py index 5307dd2f..e702e71e 100644 --- a/tests/downloads/test_downloads_master.py +++ b/tests/downloads/test_downloads_master.py @@ -178,7 +178,7 @@ def _build_deps( ): return mw.MasterDeps( config_manager=config or _FakeConfig(), - soulseek_client=soulseek or _FakeSoulseekWrapper(_FakeSoulseek()), + download_orchestrator=soulseek or _FakeSoulseekWrapper(_FakeSoulseek()), run_async=run_async or _make_run_async(), mb_worker=mb_worker, mb_release_cache=mb_release_cache if mb_release_cache is not None else {}, diff --git a/tests/downloads/test_downloads_post_processing.py b/tests/downloads/test_downloads_post_processing.py index 2076ef6e..a5f02d8c 100644 --- a/tests/downloads/test_downloads_post_processing.py +++ b/tests/downloads/test_downloads_post_processing.py @@ -49,7 +49,7 @@ class _Recorder: def _build_deps( *, config=None, - soulseek_client=None, + download_orchestrator=None, run_async=None, docker_resolve_path=None, extract_filename=None, @@ -64,7 +64,7 @@ def _build_deps( rec = _Recorder() return pp.PostProcessDeps( config_manager=config or _FakeConfig(), - soulseek_client=soulseek_client, + download_orchestrator=download_orchestrator, run_async=run_async or (lambda c: None), docker_resolve_path=docker_resolve_path or (lambda p: p), extract_filename=extract_filename or (lambda f: os.path.basename(f) if f else ''), @@ -315,7 +315,7 @@ def test_youtube_task_uses_get_download_status_to_resolve_path(monkeypatch): monkeypatch.setattr(pp.os.path, 'exists', lambda p: p == '/downloads/Money.mp3') deps, rec = _build_deps( - soulseek_client=_FakeYTClient(), + download_orchestrator=_FakeYTClient(), run_async=lambda coro: coro, # not async — direct call ) pp.run_post_processing_worker('t1', 'b1', deps) diff --git a/tests/downloads/test_downloads_task_worker.py b/tests/downloads/test_downloads_task_worker.py index fca7b10b..3449820a 100644 --- a/tests/downloads/test_downloads_task_worker.py +++ b/tests/downloads/test_downloads_task_worker.py @@ -76,7 +76,7 @@ def _build_deps( ): rec = _Recorder() return tw.TaskWorkerDeps( - soulseek_client=soulseek or _FakeClient(), + download_orchestrator=soulseek or _FakeClient(), matching_engine=matching or _FakeMatchEngine(), run_async=_sync_run_async, try_source_reuse=try_source_reuse, diff --git a/tests/search/test_search_orchestrator.py b/tests/search/test_search_orchestrator.py index 2c3cfa4b..02766746 100644 --- a/tests/search/test_search_orchestrator.py +++ b/tests/search/test_search_orchestrator.py @@ -129,7 +129,7 @@ def _build_deps(**overrides): spotify_client=None, hydrabase_client=None, hydrabase_worker=None, - soulseek_client=None, + download_orchestrator=None, fix_artist_image_url=lambda u: f'FIXED::{u}' if u else None, is_hydrabase_active=lambda: False, get_metadata_fallback_source=lambda: 'spotify', @@ -470,19 +470,19 @@ class _FakeSoulseekWithYT: def test_resolve_youtube_videos_returns_subclient(): yt = _FakeYouTube() - deps = _build_deps(soulseek_client=_FakeSoulseekWithYT(yt)) + deps = _build_deps(download_orchestrator=_FakeSoulseekWithYT(yt)) assert orchestrator.resolve_youtube_videos_client(deps) is yt def test_resolve_youtube_videos_no_soulseek_returns_none(): - deps = _build_deps(soulseek_client=None) + deps = _build_deps(download_orchestrator=None) assert orchestrator.resolve_youtube_videos_client(deps) is None def test_resolve_youtube_videos_no_youtube_attr_returns_none(): class _NoYT: pass - deps = _build_deps(soulseek_client=_NoYT()) + deps = _build_deps(download_orchestrator=_NoYT()) assert orchestrator.resolve_youtube_videos_client(deps) is None diff --git a/tests/search/test_search_stream.py b/tests/search/test_search_stream.py index c62e9c54..6404d343 100644 --- a/tests/search/test_search_stream.py +++ b/tests/search/test_search_stream.py @@ -169,7 +169,7 @@ def test_stream_finds_match_on_first_query(): result = stream.stream_search_track( track_name='Money', artist_name='Pink Floyd', album_name=None, duration_ms=180000, - config_manager=cfg, soulseek_client=soul, matching_engine=engine, + config_manager=cfg, download_orchestrator=soul, matching_engine=engine, run_async=_run_async, ) assert result is not None @@ -190,7 +190,7 @@ def test_stream_walks_to_second_query_on_no_match(): result = stream.stream_search_track( track_name='Money (Remastered)', artist_name='Pink Floyd', album_name=None, duration_ms=180000, - config_manager=cfg, soulseek_client=soul, matching_engine=engine, + config_manager=cfg, download_orchestrator=soul, matching_engine=engine, run_async=_run_async, ) assert result is not None @@ -209,7 +209,7 @@ def test_stream_returns_none_when_no_matches(): result = stream.stream_search_track( track_name='Money', artist_name='Pink Floyd', album_name=None, duration_ms=180000, - config_manager=cfg, soulseek_client=soul, matching_engine=engine, + config_manager=cfg, download_orchestrator=soul, matching_engine=engine, run_async=_run_async, ) assert result is None @@ -217,7 +217,7 @@ def test_stream_returns_none_when_no_matches(): def test_stream_falls_back_to_default_soulseek_when_no_direct_client(): # Effective mode = 'youtube' (coerced from soulseek), but soul.youtube is None - # → code falls through to soulseek_client.search directly. Streaming-mode + # → code falls through to download_orchestrator.search directly. Streaming-mode # query gen → "Pink Floyd Money". soul = _FakeSoulseek(results_per_query={'Pink Floyd Money': ([object()], [])}) cfg = _FakeConfig({ @@ -229,7 +229,7 @@ def test_stream_falls_back_to_default_soulseek_when_no_direct_client(): result = stream.stream_search_track( track_name='Money', artist_name='Pink Floyd', album_name=None, duration_ms=180000, - config_manager=cfg, soulseek_client=soul, matching_engine=engine, + config_manager=cfg, download_orchestrator=soul, matching_engine=engine, run_async=_run_async, ) assert result is not None @@ -257,7 +257,7 @@ def test_stream_continues_past_per_query_exception(): result = stream.stream_search_track( track_name='Money (Live)', artist_name='Pink Floyd', album_name=None, duration_ms=180000, - config_manager=cfg, soulseek_client=soul, matching_engine=engine, + config_manager=cfg, download_orchestrator=soul, matching_engine=engine, run_async=_run_async, ) # First query raised, second succeeded diff --git a/tests/streaming/test_prepare.py b/tests/streaming/test_prepare.py index cfe3c277..bbfc9cef 100644 --- a/tests/streaming/test_prepare.py +++ b/tests/streaming/test_prepare.py @@ -10,7 +10,7 @@ from core.streaming import prepare as sp class _FakeSoulseek: - """Minimal soulseek_client stub for the stream-prep worker.""" + """Minimal download_orchestrator stub for the stream-prep worker.""" def __init__(self, *, download_id='dl-1', all_downloads=None): self._download_id = download_id @@ -43,7 +43,7 @@ def _build_deps( state = state if state is not None else {} deps = sp.PrepareStreamDeps( config_manager=type('C', (), {'get': lambda self, k, d=None: d})(), - soulseek_client=soulseek or _FakeSoulseek(), + download_orchestrator=soulseek or _FakeSoulseek(), stream_lock=threading.Lock(), project_root=project_root, docker_resolve_path=lambda p: p, @@ -107,7 +107,7 @@ def test_stream_folder_cleared_before_download(tmp_path): # --------------------------------------------------------------------------- def test_download_returns_none_marks_error(tmp_path): - """soulseek_client.download() returning None → state.error.""" + """download_orchestrator.download() returning None → state.error.""" sk = _FakeSoulseek(download_id=None) deps = _build_deps(soulseek=sk, project_root=str(tmp_path)) diff --git a/web_server.py b/web_server.py index 47c7c759..bdd157b0 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 = plex_client = jellyfin_client = navidrome_client = soulsync_library_client = download_orchestrator = tidal_client = matching_engine = sync_service = web_scan_manager = None try: spotify_client = get_spotify_client() @@ -604,7 +604,7 @@ except Exception as e: logger.error(f" SoulSync library client failed to initialize: {e}") try: - soulseek_client = DownloadOrchestrator() + download_orchestrator = DownloadOrchestrator() logger.info(" Download orchestrator initialized") except Exception as e: logger.error(f" Download orchestrator failed to initialize: {e}") @@ -622,7 +622,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, navidrome_client) + sync_service = PlaylistSyncService(spotify_client, plex_client, download_orchestrator, jellyfin_client, navidrome_client) logger.info(" Playlist sync service initialized") except Exception as e: logger.error(f" Playlist sync service failed to initialize: {e}") @@ -630,8 +630,8 @@ except Exception as e: # Inject shutdown check callback into every download source that # accepts one. Generic dispatch via the registry — no per-source # attribute reaches needed. -if soulseek_client and hasattr(soulseek_client, 'registry'): - for _src_name, _src_client in soulseek_client.registry.all_plugins(): +if download_orchestrator and hasattr(download_orchestrator, 'registry'): + for _src_name, _src_client in download_orchestrator.registry.all_plugins(): if _src_client is not None and hasattr(_src_client, 'set_shutdown_check'): try: _src_client.set_shutdown_check(lambda: IS_SHUTTING_DOWN) @@ -1993,9 +1993,9 @@ def _register_automation_handlers(): hybrid_order = config_manager.get('download_source.hybrid_order', ['hifi', 'youtube', 'soulseek']) soulseek_active = (dl_mode == 'soulseek' or (dl_mode == 'hybrid' and 'soulseek' in hybrid_order)) - # soulseek_client is a DownloadOrchestrator; the real client lives on - # .soulseek. Match the getattr pattern used at the other call sites. - slskd = getattr(soulseek_client, 'soulseek', None) if soulseek_client else None + # Reach the underlying SoulseekClient via the orchestrator's + # generic accessor. + slskd = download_orchestrator.client('soulseek') if download_orchestrator else None if not soulseek_active or not slskd or not slskd.base_url: _update_automation_progress(automation_id, log_line='Soulseek not active — skipped', log_type='skip') @@ -2005,7 +2005,7 @@ def _register_automation_handlers(): log_line='Auto-clear disabled in settings', log_type='skip') return {'status': 'skipped'} try: - success = run_async(soulseek_client.maintain_search_history_with_buffer( + success = run_async(download_orchestrator.maintain_search_history_with_buffer( keep_searches=50, trigger_threshold=200 )) if success: @@ -2041,7 +2041,7 @@ def _register_automation_handlers(): log_line='Skipped — downloads active', log_type='skip') return {'status': 'completed'} - run_async(soulseek_client.clear_all_completed_downloads()) + run_async(download_orchestrator.clear_all_completed_downloads()) if not has_post_processing: _sweep_empty_download_directories() _update_automation_progress(automation_id, @@ -2096,7 +2096,7 @@ def _register_automation_handlers(): log_line='Download queue: skipped (active batches)', log_type='skip') else: try: - run_async(soulseek_client.clear_all_completed_downloads()) + run_async(download_orchestrator.clear_all_completed_downloads()) steps.append('Download queue: cleared') _update_automation_progress(automation_id, log_line='Download queue: cleared', log_type='success') @@ -2154,7 +2154,7 @@ def _register_automation_handlers(): _update_automation_progress(automation_id, log_line='Search cleanup: disabled in settings', log_type='skip') else: - run_async(soulseek_client.maintain_search_history_with_buffer( + run_async(download_orchestrator.maintain_search_history_with_buffer( keep_searches=50, trigger_threshold=200 )) steps.append('Search history: cleaned') @@ -2286,7 +2286,7 @@ def _register_automation_handlers(): if automation_id: _update_automation_progress(automation_id, phase='Searching', log_line=f'Searching: {query}', log_type='info') - result = run_async(soulseek_client.search_and_download_best(query)) + result = run_async(download_orchestrator.search_and_download_best(query)) if result: if automation_id: _update_automation_progress(automation_id, @@ -2348,7 +2348,7 @@ try: app.register_blueprint(api_bp, url_prefix='/api/v1') app.soulsync = { 'spotify_client': spotify_client, - 'soulseek_client': soulseek_client, + 'download_orchestrator': download_orchestrator, 'tidal_client': tidal_client, 'matching_engine': matching_engine, 'config_manager': config_manager, @@ -2453,8 +2453,9 @@ def get_cached_transfer_data(): # First, get Soulseek downloads from API transfers_data = None - if not soulseek_known_down and soulseek_client and getattr(soulseek_client, 'soulseek', None) and soulseek_client.client("soulseek").base_url: - transfers_data = run_async(soulseek_client._make_request('GET', 'transfers/downloads')) + _slsk = download_orchestrator.client("soulseek") if download_orchestrator else None + if not soulseek_known_down and _slsk and _slsk.base_url: + transfers_data = run_async(download_orchestrator._make_request('GET', 'transfers/downloads')) if transfers_data: all_transfers = [] for user_data in transfers_data: @@ -2477,9 +2478,9 @@ def get_cached_transfer_data(): all_downloads = [] # Generic dispatch — engine returns active downloads # across every plugin in one call, no per-source iteration. - if soulseek_client and hasattr(soulseek_client, 'engine'): + if download_orchestrator and hasattr(download_orchestrator, 'engine'): try: - all_downloads = run_async(soulseek_client.engine.get_all_downloads()) + all_downloads = run_async(download_orchestrator.engine.get_all_downloads()) except Exception: pass for download in all_downloads: @@ -3052,7 +3053,7 @@ def _build_prepare_stream_deps(): return _streaming_prepare.PrepareStreamDeps( config_manager=config_manager, - soulseek_client=soulseek_client, + download_orchestrator=download_orchestrator, stream_lock=stream_lock, project_root=os.path.dirname(os.path.abspath(__file__)), docker_resolve_path=docker_resolve_path, @@ -3499,10 +3500,10 @@ def get_status(): if is_serverless: soulseek_status = True soulseek_response_time = 0 - elif soulseek_relevant and soulseek_client: + elif soulseek_relevant and download_orchestrator: soulseek_start = time.time() try: - soulseek_status = run_async(soulseek_client.check_connection()) + soulseek_status = run_async(download_orchestrator.check_connection()) except Exception: soulseek_status = False soulseek_response_time = (time.time() - soulseek_start) * 1000 @@ -3915,7 +3916,7 @@ def _build_system_stats(): if soulseek_active and not soulseek_known_down: try: - transfers_data = run_async(soulseek_client._make_request('GET', 'transfers/downloads')) + transfers_data = run_async(download_orchestrator._make_request('GET', 'transfers/downloads')) if transfers_data: for user_data in transfers_data: if 'directories' in user_data: @@ -4141,11 +4142,11 @@ def handle_settings(): if navidrome_client: navidrome_client.reload_config() # Reload orchestrator settings (download source mode, hybrid_primary, etc.) - if soulseek_client: - soulseek_client.reload_settings() + if download_orchestrator: + download_orchestrator.reload_settings() # Reload YouTube client settings (rate limiting, cookies) - if hasattr(soulseek_client, 'youtube'): - soulseek_client.client("youtube").reload_settings() + if hasattr(download_orchestrator, 'youtube'): + download_orchestrator.client("youtube").reload_settings() # FIX: Re-instantiate the global tidal_client to pick up new settings try: tidal_client = TidalClient() @@ -4177,7 +4178,7 @@ def handle_settings(): data = dict(config_manager.config_data) # Include which download sources are configured so the UI can auto-disable unconfigured ones try: - data['_source_status'] = soulseek_client.get_source_status() + data['_source_status'] = download_orchestrator.get_source_status() except Exception: pass return jsonify(data) @@ -6728,7 +6729,7 @@ def _build_search_deps(): spotify_client=spotify_client, hydrabase_client=hydrabase_client, hydrabase_worker=hydrabase_worker, - soulseek_client=soulseek_client, + download_orchestrator=download_orchestrator, fix_artist_image_url=fix_artist_image_url, is_hydrabase_active=_is_hydrabase_active, get_metadata_fallback_source=_get_metadata_fallback_source, @@ -6754,7 +6755,7 @@ def search_music(): add_activity_item("", "Search Started", f"'{query}'", "Now") try: - results = _search_basic.run_basic_soulseek_search(query, soulseek_client, run_async) + results = _search_basic.run_basic_soulseek_search(query, download_orchestrator, run_async) add_activity_item("", "Search Complete", f"'{query}' - {len(results)} results", "Now") return jsonify({"results": results}) except Exception as e: @@ -6809,7 +6810,7 @@ def enhanced_search_source(source_name): When the requested source's client isn't available (Spotify unauthed, Discogs missing token, Hydrabase disconnected, MusicBrainz import - failure, soulseek_client.client("youtube") missing), returns plain JSON + failure, download_orchestrator.client("youtube") missing), returns plain JSON `{"artists":[],"albums":[],"tracks":[],"available":false}` to match the original endpoint contract. """ @@ -6894,7 +6895,7 @@ def stream_enhanced_search_track(): album_name=album_name, duration_ms=duration_ms, config_manager=config_manager, - soulseek_client=soulseek_client, + download_orchestrator=download_orchestrator, matching_engine=matching_engine, run_async=run_async, ) @@ -7033,7 +7034,7 @@ def download_music_video(): def _progress(pct): _music_video_downloads[video_id]['progress'] = round(pct, 1) - final_path = soulseek_client.client("youtube").download_music_video(video_url, output_path, progress_callback=_progress) + final_path = download_orchestrator.client("youtube").download_music_video(video_url, output_path, progress_callback=_progress) if final_path and os.path.exists(final_path): _music_video_downloads[video_id]['status'] = 'completed' @@ -7092,7 +7093,7 @@ def start_download(): filename = track_data.get('filename') file_size = track_data.get('size', 0) - download_id = run_async(soulseek_client.download( + download_id = run_async(download_orchestrator.download( username, filename, file_size @@ -7141,7 +7142,7 @@ def start_download(): if not username or not filename: return jsonify({"error": "Missing username or filename."}), 400 - download_id = run_async(soulseek_client.download(username, filename, file_size)) + download_id = run_async(download_orchestrator.download(username, filename, file_size)) logger.info(f"Download ID returned: {download_id}") if download_id: @@ -7337,7 +7338,7 @@ def get_download_status(): A robust status checker that correctly finds completed files by searching the entire download directory with fuzzy matching, mirroring the logic from downloads.py. """ - if not soulseek_client: + if not download_orchestrator: return jsonify({"transfers": []}) try: @@ -7346,7 +7347,7 @@ def get_download_status(): soulseek_known_down = not _status_cache.get('soulseek', {}).get('connected', True) transfers_data = None if not soulseek_known_down: - transfers_data = run_async(soulseek_client._make_request('GET', 'transfers/downloads')) + transfers_data = run_async(download_orchestrator._make_request('GET', 'transfers/downloads')) # Don't return early if no Soulseek transfers - YouTube/Tidal downloads need to be checked too! all_transfers = [] @@ -7414,7 +7415,7 @@ def get_download_status(): transfer_id = file_info.get('id') if transfer_id: try: - run_async(soulseek_client.cancel_download(str(transfer_id), username, remove=True)) + run_async(download_orchestrator.cancel_download(str(transfer_id), username, remove=True)) except Exception: pass _orphaned_download_keys.discard(context_key) @@ -7526,7 +7527,7 @@ def get_download_status(): # Also include YouTube/Tidal downloads in the response try: - all_streaming_downloads = run_async(soulseek_client.get_all_downloads()) + all_streaming_downloads = run_async(download_orchestrator.get_all_downloads()) for download in all_streaming_downloads: if download.username in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr', 'soundcloud'): @@ -7658,7 +7659,7 @@ def cancel_download(): return jsonify({"success": False, "error": "Missing download_id or username."}), 400 try: - success = _downloads_cancel.cancel_single_download(soulseek_client, run_async, download_id, username) + success = _downloads_cancel.cancel_single_download(download_orchestrator, run_async, download_id, username) if success: return jsonify({"success": True, "message": "Download cancelled."}) return jsonify({"success": False, "error": "Failed to cancel download via slskd."}), 500 @@ -7672,7 +7673,7 @@ def cancel_all_downloads(): """Cancel all active downloads from slskd, then clear completed ones.""" try: success, msg = _downloads_cancel.cancel_all_active( - soulseek_client, run_async, _sweep_empty_download_directories, + download_orchestrator, run_async, _sweep_empty_download_directories, ) if success: return jsonify({"success": True, "message": msg}) @@ -7687,7 +7688,7 @@ def clear_finished_downloads(): """Clear all terminal (completed, cancelled, failed) downloads from slskd.""" try: success = _downloads_cancel.clear_finished_active( - soulseek_client, run_async, _sweep_empty_download_directories, + download_orchestrator, run_async, _sweep_empty_download_directories, ) if success: return jsonify({"success": True, "message": "Finished downloads cleared."}) @@ -8074,7 +8075,7 @@ def clear_all_searches(): Clear all searches from slskd search history. """ try: - success = run_async(soulseek_client.clear_all_searches()) + success = run_async(download_orchestrator.clear_all_searches()) if success: add_activity_item("", "Search Cleanup", "All search history cleared manually", "Now") return jsonify({"success": True, "message": "All searches cleared."}) @@ -8094,7 +8095,7 @@ def maintain_search_history(): keep_searches = data.get('keep_searches', 50) trigger_threshold = data.get('trigger_threshold', 200) - success = run_async(soulseek_client.maintain_search_history_with_buffer( + success = run_async(download_orchestrator.maintain_search_history_with_buffer( keep_searches=keep_searches, trigger_threshold=trigger_threshold )) if success: @@ -11674,7 +11675,7 @@ def redownload_search_sources(track_id): # Get all available download source clients download_clients = {} try: - orch = soulseek_client # The download orchestrator + orch = download_orchestrator # The download orchestrator if hasattr(orch, 'soulseek') and orch.soulseek: if not (hasattr(orch.soulseek, 'is_configured') and not orch.soulseek.is_configured()): download_clients['soulseek'] = orch.soulseek @@ -11698,7 +11699,7 @@ def redownload_search_sources(track_id): if not download_clients: # Fallback: use orchestrator directly - download_clients = {'default': soulseek_client} + download_clients = {'default': download_orchestrator} logger.info(f"[Redownload] Streaming search across {len(download_clients)} sources: {list(download_clients.keys())}") @@ -12660,7 +12661,7 @@ def _start_enhanced_album_download(enhanced_tracks, unmatched_tracks, spotify_ar continue # Start download - download_id = run_async(soulseek_client.download(username, filename, size)) + download_id = run_async(download_orchestrator.download(username, filename, size)) if download_id: context_key = _make_context_key(username, filename) @@ -12708,7 +12709,7 @@ def _start_enhanced_album_download(enhanced_tracks, unmatched_tracks, spotify_ar if not username or not filename: continue - download_id = run_async(soulseek_client.download(username, filename, size)) + download_id = run_async(download_orchestrator.download(username, filename, size)) if download_id: context_key = _make_context_key(username, filename) @@ -12816,7 +12817,7 @@ def _start_album_download_tasks(album_result, spotify_artist, spotify_album): 'disc_number': corrected_meta.get('disc_number', 1) } - download_id = run_async(soulseek_client.download(username, filename, size)) + download_id = run_async(download_orchestrator.download(username, filename, size)) if download_id: context_key = _make_context_key(username, filename) @@ -12883,7 +12884,7 @@ def start_matched_download(): if not username or not filename: return jsonify({"success": False, "error": "Missing username or filename"}), 400 - download_id = run_async(soulseek_client.download(username, filename, size)) + download_id = run_async(download_orchestrator.download(username, filename, size)) if download_id: context_key = _make_context_key(username, filename) @@ -12943,7 +12944,7 @@ def start_matched_download(): download_payload['title'] = parsed_meta.get('title') or download_payload.get('title') download_payload['artist'] = parsed_meta.get('artist') or download_payload.get('artist') - download_id = run_async(soulseek_client.download(username, filename, size)) + download_id = run_async(download_orchestrator.download(username, filename, size)) if download_id: context_key = _make_context_key(username, filename) @@ -12972,7 +12973,7 @@ def start_matched_download(): def _parse_filename_metadata(filename: str) -> dict: """ - A direct port of the metadata parsing logic from the GUI's soulseek_client.py. + A direct port of the metadata parsing logic from the GUI's download_orchestrator.py. This is the crucial missing step that cleans filenames BEFORE Spotify matching. """ return parse_filename_metadata(filename) @@ -14148,7 +14149,7 @@ def _enhance_file_metadata(file_path: str, context: dict, artist: dict, album_in genius_worker=genius_worker, spotify_enrichment_worker=spotify_enrichment_worker, itunes_enrichment_worker=itunes_enrichment_worker, - hifi_client=soulseek_client.client("hifi") if soulseek_client else None, + hifi_client=download_orchestrator.client("hifi") if download_orchestrator else None, ), ) @@ -14253,7 +14254,7 @@ def _post_process_matched_download_with_verification(context_key, context, file_ genius_worker=genius_worker, spotify_enrichment_worker=spotify_enrichment_worker, itunes_enrichment_worker=itunes_enrichment_worker, - hifi_client=soulseek_client.client("hifi") if soulseek_client else None, + hifi_client=download_orchestrator.client("hifi") if download_orchestrator else None, ), ) @@ -14377,7 +14378,7 @@ def _post_process_matched_download(context_key, context, file_path): genius_worker=genius_worker, spotify_enrichment_worker=spotify_enrichment_worker, itunes_enrichment_worker=itunes_enrichment_worker, - hifi_client=soulseek_client.client("hifi") if soulseek_client else None, + hifi_client=download_orchestrator.client("hifi") if download_orchestrator else None, ), ) @@ -16821,7 +16822,7 @@ def _build_master_deps(): return _downloads_master.MasterDeps( config_manager=config_manager, - soulseek_client=soulseek_client, + download_orchestrator=download_orchestrator, run_async=run_async, mb_worker=mb_worker, mb_release_cache=mb_release_cache, @@ -16859,7 +16860,7 @@ def _build_post_processing_deps(): """Build the PostProcessDeps bundle from web_server.py globals on each call.""" return _downloads_post_processing.PostProcessDeps( config_manager=config_manager, - soulseek_client=soulseek_client, + download_orchestrator=download_orchestrator, run_async=run_async, docker_resolve_path=docker_resolve_path, extract_filename=extract_filename, @@ -16886,7 +16887,7 @@ from core.downloads import task_worker as _downloads_task_worker def _build_task_worker_deps(): """Build TaskWorkerDeps bundle from web_server.py globals on each call.""" return _downloads_task_worker.TaskWorkerDeps( - soulseek_client=soulseek_client, + download_orchestrator=download_orchestrator, matching_engine=matching_engine, run_async=run_async, try_source_reuse=_try_source_reuse, @@ -16912,7 +16913,7 @@ from core.downloads import candidates as _downloads_candidates def _build_candidates_deps(): """Build the CandidatesDeps bundle from web_server.py globals on each call.""" return _downloads_candidates.CandidatesDeps( - soulseek_client=soulseek_client, + download_orchestrator=download_orchestrator, spotify_client=spotify_client, run_async=run_async, get_database=get_database, @@ -17077,7 +17078,7 @@ def _try_source_reuse(task_id, batch_id, track): # Sort by confidence, filter by quality preference candidates.sort(key=lambda c: c.confidence, reverse=True) _sr.info(f"Found {len(candidates)} candidates above 0.70, best={candidates[0].confidence:.3f} ({candidates[0].filename})") - slsk = soulseek_client.client("soulseek") if hasattr(soulseek_client, 'soulseek') else soulseek_client + slsk = download_orchestrator.client("soulseek") if hasattr(download_orchestrator, 'soulseek') else download_orchestrator filtered = slsk.filter_results_by_quality_preference(candidates) if not filtered: _sr.info(f"Quality filter rejected all candidates for task {task_id}") @@ -17156,8 +17157,8 @@ def _store_batch_source(batch_id, username, filename): return try: - # Access SoulseekClient directly (soulseek_client is DownloadOrchestrator) - slsk = soulseek_client.client("soulseek") if hasattr(soulseek_client, 'soulseek') else soulseek_client + # Access SoulseekClient directly (download_orchestrator is DownloadOrchestrator) + slsk = download_orchestrator.client("soulseek") if hasattr(download_orchestrator, 'soulseek') else download_orchestrator _sr.info(f"Browsing {username}:{folder_path}...") files = run_async(slsk.browse_user_directory(username, folder_path)) if not files: @@ -17488,7 +17489,7 @@ def cancel_download_task(): if download_id and username: try: # This is an async call, so we run it and wait - run_async(soulseek_client.cancel_download(download_id, username, remove=True)) + run_async(download_orchestrator.cancel_download(download_id, username, remove=True)) logger.warning(f"Successfully cancelled Soulseek download {download_id} for task {task_id}") except Exception as e: logger.error(f"Failed to cancel download on slskd, but worker already moved on: {e}") @@ -17733,14 +17734,14 @@ def cancel_task_v2(): # username: youtube/tidal/qobuz/hifi/deezer_dl/lidarr go to # their streaming clients, anything else goes to Soulseek. # - # Replaces an older block that assumed soulseek_client was a + # Replaces an older block that assumed download_orchestrator was a # raw SoulseekClient and accessed .base_url / ._make_request # directly — crashed with AttributeError on the orchestrator # and silently left streaming downloads running in background. try: logger.info(f"[Atomic Cancel] Dispatching cancel to orchestrator: username={username} download_id={download_id}") cancel_success = run_async( - soulseek_client.cancel_download(download_id, username, remove=True) + download_orchestrator.cancel_download(download_id, username, remove=True) ) if cancel_success: logger.info(f"[Atomic Cancel] Orchestrator cancelled download: {download_id}") @@ -19725,7 +19726,7 @@ def get_discover_album(source, album_id): def hifi_status(): """Check if HiFi API instances are reachable.""" try: - hifi = soulseek_client.client("hifi") + hifi = download_orchestrator.client("hifi") available = hifi.is_available() version = hifi.get_version() if available else None return jsonify({ @@ -19749,8 +19750,8 @@ def soundcloud_status(): """ try: sc = None - if soulseek_client and hasattr(soulseek_client, 'soundcloud'): - sc = soulseek_client.client("soundcloud") + if download_orchestrator and hasattr(download_orchestrator, 'soundcloud'): + sc = download_orchestrator.client("soundcloud") if not sc: return jsonify({ "available": False, @@ -19778,7 +19779,7 @@ def hifi_instances(): """Check availability of all HiFi API instances.""" import requests as req try: - hifi = soulseek_client.client("hifi") + hifi = download_orchestrator.client("hifi") instances = list(hifi._instances) results = [] for url in instances: @@ -19834,8 +19835,8 @@ def hifi_add_instance(): if not added: return jsonify({'success': False, 'error': 'Instance already exists'}), 400 # Reload the HiFi client - if soulseek_client: - soulseek_client.reload_instances('hifi') + if download_orchestrator: + download_orchestrator.reload_instances('hifi') return jsonify({'success': True, 'url': url}) except Exception as e: logger.error(f"Error adding HiFi instance: {e}") @@ -19856,8 +19857,8 @@ def hifi_remove_instance(): if not removed: return jsonify({'success': False, 'error': 'Instance not found'}), 404 # Reload the HiFi client - if soulseek_client: - soulseek_client.reload_instances('hifi') + if download_orchestrator: + download_orchestrator.reload_instances('hifi') return jsonify({'success': True, 'url': url}) except Exception as e: logger.error(f"Error removing HiFi instance: {e}") @@ -19877,8 +19878,8 @@ def hifi_toggle_instance(): from database.music_database import get_database db = get_database() db.toggle_hifi_instance(url, enabled) - if soulseek_client: - soulseek_client.reload_instances('hifi') + if download_orchestrator: + download_orchestrator.reload_instances('hifi') return jsonify({'success': True}) except Exception as e: logger.error(f"Error toggling HiFi instance: {e}") @@ -19899,8 +19900,8 @@ def hifi_reorder_instances(): if not db.reorder_hifi_instances(urls): return jsonify({'success': False, 'error': 'One or more URLs not found'}), 400 # Reload the HiFi client - if soulseek_client: - soulseek_client.reload_instances('hifi') + if download_orchestrator: + download_orchestrator.reload_instances('hifi') return jsonify({'success': True}) except Exception as e: logger.error(f"Error reordering HiFi instances: {e}") @@ -20052,9 +20053,9 @@ def deezer_download_test_download(): def _get_tidal_download_client(): """Get Tidal download client from the orchestrator, with helpful error if unavailable.""" - if not soulseek_client: + if not download_orchestrator: raise RuntimeError("Download orchestrator not initialized — check startup logs for errors") - tidal = soulseek_client.client("tidal") if hasattr(soulseek_client, 'client') else None + tidal = download_orchestrator.client("tidal") if hasattr(download_orchestrator, 'client') else None if not tidal: raise RuntimeError("Tidal download client not available — ensure tidalapi is installed") return tidal @@ -20126,7 +20127,7 @@ def qobuz_auth_login(): if not email or not password: return jsonify({"success": False, "error": "Email and password required"}), 400 - qobuz = soulseek_client.client("qobuz") + qobuz = download_orchestrator.client("qobuz") result = qobuz.login(email, password) if result['status'] == 'success': @@ -20149,7 +20150,7 @@ def qobuz_auth_token(): if not token: return jsonify({"success": False, "error": "Auth token required"}), 400 - qobuz = soulseek_client.client("qobuz") + qobuz = download_orchestrator.client("qobuz") result = qobuz.login_with_token(token) if result['status'] == 'success': @@ -20166,7 +20167,7 @@ def qobuz_auth_token(): def qobuz_auth_status(): """Check if Qobuz client is authenticated.""" try: - qobuz = soulseek_client.client("qobuz") + qobuz = download_orchestrator.client("qobuz") authenticated = qobuz.is_authenticated() user_info = {} if authenticated and qobuz.user_info: @@ -20183,7 +20184,7 @@ def qobuz_auth_status(): def qobuz_auth_logout(): """Logout from Qobuz.""" try: - soulseek_client.client("qobuz").logout() + download_orchestrator.client("qobuz").logout() _sync_qobuz_credentials_to_worker() return jsonify({"success": True}) except Exception as e: @@ -21125,7 +21126,7 @@ def _get_metadata_fallback_client(): def get_deezer_arl_status(): """Check if Deezer ARL is configured and authenticated.""" try: - deezer_dl = soulseek_client.client("deezer_dl") if soulseek_client and hasattr(soulseek_client, 'client') else None + deezer_dl = download_orchestrator.client("deezer_dl") if download_orchestrator and hasattr(download_orchestrator, 'client') else None if deezer_dl and deezer_dl.is_authenticated(): user_data = deezer_dl._user_data or {} return jsonify({ @@ -21142,7 +21143,7 @@ def get_deezer_arl_status(): def get_deezer_arl_playlists(): """Fetch user playlists via Deezer ARL authentication (like /api/spotify/playlists).""" try: - deezer_dl = soulseek_client.client("deezer_dl") if soulseek_client and hasattr(soulseek_client, 'client') else None + deezer_dl = download_orchestrator.client("deezer_dl") if download_orchestrator and hasattr(download_orchestrator, 'client') else None if not deezer_dl or not deezer_dl.is_authenticated(): return jsonify({'error': 'Deezer ARL not authenticated. Configure your ARL token in Settings > Downloads.'}), 401 @@ -21170,7 +21171,7 @@ def get_deezer_arl_playlists(): def get_deezer_arl_playlist_tracks(playlist_id): """Fetch full playlist with tracks via ARL (like /api/spotify/playlist/).""" try: - deezer_dl = soulseek_client.client("deezer_dl") if soulseek_client and hasattr(soulseek_client, 'client') else None + deezer_dl = download_orchestrator.client("deezer_dl") if download_orchestrator and hasattr(download_orchestrator, 'client') else None if not deezer_dl or not deezer_dl.is_authenticated(): return jsonify({'error': 'Deezer ARL not authenticated.'}), 401 @@ -27362,8 +27363,8 @@ def get_your_artists_sources(): try: deezer_cl = _get_deezer_client() deezer_oauth = deezer_cl and hasattr(deezer_cl, 'is_user_authenticated') and deezer_cl.is_user_authenticated() - deezer_arl = (hasattr(soulseek_client, 'deezer_dl') and soulseek_client.client("deezer_dl") - and soulseek_client.client("deezer_dl").is_authenticated()) + deezer_arl = (hasattr(download_orchestrator, 'deezer_dl') and download_orchestrator.client("deezer_dl") + and download_orchestrator.client("deezer_dl").is_authenticated()) if deezer_oauth or deezer_arl: connected.append('deezer') except Exception: @@ -27484,10 +27485,10 @@ def _fetch_and_match_liked_artists(profile_id: int): if deezer_cl and hasattr(deezer_cl, 'is_user_authenticated') and deezer_cl.is_user_authenticated(): logger.info("[Your Artists] Fetching favorite artists from Deezer (OAuth)...") artists = deezer_cl.get_user_favorite_artists(limit=200) - elif (hasattr(soulseek_client, 'deezer_dl') and soulseek_client.client("deezer_dl") - and soulseek_client.client("deezer_dl").is_authenticated()): + elif (hasattr(download_orchestrator, 'deezer_dl') and download_orchestrator.client("deezer_dl") + and download_orchestrator.client("deezer_dl").is_authenticated()): logger.info("[Your Artists] Fetching favorite artists from Deezer (ARL)...") - artists = soulseek_client.client("deezer_dl").get_user_favorite_artists(limit=200) + artists = download_orchestrator.client("deezer_dl").get_user_favorite_artists(limit=200) for a in artists: database.upsert_liked_artist( artist_name=a['name'], source_service='deezer', @@ -27634,8 +27635,8 @@ def get_your_albums_sources(): try: deezer_cl = _get_deezer_client() deezer_oauth = deezer_cl and hasattr(deezer_cl, 'is_user_authenticated') and deezer_cl.is_user_authenticated() - deezer_arl = (hasattr(soulseek_client, 'deezer_dl') and soulseek_client.client("deezer_dl") - and soulseek_client.client("deezer_dl").is_authenticated()) + deezer_arl = (hasattr(download_orchestrator, 'deezer_dl') and download_orchestrator.client("deezer_dl") + and download_orchestrator.client("deezer_dl").is_authenticated()) if deezer_oauth or deezer_arl: connected.append('deezer') except Exception: @@ -27742,10 +27743,10 @@ def _fetch_liked_albums(profile_id: int): if deezer_cl and hasattr(deezer_cl, 'is_user_authenticated') and deezer_cl.is_user_authenticated(): logger.info("[Your Albums] Fetching favorite albums from Deezer (OAuth)...") albums = deezer_cl.get_user_favorite_albums(limit=500) - elif (hasattr(soulseek_client, 'deezer_dl') and soulseek_client.client("deezer_dl") - and soulseek_client.client("deezer_dl").is_authenticated()): + elif (hasattr(download_orchestrator, 'deezer_dl') and download_orchestrator.client("deezer_dl") + and download_orchestrator.client("deezer_dl").is_authenticated()): logger.info("[Your Albums] Fetching favorite albums from Deezer (ARL)...") - albums = soulseek_client.client("deezer_dl").get_user_favorite_albums(limit=500) + albums = download_orchestrator.client("deezer_dl").get_user_favorite_albums(limit=500) for a in albums: database.upsert_liked_album( album_name=a['album_name'], artist_name=a['artist_name'], @@ -32666,7 +32667,7 @@ register_runtime_clients( ) _init_connection_test( - soulseek_client_obj=soulseek_client, + download_orchestrator_obj=download_orchestrator, qobuz_worker=qobuz_enrichment_worker, hydrabase_client_obj=hydrabase_client, docker_resolve_url_fn=docker_resolve_url, @@ -32679,12 +32680,12 @@ _init_discover_hero(get_metadata_fallback_client_fn=_get_metadata_fallback_clien _init_download_validation( matching_engine_obj=matching_engine, - soulseek_client_obj=soulseek_client, + download_orchestrator_obj=download_orchestrator, ) _init_wishlist_failed( engine=automation_engine, - soulseek_client_obj=soulseek_client, + download_orchestrator_obj=download_orchestrator, sweep_fn=_sweep_empty_download_directories, ) @@ -32703,7 +32704,7 @@ _init_debug_info( sync_states_dict=sync_states, youtube_playlist_states_dict=youtube_playlist_states, tidal_discovery_states_dict=tidal_discovery_states, - soulseek_client_obj=soulseek_client, + download_orchestrator_obj=download_orchestrator, log_path=_log_path, log_dir=_log_dir, flask_app=app, @@ -32722,7 +32723,7 @@ _init_download_monitor( start_next_batch_of_downloads=_start_next_batch_of_downloads, orphaned_download_keys=_orphaned_download_keys, missing_download_executor_obj=missing_download_executor, - soulseek_client_obj=soulseek_client, + download_orchestrator_obj=download_orchestrator, ) # --- Hydrabase Auto-Reconnect --- diff --git a/webui/static/helper.js b/webui/static/helper.js index 9e0f0beb..f148ebff 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3432,6 +3432,7 @@ const WHATS_NEW = { '2.4.2': [ // --- post-2.4.1 dev work — entries hidden by _getLatestWhatsNewVersion until the build version bumps --- { date: 'Unreleased — 2.4.2 dev cycle' }, + { title: 'Internal: Rename `soulseek_client` Global → `download_orchestrator`', desc: 'internal — followup cleanup. the global handle in web_server.py was named `soulseek_client` for historical reasons (the orchestrator was originally just the soulseek client and grew downstream sources around it), but the type has long been `DownloadOrchestrator` not `SoulseekClient`. renamed the global + every parameter/attribute that carried the legacy name across web_server.py, api/, core/downloads/*, core/search/*, core/streaming/*, services/sync_service.py, and the test fixtures (`MasterDeps.soulseek_client` → `download_orchestrator`, `init(soulseek_client_obj)` → `init(download_orchestrator_obj)`, etc). module path `core.soulseek_client` and class `SoulseekClient` (the actual soulseek-only client) are unchanged — only the orchestrator handle renamed. ~250 references touched, suite green.' }, { title: 'Internal: Drop Backward-Compat Per-Source Attrs', desc: 'internal — followup to cin\'s download engine review. removed the `orchestrator.soulseek` / `.youtube` / `.tidal` / `.qobuz` / `.hifi` / `.deezer_dl` / `.lidarr` / `.soundcloud` attribute aliases that were preserved for backward compat. external callers (core/downloads/, core/search/, web_server.py) all migrated to the generic `orchestrator.client(\'\')` accessor — alias-aware (legacy `deezer_dl` resolves to canonical `deezer`), single source of truth via the registry. the orchestrator\'s own internal `self.soulseek` / `self.deezer_dl` reaches also routed through `client()` so the only place that knows about per-source identity is the registry. test fakes updated to expose `client(name)` instead of stuffing attributes; conformance test pinned to the new accessor contract. zero behavior change — just cleaner shape.' }, { title: 'Internal: Download Engine Review Followup', desc: 'internal — three correctness fixes on top of the download engine refactor, all flagged in cin\'s pr review. (1) `engine.cancel_download(source_hint=\'deezer_dl\')` was silently routing deezer cancels to soulseek because the legacy alias never made it to the engine\'s plugin map — only the registry knew about it. fix: aliases now flow through `register_plugin` and `get_plugin` / `cancel_download` resolve them to the canonical name. (2) `_resolve_source_chain` filtered hybrid_order against canonical registry names only, so any user with `deezer_dl` in their config quietly dropped deezer from hybrid mode. fix: orchestrator normalizes through `registry.get_spec()` first. (3) the worker\'s terminal write was a read-then-write split — a cancel landing between the snapshot and the update could be overwritten back to errored / completed. fix: new atomic `update_record_unless_state` on the engine holds `state_lock` across the check + write; both `_mark_terminal` AND the success path use it now. also added generic `client(name)` / `configured_clients()` / `reload_instances(name?)` accessors on the orchestrator + a `get/set_download_orchestrator()` singleton matching cin\'s `get_metadata_engine()` shape, and migrated 30 external `soulseek_client.` reaches in web_server.py to `client("")`. 18 new tests pin every fix.' }, { title: 'Internal: Typed Metadata Foundation', desc: 'internal — first step of a multi-pr migration to give the metadata pipeline a real contract. the codebase historically grew duck-typed extractors (`_extract_lookup_value(album_data, "id", "album_id", "collectionId", "release_id", default=...)`) at every consumer site because each provider returns its own response shape. ~150 of those across the codebase. new `core/metadata/types.py` defines canonical typed `Album` / `Track` / `Artist` dataclasses with strict required fields. per-source classmethod converters (from_spotify_dict, from_itunes_dict, from_deezer_dict, from_discogs_dict, from_musicbrainz_dict, from_hydrabase_dict) are the SINGLE place that knows each provider\'s wire shape. zero behavior changes in this pr — pure additive foundation. follow-up prs migrate consumers one at a time. full migration plan documented at docs/metadata-types-migration.md.', page: 'library' },