Cin-6: Rename soulseek_client global → download_orchestrator

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.
This commit is contained in:
Broque Thomas 2026-05-04 23:23:32 -07:00
parent 7519c3d50c
commit 61ba3a15de
28 changed files with 212 additions and 212 deletions

View file

@ -121,7 +121,7 @@ def register_routes(bp):
try: try:
from utils.async_helpers import run_async from utils.async_helpers import run_async
soulseek = current_app.soulsync.get("soulseek_client") soulseek = current_app.soulsync.get("download_orchestrator")
if not soulseek: if not soulseek:
return api_error("NOT_AVAILABLE", "Soulseek client not configured.", 503) 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.""" """Cancel all active downloads and clear completed ones."""
try: try:
from utils.async_helpers import run_async from utils.async_helpers import run_async
soulseek = current_app.soulsync.get("soulseek_client") soulseek = current_app.soulsync.get("download_orchestrator")
if not soulseek: if not soulseek:
return api_error("NOT_AVAILABLE", "Soulseek client not configured.", 503) return api_error("NOT_AVAILABLE", "Soulseek client not configured.", 503)

View file

@ -104,7 +104,7 @@ def _run_search_and_download(request_id, query, notify_url):
if request_id in _pending_requests: if request_id in _pending_requests:
_pending_requests[request_id]['status'] = 'searching' _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: if not soulseek:
with _requests_lock: with _requests_lock:
if request_id in _pending_requests: if request_id in _pending_requests:

View file

@ -26,7 +26,7 @@ def register_routes(bp):
spotify = ctx.get("spotify_client") spotify = ctx.get("spotify_client")
spotify_ok = bool(spotify and spotify.is_authenticated()) spotify_ok = bool(spotify and spotify.is_authenticated())
soulseek = ctx.get("soulseek_client") soulseek = ctx.get("download_orchestrator")
soulseek_ok = bool(soulseek) soulseek_ok = bool(soulseek)
hydrabase = ctx.get("hydrabase_client") hydrabase = ctx.get("hydrabase_client")

View file

@ -1,6 +1,6 @@
"""Service connection test — lifted from web_server.py. """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 qobuz_enrichment_worker, hydrabase_client, docker_resolve_url, and
docker_resolve_path are injected at runtime because they live in docker_resolve_path are injected at runtime because they live in
web_server.py and are constructed there. web_server.py and are constructed there.
@ -27,7 +27,7 @@ def _get_metadata_fallback_source():
# Injected at runtime via init(). # Injected at runtime via init().
soulseek_client = None download_orchestrator = None
qobuz_enrichment_worker = None qobuz_enrichment_worker = None
hydrabase_client = None hydrabase_client = None
docker_resolve_url = None docker_resolve_url = None
@ -35,16 +35,16 @@ docker_resolve_path = None
def init( def init(
soulseek_client_obj, download_orchestrator_obj,
qobuz_worker, qobuz_worker,
hydrabase_client_obj, hydrabase_client_obj,
docker_resolve_url_fn, docker_resolve_url_fn,
docker_resolve_path_fn, docker_resolve_path_fn,
): ):
"""Bind web_server-side helpers/globals so the lifted body can resolve them.""" """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 global docker_resolve_url, docker_resolve_path
soulseek_client = soulseek_client_obj download_orchestrator = download_orchestrator_obj
qobuz_enrichment_worker = qobuz_worker qobuz_enrichment_worker = qobuz_worker
hydrabase_client = hydrabase_client_obj hydrabase_client = hydrabase_client_obj
docker_resolve_url = docker_resolve_url_fn docker_resolve_url = docker_resolve_url_fn
@ -177,13 +177,13 @@ def run_service_test(service, test_config):
else: else:
return False, f"Output folder not found: {transfer_path}" return False, f"Output folder not found: {transfer_path}"
elif service == "soulseek": 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." return False, "Download orchestrator failed to initialize. Check server logs for startup errors."
# Test the orchestrator's configured download source (not just Soulseek) # Test the orchestrator's configured download source (not just Soulseek)
download_mode = config_manager.get('download_source.mode', 'hybrid') 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 # Success message based on active mode
mode_messages = { mode_messages = {
'soulseek': "Successfully connected to Soulseek network via slskd.", 'soulseek': "Successfully connected to Soulseek network via slskd.",

View file

@ -64,7 +64,7 @@ download_batches = None
sync_states = None sync_states = None
youtube_playlist_states = None youtube_playlist_states = None
tidal_discovery_states = None tidal_discovery_states = None
soulseek_client = None download_orchestrator = None
_log_path = None _log_path = None
_log_dir = None _log_dir = None
app = None app = None
@ -80,7 +80,7 @@ def init(
sync_states_dict, sync_states_dict,
youtube_playlist_states_dict, youtube_playlist_states_dict,
tidal_discovery_states_dict, tidal_discovery_states_dict,
soulseek_client_obj, download_orchestrator_obj,
log_path, log_path,
log_dir, log_dir,
flask_app, flask_app,
@ -90,7 +90,7 @@ def init(
"""Bind shared state/helpers from web_server.""" """Bind shared state/helpers from web_server."""
global SOULSYNC_VERSION, _DIRECT_RUN, _status_cache, qobuz_enrichment_worker global SOULSYNC_VERSION, _DIRECT_RUN, _status_cache, qobuz_enrichment_worker
global download_batches, sync_states, youtube_playlist_states 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 global app, get_database, _get_tidal_client
SOULSYNC_VERSION = soulsync_version SOULSYNC_VERSION = soulsync_version
_DIRECT_RUN = direct_run _DIRECT_RUN = direct_run
@ -100,7 +100,7 @@ def init(
sync_states = sync_states_dict sync_states = sync_states_dict
youtube_playlist_states = youtube_playlist_states_dict youtube_playlist_states = youtube_playlist_states_dict
tidal_discovery_states = tidal_discovery_states_dict tidal_discovery_states = tidal_discovery_states_dict
soulseek_client = soulseek_client_obj download_orchestrator = download_orchestrator_obj
_log_path = log_path _log_path = log_path
_log_dir = log_dir _log_dir = log_dir
app = flask_app app = flask_app
@ -292,9 +292,9 @@ def get_debug_info():
# Download client init failures # Download client init failures
info['download_client_failures'] = [] info['download_client_failures'] = []
if soulseek_client and hasattr(soulseek_client, '_init_failures'): if download_orchestrator and hasattr(download_orchestrator, '_init_failures'):
info['download_client_failures'] = soulseek_client._init_failures info['download_client_failures'] = download_orchestrator._init_failures
elif not soulseek_client: elif not download_orchestrator:
info['download_client_failures'] = ['ALL (orchestrator failed to initialize)'] info['download_client_failures'] = ['ALL (orchestrator failed to initialize)']
# API rate monitor — current calls/min, 24h totals, peaks, rate limit events # API rate monitor — current calls/min, 24h totals, peaks, rate limit events

View file

@ -547,10 +547,8 @@ class DownloadOrchestrator:
# Singleton accessor — mirrors Cin's metadata engine pattern # Singleton accessor — mirrors Cin's metadata engine pattern
# (``get_metadata_engine()``). Callers that don't need a custom # (``get_metadata_engine()``). Callers that don't need a custom
# registry use this instead of instantiating DownloadOrchestrator # registry use this instead of instantiating DownloadOrchestrator
# directly. Currently web_server.py constructs the singleton at # directly. web_server.py constructs the singleton at startup and
# startup and exposes it via the legacy ``soulseek_client`` global; # exposes it via the ``download_orchestrator`` global.
# this factory exists for new callers + future migration of that
# global to a more honestly-named ``download_orchestrator``.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_default_orchestrator: Optional['DownloadOrchestrator'] = None _default_orchestrator: Optional['DownloadOrchestrator'] = None

View file

@ -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: download_id: str, username: str) -> bool:
"""Cancel one specific slskd download (with `remove=True`).""" """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]: sweep_callback: Callable[[], None]) -> tuple[bool, str]:
"""Cancel every active slskd download, clear the resulting ones, sweep dirs. """Cancel every active slskd download, clear the resulting ones, sweep dirs.
Returns `(success, message)` so the route can map to the right HTTP shape. 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: if not cancel_success:
return False, "Failed to cancel active downloads." 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() sweep_callback()
return True, "All downloads cancelled and cleared." 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: sweep_callback: Callable[[], None]) -> bool:
"""Clear all terminal transfers from slskd, sweep dirs on success.""" """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: if success:
sweep_callback() sweep_callback()
return success return success

View file

@ -25,7 +25,7 @@ and notifies the lifecycle via `on_download_completed(success=False)` so the
worker slot frees up. worker slot frees up.
Lifted verbatim from web_server.py. Wide dependency surface 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`. status updater, DB) all injected via `CandidatesDeps`.
""" """
@ -49,7 +49,7 @@ logger = logging.getLogger(__name__)
@dataclass @dataclass
class CandidatesDeps: class CandidatesDeps:
"""Bundle of cross-cutting deps the candidate-fallback logic needs.""" """Bundle of cross-cutting deps the candidate-fallback logic needs."""
soulseek_client: Any download_orchestrator: Any
spotify_client: Any spotify_client: Any
run_async: Callable[..., Any] run_async: Callable[..., Any]
get_database: Callable[[], Any] get_database: Callable[[], Any]
@ -209,7 +209,7 @@ def attempt_download_with_candidates(task_id, candidates, track, batch_id=None,
# Initiate download # Initiate download
logger.info(f"[Modal Worker] Starting download: {username} / {os.path.basename(filename)}") 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: if download_id:
# Store context for post-processing with complete Spotify metadata (GUI PARITY) # 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") 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 to cancel the download immediately
try: 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}") logger.warning(f"Successfully cancelled active download {download_id}")
except Exception as cancel_error: except Exception as cancel_error:
logger.error(f"Failed to cancel active download {download_id}: {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) deps.on_download_completed(batch_id, task_id, success=False)
return False return False
# Store download information - use real download ID from soulseek_client # Store download information - use real download ID from download_orchestrator
# CRITICAL FIX: Trust the download ID returned by soulseek_client.download() # CRITICAL FIX: Trust the download ID returned by download_orchestrator.download()
download_tasks[task_id]['download_id'] = download_id download_tasks[task_id]['download_id'] = download_id
download_tasks[task_id]['username'] = username download_tasks[task_id]['username'] = username

View file

@ -42,7 +42,7 @@ logger = logging.getLogger(__name__)
class MasterDeps: class MasterDeps:
"""Bundle of cross-cutting deps the master worker needs.""" """Bundle of cross-cutting deps the master worker needs."""
config_manager: Any config_manager: Any
soulseek_client: Any download_orchestrator: Any
run_async: Callable[..., Any] run_async: Callable[..., Any]
mb_worker: Any mb_worker: Any
mb_release_cache: dict 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}'") _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}'") 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) # Try multiple query variations (banned keywords in artist/album name can return 0 results)
album_queries = [f"{artist_name} {album_name}"] album_queries = [f"{artist_name} {album_name}"]

View file

@ -33,7 +33,7 @@ _run_post_processing_worker = None
_start_next_batch_of_downloads = None _start_next_batch_of_downloads = None
_orphaned_download_keys = None _orphaned_download_keys = None
missing_download_executor = None missing_download_executor = None
soulseek_client = None download_orchestrator = None
def init( def init(
@ -44,12 +44,12 @@ def init(
start_next_batch_of_downloads, start_next_batch_of_downloads,
orphaned_download_keys, orphaned_download_keys,
missing_download_executor_obj, missing_download_executor_obj,
soulseek_client_obj, download_orchestrator_obj,
): ):
"""Bind web_server-side helpers/globals so the class body can resolve them.""" """Bind web_server-side helpers/globals so the class body can resolve them."""
global _make_context_key, _on_download_completed, _download_track_worker global _make_context_key, _on_download_completed, _download_track_worker
global _run_post_processing_worker, _start_next_batch_of_downloads 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 _make_context_key = make_context_key
_on_download_completed = on_download_completed _on_download_completed = on_download_completed
_download_track_worker = download_track_worker _download_track_worker = download_track_worker
@ -57,7 +57,7 @@ def init(
_start_next_batch_of_downloads = start_next_batch_of_downloads _start_next_batch_of_downloads = start_next_batch_of_downloads
_orphaned_download_keys = orphaned_download_keys _orphaned_download_keys = orphaned_download_keys
missing_download_executor = missing_download_executor_obj missing_download_executor = missing_download_executor_obj
soulseek_client = soulseek_client_obj download_orchestrator = download_orchestrator_obj
class WebUIDownloadMonitor: class WebUIDownloadMonitor:
@ -199,7 +199,7 @@ class WebUIDownloadMonitor:
if op[0] == 'cancel_download': if op[0] == 'cancel_download':
_, download_id, username = op _, download_id, username = op
logger.debug(f"[Deferred] Cancelling download: {download_id} from {username}") 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}") logger.debug(f"[Deferred] Successfully cancelled download {download_id}")
elif op[0] == 'cleanup_orphan': elif op[0] == 'cleanup_orphan':
_, context_key = op _, context_key = op
@ -254,9 +254,9 @@ class WebUIDownloadMonitor:
# Get Soulseek downloads from API # Get Soulseek downloads from API
transfers_data = None 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: 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: if transfers_data:
for user_data in transfers_data: for user_data in transfers_data:
username = user_data.get('username', 'Unknown') username = user_data.get('username', 'Unknown')
@ -271,9 +271,9 @@ class WebUIDownloadMonitor:
# cross-source aggregation, no per-source iteration. # cross-source aggregation, no per-source iteration.
try: try:
all_downloads = [] all_downloads = []
if soulseek_client and hasattr(soulseek_client, 'engine'): if download_orchestrator and hasattr(download_orchestrator, 'engine'):
try: try:
all_downloads = run_async(soulseek_client.engine.get_all_downloads()) all_downloads = run_async(download_orchestrator.engine.get_all_downloads())
except Exception: except Exception:
pass pass
for download in all_downloads: for download in all_downloads:

View file

@ -54,7 +54,7 @@ class PostProcessDeps:
always live (no caching of pre-init Spotify clients etc). always live (no caching of pre-init Spotify clients etc).
""" """
config_manager: Any config_manager: Any
soulseek_client: Any download_orchestrator: Any
run_async: Callable run_async: Callable
docker_resolve_path: Callable[[str], str] docker_resolve_path: Callable[[str], str]
extract_filename: 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 # 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 # 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 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: if status and status.file_path:
real_path = status.file_path real_path = status.file_path
if os.path.exists(real_path): if os.path.exists(real_path):

View file

@ -34,7 +34,7 @@ logger = logging.getLogger(__name__)
@dataclass @dataclass
class TaskWorkerDeps: class TaskWorkerDeps:
"""Bundle of cross-cutting deps the per-task download worker needs.""" """Bundle of cross-cutting deps the per-task download worker needs."""
soulseek_client: Any download_orchestrator: Any
matching_engine: Any matching_engine: Any
run_async: Callable run_async: Callable
try_source_reuse: Callable # (task_id, batch_id, track) -> bool 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: try:
# Perform search with timeout # 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") 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 # 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 === # === HYBRID FALLBACK: If primary source failed, try remaining sources directly ===
# The orchestrator's hybrid search stops at the first source with results, even if # The orchestrator's hybrid search stops at the first source with results, even if
# those results all fail quality filtering. Try remaining sources individually. # 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: try:
orch = deps.soulseek_client orch = deps.download_orchestrator
hybrid_order = getattr(orch, 'hybrid_order', None) or [] hybrid_order = getattr(orch, 'hybrid_order', None) or []
if not hybrid_order: if not hybrid_order:
primary = getattr(orch, 'hybrid_primary', 'soulseek') primary = getattr(orch, 'hybrid_primary', 'soulseek')

View file

@ -1,7 +1,7 @@
"""Soulseek/streaming candidate validation — lifted from web_server.py. """Soulseek/streaming candidate validation — lifted from web_server.py.
Body is byte-identical to the original. ``matching_engine`` and 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 constructed in web_server.py and referenced by name throughout
the body. the body.
""" """
@ -14,14 +14,14 @@ logger = logging.getLogger(__name__)
# Injected at runtime via init(). # Injected at runtime via init().
matching_engine = None 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.""" """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 matching_engine = matching_engine_obj
soulseek_client = soulseek_client_obj download_orchestrator = download_orchestrator_obj
def get_valid_candidates(results, spotify_track, query): 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 quality_filtered_candidates = initial_candidates
else: else:
# Filter by user's quality profile before artist verification (Soulseek only) # Filter by user's quality profile before artist verification (Soulseek only)
# Use existing soulseek_client to avoid re-initializing (which accesses download_path filesystem) # Use existing download_orchestrator to avoid re-initializing (which accesses download_path filesystem)
quality_filtered_candidates = soulseek_client.client('soulseek').filter_results_by_quality_preference(initial_candidates) quality_filtered_candidates = download_orchestrator.client('soulseek').filter_results_by_quality_preference(initial_candidates)
# IMPORTANT: Respect empty results from quality filter # IMPORTANT: Respect empty results from quality filter
# If user has strict quality requirements (e.g., FLAC-only with fallback disabled), # If user has strict quality requirements (e.g., FLAC-only with fallback disabled),

View file

@ -2,7 +2,7 @@
Body is byte-identical to the original. Wishlist helpers are Body is byte-identical to the original. Wishlist helpers are
direct imports from core.wishlist.*; runtime state comes from 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 sweep helper are injected via init() because they are constructed
in web_server.py. in web_server.py.
""" """
@ -29,15 +29,15 @@ logger = logging.getLogger(__name__)
# Injected at runtime via init(). # Injected at runtime via init().
automation_engine = None automation_engine = None
soulseek_client = None download_orchestrator = None
_sweep_empty_download_directories = 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.""" """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 automation_engine = engine
soulseek_client = soulseek_client_obj download_orchestrator = download_orchestrator_obj
_sweep_empty_download_directories = sweep_fn _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 # Auto-cleanup: Clear completed downloads from slskd
try: try:
logger.info(f"[Auto-Cleanup] Clearing completed downloads from slskd after batch {batch_id}") 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") logger.info("[Auto-Cleanup] Completed downloads cleared from slskd")
except Exception as cleanup_error: except Exception as cleanup_error:
logger.warning(f"[Auto-Cleanup] Failed to clear completed downloads: {cleanup_error}") logger.warning(f"[Auto-Cleanup] Failed to clear completed downloads: {cleanup_error}")

View file

@ -14,7 +14,7 @@ logger = logging.getLogger(__name__)
def run_basic_soulseek_search( def run_basic_soulseek_search(
query: str, query: str,
soulseek_client, download_orchestrator,
run_async: Callable, run_async: Callable,
) -> list[dict]: ) -> list[dict]:
"""Search Soulseek for `query`, normalize albums + tracks to one sorted list. """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 Returns dicts with `result_type` set to "album" or "track" and sorted by
`quality_score` descending. Empty list on any failure (caller logs). `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 = [] processed_albums = []
for album in albums: for album in albums:

View file

@ -49,7 +49,7 @@ class SearchDeps:
spotify_client: Any spotify_client: Any
hydrabase_client: Any hydrabase_client: Any
hydrabase_worker: Any hydrabase_worker: Any
soulseek_client: Any download_orchestrator: Any
fix_artist_image_url: Callable[[Optional[str]], Optional[str]] fix_artist_image_url: Callable[[Optional[str]], Optional[str]]
is_hydrabase_active: Callable[[], bool] is_hydrabase_active: Callable[[], bool]
get_metadata_fallback_source: Callable[[], str] 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): def resolve_youtube_videos_client(deps: SearchDeps):
"""Return the YouTube download client (used for music-video search) """Return the YouTube download client (used for music-video search)
via the orchestrator's generic accessor, or None when unavailable.""" 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 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]: def stream_youtube_videos(query: str, youtube_client, run_async: Callable) -> Iterator[str]:

View file

@ -96,7 +96,7 @@ def stream_search_track(
album_name: Optional[str], album_name: Optional[str],
duration_ms: int, duration_ms: int,
config_manager, config_manager,
soulseek_client, download_orchestrator,
matching_engine, matching_engine,
run_async: Callable, run_async: Callable,
) -> Optional[dict]: ) -> Optional[dict]:
@ -120,7 +120,7 @@ def stream_search_track(
# Map mode name to canonical registry name (legacy 'deezer_dl' # Map mode name to canonical registry name (legacy 'deezer_dl'
# alias resolves to 'deezer' via the orchestrator's registry). # 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 use_direct_client = stream_client is not None
max_peer_queue = config_manager.get('soulseek.max_peer_queue', 0) or 0 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: if use_direct_client:
tracks_result, _ = run_async(stream_client.search(query, timeout=15)) tracks_result, _ = run_async(stream_client.search(query, timeout=15))
else: 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: if not tracks_result:
logger.info(f"No results for query '{query}', trying next...") logger.info(f"No results for query '{query}', trying next...")

View file

@ -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. 1. Reset stream state to 'loading' with the new track info.
2. Clear any prior file from the Stream/ folder (only one stream lives 2. Clear any prior file from the Stream/ folder (only one stream lives
there at a time). 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. 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 progress, with separate handling for queued vs actively downloading
states. Queue timeout = 15 s; overall timeout = 60 s. states. Queue timeout = 15 s; overall timeout = 60 s.
5. On completion (state ~ 'succeeded' or progress >= 100% AND bytes 5. On completion (state ~ 'succeeded' or progress >= 100% AND bytes
@ -45,7 +45,7 @@ logger = logging.getLogger(__name__)
class PrepareStreamDeps: class PrepareStreamDeps:
"""Bundle of cross-cutting deps the stream-prep worker needs.""" """Bundle of cross-cutting deps the stream-prep worker needs."""
config_manager: Any config_manager: Any
soulseek_client: Any download_orchestrator: Any
stream_lock: Any # threading.Lock stream_lock: Any # threading.Lock
project_root: str # absolute path to web_server.py's directory project_root: str # absolute path to web_server.py's directory
docker_resolve_path: Callable[[str], str] docker_resolve_path: Callable[[str], str]
@ -112,7 +112,7 @@ def prepare_stream_task(track_data, deps: PrepareStreamDeps):
asyncio.set_event_loop(loop) asyncio.set_event_loop(loop)
try: 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('username'),
track_data.get('filename'), track_data.get('filename'),
track_data.get('size', 0) track_data.get('size', 0)
@ -144,7 +144,7 @@ def prepare_stream_task(track_data, deps: PrepareStreamDeps):
try: try:
# Use orchestrator's get_all_downloads() which works for both sources # 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) download_status = deps.find_streaming_download_in_all_downloads(all_downloads, track_data)
if download_status: if download_status:
@ -252,7 +252,7 @@ def prepare_stream_task(track_data, deps: PrepareStreamDeps):
download_id = download_status.get('id', '') download_id = download_status.get('id', '')
if download_id and track_data.get('username'): if download_id and track_data.get('username'):
success = loop.run_until_complete( 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) download_id, track_data.get('username'), remove=True)
) )
if success: if success:

View file

@ -44,12 +44,12 @@ class SyncProgress:
failed_tracks: int = 0 failed_tracks: int = 0
class PlaylistSyncService: class PlaylistSyncService:
def __init__(self, spotify_client: SpotifyClient, plex_client: PlexClient, soulseek_client: SoulseekClient, jellyfin_client: JellyfinClient = None, navidrome_client = None): def __init__(self, spotify_client: SpotifyClient, plex_client: PlexClient, download_orchestrator: SoulseekClient, jellyfin_client: JellyfinClient = None, navidrome_client = None):
self.spotify_client = spotify_client self.spotify_client = spotify_client
self.plex_client = plex_client self.plex_client = plex_client
self.jellyfin_client = jellyfin_client self.jellyfin_client = jellyfin_client
self.navidrome_client = navidrome_client self.navidrome_client = navidrome_client
self.soulseek_client = soulseek_client self.download_orchestrator = download_orchestrator
self.progress_callbacks = {} # Playlist-specific progress callbacks self.progress_callbacks = {} # Playlist-specific progress callbacks
self.syncing_playlists = set() # Track multiple syncing playlists self.syncing_playlists = set() # Track multiple syncing playlists
self._cancelled = False self._cancelled = False
@ -635,7 +635,7 @@ class PlaylistSyncService:
query = self.matching_engine.generate_download_query(match_result.spotify_track) query = self.matching_engine.generate_download_query(match_result.spotify_track)
logger.info(f"Attempting to download: {query}") 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: if download_id:
downloaded_count += 1 downloaded_count += 1

View file

@ -101,7 +101,7 @@ def _build_deps(
on_complete=None, on_complete=None,
): ):
deps = dc.CandidatesDeps( deps = dc.CandidatesDeps(
soulseek_client=soulseek or _FakeSoulseek(), download_orchestrator=soulseek or _FakeSoulseek(),
spotify_client=spotify or _FakeSpotify(), spotify_client=spotify or _FakeSpotify(),
run_async=_run_async, run_async=_run_async,
get_database=lambda: db or _FakeDB(), 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) result = dc.attempt_download_with_candidates("t1", candidates, track, batch_id="b1", deps=deps)
assert result is True 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 download_tasks["t1"]["download_id"] == "dl-1"
assert "user1::best.flac" in matched_downloads_context 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) dc.attempt_download_with_candidates("t2", candidates, track, batch_id=None, deps=deps)
# First call should be the highest-confidence one # 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) dc.attempt_download_with_candidates("t3", candidates, track, batch_id=None, deps=deps)
# First candidate skipped (already used), second one tried # First candidate skipped (already used), second one tried
assert len(deps.soulseek_client.download_calls) == 1 assert len(deps.download_orchestrator.download_calls) == 1
assert deps.soulseek_client.download_calls[0][1] == "fresh.flac" 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) 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) result = dc.attempt_download_with_candidates("t5", candidates, track, batch_id=None, deps=deps)
assert result is False assert result is False
assert deps.soulseek_client.download_calls == [] assert deps.download_orchestrator.download_calls == []
def test_task_deleted_returns_false(): 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) dc.attempt_download_with_candidates("t6", candidates, track, batch_id=None, deps=deps)
# Both candidates skipped (download_id already present) # 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(): 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 assert result is False
# cancel_download was called for the in-flight transfer # 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 # on_download_completed fired with success=False to free the worker slot
assert completion_calls == [("b7", "t7", False)] 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(): 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) soulseek = _FakeSoulseek(download_id=None)
deps = _build_deps(soulseek=soulseek) deps = _build_deps(soulseek=soulseek)
_seed_task("t8") _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) dc.attempt_download_with_candidates("t13", candidates, track, batch_id=None, deps=deps)
# First one wins — second never tried because download succeeded # First one wins — second never tried because download succeeded
assert len(deps.soulseek_client.download_calls) == 1 assert len(deps.download_orchestrator.download_calls) == 1
assert deps.soulseek_client.download_calls[0][1] == "a.flac" assert deps.download_orchestrator.download_calls[0][1] == "a.flac"

View file

@ -178,7 +178,7 @@ def _build_deps(
): ):
return mw.MasterDeps( return mw.MasterDeps(
config_manager=config or _FakeConfig(), 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(), run_async=run_async or _make_run_async(),
mb_worker=mb_worker, mb_worker=mb_worker,
mb_release_cache=mb_release_cache if mb_release_cache is not None else {}, mb_release_cache=mb_release_cache if mb_release_cache is not None else {},

View file

@ -49,7 +49,7 @@ class _Recorder:
def _build_deps( def _build_deps(
*, *,
config=None, config=None,
soulseek_client=None, download_orchestrator=None,
run_async=None, run_async=None,
docker_resolve_path=None, docker_resolve_path=None,
extract_filename=None, extract_filename=None,
@ -64,7 +64,7 @@ def _build_deps(
rec = _Recorder() rec = _Recorder()
return pp.PostProcessDeps( return pp.PostProcessDeps(
config_manager=config or _FakeConfig(), config_manager=config or _FakeConfig(),
soulseek_client=soulseek_client, download_orchestrator=download_orchestrator,
run_async=run_async or (lambda c: None), run_async=run_async or (lambda c: None),
docker_resolve_path=docker_resolve_path or (lambda p: p), docker_resolve_path=docker_resolve_path or (lambda p: p),
extract_filename=extract_filename or (lambda f: os.path.basename(f) if f else ''), 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') monkeypatch.setattr(pp.os.path, 'exists', lambda p: p == '/downloads/Money.mp3')
deps, rec = _build_deps( deps, rec = _build_deps(
soulseek_client=_FakeYTClient(), download_orchestrator=_FakeYTClient(),
run_async=lambda coro: coro, # not async — direct call run_async=lambda coro: coro, # not async — direct call
) )
pp.run_post_processing_worker('t1', 'b1', deps) pp.run_post_processing_worker('t1', 'b1', deps)

View file

@ -76,7 +76,7 @@ def _build_deps(
): ):
rec = _Recorder() rec = _Recorder()
return tw.TaskWorkerDeps( return tw.TaskWorkerDeps(
soulseek_client=soulseek or _FakeClient(), download_orchestrator=soulseek or _FakeClient(),
matching_engine=matching or _FakeMatchEngine(), matching_engine=matching or _FakeMatchEngine(),
run_async=_sync_run_async, run_async=_sync_run_async,
try_source_reuse=try_source_reuse, try_source_reuse=try_source_reuse,

View file

@ -129,7 +129,7 @@ def _build_deps(**overrides):
spotify_client=None, spotify_client=None,
hydrabase_client=None, hydrabase_client=None,
hydrabase_worker=None, hydrabase_worker=None,
soulseek_client=None, download_orchestrator=None,
fix_artist_image_url=lambda u: f'FIXED::{u}' if u else None, fix_artist_image_url=lambda u: f'FIXED::{u}' if u else None,
is_hydrabase_active=lambda: False, is_hydrabase_active=lambda: False,
get_metadata_fallback_source=lambda: 'spotify', get_metadata_fallback_source=lambda: 'spotify',
@ -470,19 +470,19 @@ class _FakeSoulseekWithYT:
def test_resolve_youtube_videos_returns_subclient(): def test_resolve_youtube_videos_returns_subclient():
yt = _FakeYouTube() 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 assert orchestrator.resolve_youtube_videos_client(deps) is yt
def test_resolve_youtube_videos_no_soulseek_returns_none(): 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 assert orchestrator.resolve_youtube_videos_client(deps) is None
def test_resolve_youtube_videos_no_youtube_attr_returns_none(): def test_resolve_youtube_videos_no_youtube_attr_returns_none():
class _NoYT: class _NoYT:
pass pass
deps = _build_deps(soulseek_client=_NoYT()) deps = _build_deps(download_orchestrator=_NoYT())
assert orchestrator.resolve_youtube_videos_client(deps) is None assert orchestrator.resolve_youtube_videos_client(deps) is None

View file

@ -169,7 +169,7 @@ def test_stream_finds_match_on_first_query():
result = stream.stream_search_track( result = stream.stream_search_track(
track_name='Money', artist_name='Pink Floyd', album_name=None, track_name='Money', artist_name='Pink Floyd', album_name=None,
duration_ms=180000, 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, run_async=_run_async,
) )
assert result is not None assert result is not None
@ -190,7 +190,7 @@ def test_stream_walks_to_second_query_on_no_match():
result = stream.stream_search_track( result = stream.stream_search_track(
track_name='Money (Remastered)', artist_name='Pink Floyd', album_name=None, track_name='Money (Remastered)', artist_name='Pink Floyd', album_name=None,
duration_ms=180000, 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, run_async=_run_async,
) )
assert result is not None assert result is not None
@ -209,7 +209,7 @@ def test_stream_returns_none_when_no_matches():
result = stream.stream_search_track( result = stream.stream_search_track(
track_name='Money', artist_name='Pink Floyd', album_name=None, track_name='Money', artist_name='Pink Floyd', album_name=None,
duration_ms=180000, 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, run_async=_run_async,
) )
assert result is None 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(): def test_stream_falls_back_to_default_soulseek_when_no_direct_client():
# Effective mode = 'youtube' (coerced from soulseek), but soul.youtube is None # 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". # query gen → "Pink Floyd Money".
soul = _FakeSoulseek(results_per_query={'Pink Floyd Money': ([object()], [])}) soul = _FakeSoulseek(results_per_query={'Pink Floyd Money': ([object()], [])})
cfg = _FakeConfig({ cfg = _FakeConfig({
@ -229,7 +229,7 @@ def test_stream_falls_back_to_default_soulseek_when_no_direct_client():
result = stream.stream_search_track( result = stream.stream_search_track(
track_name='Money', artist_name='Pink Floyd', album_name=None, track_name='Money', artist_name='Pink Floyd', album_name=None,
duration_ms=180000, 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, run_async=_run_async,
) )
assert result is not None assert result is not None
@ -257,7 +257,7 @@ def test_stream_continues_past_per_query_exception():
result = stream.stream_search_track( result = stream.stream_search_track(
track_name='Money (Live)', artist_name='Pink Floyd', album_name=None, track_name='Money (Live)', artist_name='Pink Floyd', album_name=None,
duration_ms=180000, 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, run_async=_run_async,
) )
# First query raised, second succeeded # First query raised, second succeeded

View file

@ -10,7 +10,7 @@ from core.streaming import prepare as sp
class _FakeSoulseek: 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): def __init__(self, *, download_id='dl-1', all_downloads=None):
self._download_id = download_id self._download_id = download_id
@ -43,7 +43,7 @@ def _build_deps(
state = state if state is not None else {} state = state if state is not None else {}
deps = sp.PrepareStreamDeps( deps = sp.PrepareStreamDeps(
config_manager=type('C', (), {'get': lambda self, k, d=None: d})(), 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(), stream_lock=threading.Lock(),
project_root=project_root, project_root=project_root,
docker_resolve_path=lambda p: p, 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): 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) sk = _FakeSoulseek(download_id=None)
deps = _build_deps(soulseek=sk, project_root=str(tmp_path)) deps = _build_deps(soulseek=sk, project_root=str(tmp_path))

View file

@ -570,7 +570,7 @@ IS_SHUTTING_DOWN = False
# Each client is initialized independently so one failure doesn't take down everything. # Each client is initialized independently so one failure doesn't take down everything.
# Previously, a single exception set ALL clients to None, breaking the entire app. # Previously, a single exception set ALL clients to None, breaking the entire app.
logger.info("Initializing SoulSync services for Web UI...") logger.info("Initializing SoulSync services for Web UI...")
spotify_client = plex_client = jellyfin_client = navidrome_client = soulsync_library_client = soulseek_client = tidal_client = matching_engine = sync_service = web_scan_manager = None spotify_client = plex_client = jellyfin_client = navidrome_client = soulsync_library_client = download_orchestrator = tidal_client = matching_engine = sync_service = web_scan_manager = None
try: try:
spotify_client = get_spotify_client() spotify_client = get_spotify_client()
@ -604,7 +604,7 @@ except Exception as e:
logger.error(f" SoulSync library client failed to initialize: {e}") logger.error(f" SoulSync library client failed to initialize: {e}")
try: try:
soulseek_client = DownloadOrchestrator() download_orchestrator = DownloadOrchestrator()
logger.info(" Download orchestrator initialized") logger.info(" Download orchestrator initialized")
except Exception as e: except Exception as e:
logger.error(f" Download orchestrator failed to initialize: {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}") logger.error(f" Matching engine failed to initialize: {e}")
try: 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") logger.info(" Playlist sync service initialized")
except Exception as e: except Exception as e:
logger.error(f" Playlist sync service failed to initialize: {e}") logger.error(f" Playlist sync service failed to initialize: {e}")
@ -630,8 +630,8 @@ except Exception as e:
# Inject shutdown check callback into every download source that # Inject shutdown check callback into every download source that
# accepts one. Generic dispatch via the registry — no per-source # accepts one. Generic dispatch via the registry — no per-source
# attribute reaches needed. # attribute reaches needed.
if soulseek_client and hasattr(soulseek_client, 'registry'): if download_orchestrator and hasattr(download_orchestrator, 'registry'):
for _src_name, _src_client in soulseek_client.registry.all_plugins(): for _src_name, _src_client in download_orchestrator.registry.all_plugins():
if _src_client is not None and hasattr(_src_client, 'set_shutdown_check'): if _src_client is not None and hasattr(_src_client, 'set_shutdown_check'):
try: try:
_src_client.set_shutdown_check(lambda: IS_SHUTTING_DOWN) _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']) hybrid_order = config_manager.get('download_source.hybrid_order', ['hifi', 'youtube', 'soulseek'])
soulseek_active = (dl_mode == 'soulseek' or soulseek_active = (dl_mode == 'soulseek' or
(dl_mode == 'hybrid' and 'soulseek' in hybrid_order)) (dl_mode == 'hybrid' and 'soulseek' in hybrid_order))
# soulseek_client is a DownloadOrchestrator; the real client lives on # Reach the underlying SoulseekClient via the orchestrator's
# .soulseek. Match the getattr pattern used at the other call sites. # generic accessor.
slskd = getattr(soulseek_client, 'soulseek', None) if soulseek_client else None slskd = download_orchestrator.client('soulseek') if download_orchestrator else None
if not soulseek_active or not slskd or not slskd.base_url: if not soulseek_active or not slskd or not slskd.base_url:
_update_automation_progress(automation_id, _update_automation_progress(automation_id,
log_line='Soulseek not active — skipped', log_type='skip') 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') log_line='Auto-clear disabled in settings', log_type='skip')
return {'status': 'skipped'} return {'status': 'skipped'}
try: 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 keep_searches=50, trigger_threshold=200
)) ))
if success: if success:
@ -2041,7 +2041,7 @@ def _register_automation_handlers():
log_line='Skipped — downloads active', log_type='skip') log_line='Skipped — downloads active', log_type='skip')
return {'status': 'completed'} return {'status': 'completed'}
run_async(soulseek_client.clear_all_completed_downloads()) run_async(download_orchestrator.clear_all_completed_downloads())
if not has_post_processing: if not has_post_processing:
_sweep_empty_download_directories() _sweep_empty_download_directories()
_update_automation_progress(automation_id, _update_automation_progress(automation_id,
@ -2096,7 +2096,7 @@ def _register_automation_handlers():
log_line='Download queue: skipped (active batches)', log_type='skip') log_line='Download queue: skipped (active batches)', log_type='skip')
else: else:
try: try:
run_async(soulseek_client.clear_all_completed_downloads()) run_async(download_orchestrator.clear_all_completed_downloads())
steps.append('Download queue: cleared') steps.append('Download queue: cleared')
_update_automation_progress(automation_id, _update_automation_progress(automation_id,
log_line='Download queue: cleared', log_type='success') log_line='Download queue: cleared', log_type='success')
@ -2154,7 +2154,7 @@ def _register_automation_handlers():
_update_automation_progress(automation_id, _update_automation_progress(automation_id,
log_line='Search cleanup: disabled in settings', log_type='skip') log_line='Search cleanup: disabled in settings', log_type='skip')
else: 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 keep_searches=50, trigger_threshold=200
)) ))
steps.append('Search history: cleaned') steps.append('Search history: cleaned')
@ -2286,7 +2286,7 @@ def _register_automation_handlers():
if automation_id: if automation_id:
_update_automation_progress(automation_id, _update_automation_progress(automation_id,
phase='Searching', log_line=f'Searching: {query}', log_type='info') 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 result:
if automation_id: if automation_id:
_update_automation_progress(automation_id, _update_automation_progress(automation_id,
@ -2348,7 +2348,7 @@ try:
app.register_blueprint(api_bp, url_prefix='/api/v1') app.register_blueprint(api_bp, url_prefix='/api/v1')
app.soulsync = { app.soulsync = {
'spotify_client': spotify_client, 'spotify_client': spotify_client,
'soulseek_client': soulseek_client, 'download_orchestrator': download_orchestrator,
'tidal_client': tidal_client, 'tidal_client': tidal_client,
'matching_engine': matching_engine, 'matching_engine': matching_engine,
'config_manager': config_manager, 'config_manager': config_manager,
@ -2453,8 +2453,9 @@ def get_cached_transfer_data():
# First, get Soulseek downloads from API # First, get Soulseek downloads from API
transfers_data = None transfers_data = None
if not soulseek_known_down and soulseek_client and getattr(soulseek_client, 'soulseek', None) and soulseek_client.client("soulseek").base_url: _slsk = download_orchestrator.client("soulseek") if download_orchestrator else None
transfers_data = run_async(soulseek_client._make_request('GET', 'transfers/downloads')) 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: if transfers_data:
all_transfers = [] all_transfers = []
for user_data in transfers_data: for user_data in transfers_data:
@ -2477,9 +2478,9 @@ def get_cached_transfer_data():
all_downloads = [] all_downloads = []
# Generic dispatch — engine returns active downloads # Generic dispatch — engine returns active downloads
# across every plugin in one call, no per-source iteration. # 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: try:
all_downloads = run_async(soulseek_client.engine.get_all_downloads()) all_downloads = run_async(download_orchestrator.engine.get_all_downloads())
except Exception: except Exception:
pass pass
for download in all_downloads: for download in all_downloads:
@ -3052,7 +3053,7 @@ def _build_prepare_stream_deps():
return _streaming_prepare.PrepareStreamDeps( return _streaming_prepare.PrepareStreamDeps(
config_manager=config_manager, config_manager=config_manager,
soulseek_client=soulseek_client, download_orchestrator=download_orchestrator,
stream_lock=stream_lock, stream_lock=stream_lock,
project_root=os.path.dirname(os.path.abspath(__file__)), project_root=os.path.dirname(os.path.abspath(__file__)),
docker_resolve_path=docker_resolve_path, docker_resolve_path=docker_resolve_path,
@ -3499,10 +3500,10 @@ def get_status():
if is_serverless: if is_serverless:
soulseek_status = True soulseek_status = True
soulseek_response_time = 0 soulseek_response_time = 0
elif soulseek_relevant and soulseek_client: elif soulseek_relevant and download_orchestrator:
soulseek_start = time.time() soulseek_start = time.time()
try: try:
soulseek_status = run_async(soulseek_client.check_connection()) soulseek_status = run_async(download_orchestrator.check_connection())
except Exception: except Exception:
soulseek_status = False soulseek_status = False
soulseek_response_time = (time.time() - soulseek_start) * 1000 soulseek_response_time = (time.time() - soulseek_start) * 1000
@ -3915,7 +3916,7 @@ def _build_system_stats():
if soulseek_active and not soulseek_known_down: if soulseek_active and not soulseek_known_down:
try: 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: if transfers_data:
for user_data in transfers_data: for user_data in transfers_data:
if 'directories' in user_data: if 'directories' in user_data:
@ -4141,11 +4142,11 @@ def handle_settings():
if navidrome_client: if navidrome_client:
navidrome_client.reload_config() navidrome_client.reload_config()
# Reload orchestrator settings (download source mode, hybrid_primary, etc.) # Reload orchestrator settings (download source mode, hybrid_primary, etc.)
if soulseek_client: if download_orchestrator:
soulseek_client.reload_settings() download_orchestrator.reload_settings()
# Reload YouTube client settings (rate limiting, cookies) # Reload YouTube client settings (rate limiting, cookies)
if hasattr(soulseek_client, 'youtube'): if hasattr(download_orchestrator, 'youtube'):
soulseek_client.client("youtube").reload_settings() download_orchestrator.client("youtube").reload_settings()
# FIX: Re-instantiate the global tidal_client to pick up new settings # FIX: Re-instantiate the global tidal_client to pick up new settings
try: try:
tidal_client = TidalClient() tidal_client = TidalClient()
@ -4177,7 +4178,7 @@ def handle_settings():
data = dict(config_manager.config_data) data = dict(config_manager.config_data)
# Include which download sources are configured so the UI can auto-disable unconfigured ones # Include which download sources are configured so the UI can auto-disable unconfigured ones
try: try:
data['_source_status'] = soulseek_client.get_source_status() data['_source_status'] = download_orchestrator.get_source_status()
except Exception: except Exception:
pass pass
return jsonify(data) return jsonify(data)
@ -6728,7 +6729,7 @@ def _build_search_deps():
spotify_client=spotify_client, spotify_client=spotify_client,
hydrabase_client=hydrabase_client, hydrabase_client=hydrabase_client,
hydrabase_worker=hydrabase_worker, hydrabase_worker=hydrabase_worker,
soulseek_client=soulseek_client, download_orchestrator=download_orchestrator,
fix_artist_image_url=fix_artist_image_url, fix_artist_image_url=fix_artist_image_url,
is_hydrabase_active=_is_hydrabase_active, is_hydrabase_active=_is_hydrabase_active,
get_metadata_fallback_source=_get_metadata_fallback_source, get_metadata_fallback_source=_get_metadata_fallback_source,
@ -6754,7 +6755,7 @@ def search_music():
add_activity_item("", "Search Started", f"'{query}'", "Now") add_activity_item("", "Search Started", f"'{query}'", "Now")
try: 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") add_activity_item("", "Search Complete", f"'{query}' - {len(results)} results", "Now")
return jsonify({"results": results}) return jsonify({"results": results})
except Exception as e: 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, When the requested source's client isn't available (Spotify unauthed,
Discogs missing token, Hydrabase disconnected, MusicBrainz import 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 `{"artists":[],"albums":[],"tracks":[],"available":false}` to match
the original endpoint contract. the original endpoint contract.
""" """
@ -6894,7 +6895,7 @@ def stream_enhanced_search_track():
album_name=album_name, album_name=album_name,
duration_ms=duration_ms, duration_ms=duration_ms,
config_manager=config_manager, config_manager=config_manager,
soulseek_client=soulseek_client, download_orchestrator=download_orchestrator,
matching_engine=matching_engine, matching_engine=matching_engine,
run_async=run_async, run_async=run_async,
) )
@ -7033,7 +7034,7 @@ def download_music_video():
def _progress(pct): def _progress(pct):
_music_video_downloads[video_id]['progress'] = round(pct, 1) _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): if final_path and os.path.exists(final_path):
_music_video_downloads[video_id]['status'] = 'completed' _music_video_downloads[video_id]['status'] = 'completed'
@ -7092,7 +7093,7 @@ def start_download():
filename = track_data.get('filename') filename = track_data.get('filename')
file_size = track_data.get('size', 0) file_size = track_data.get('size', 0)
download_id = run_async(soulseek_client.download( download_id = run_async(download_orchestrator.download(
username, username,
filename, filename,
file_size file_size
@ -7141,7 +7142,7 @@ def start_download():
if not username or not filename: if not username or not filename:
return jsonify({"error": "Missing username or filename."}), 400 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}") logger.info(f"Download ID returned: {download_id}")
if download_id: if download_id:
@ -7337,7 +7338,7 @@ def get_download_status():
A robust status checker that correctly finds completed files by searching A robust status checker that correctly finds completed files by searching
the entire download directory with fuzzy matching, mirroring the logic from downloads.py. the entire download directory with fuzzy matching, mirroring the logic from downloads.py.
""" """
if not soulseek_client: if not download_orchestrator:
return jsonify({"transfers": []}) return jsonify({"transfers": []})
try: try:
@ -7346,7 +7347,7 @@ def get_download_status():
soulseek_known_down = not _status_cache.get('soulseek', {}).get('connected', True) soulseek_known_down = not _status_cache.get('soulseek', {}).get('connected', True)
transfers_data = None transfers_data = None
if not soulseek_known_down: 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! # Don't return early if no Soulseek transfers - YouTube/Tidal downloads need to be checked too!
all_transfers = [] all_transfers = []
@ -7414,7 +7415,7 @@ def get_download_status():
transfer_id = file_info.get('id') transfer_id = file_info.get('id')
if transfer_id: if transfer_id:
try: 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: except Exception:
pass pass
_orphaned_download_keys.discard(context_key) _orphaned_download_keys.discard(context_key)
@ -7526,7 +7527,7 @@ def get_download_status():
# Also include YouTube/Tidal downloads in the response # Also include YouTube/Tidal downloads in the response
try: 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: for download in all_streaming_downloads:
if download.username in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr', 'soundcloud'): 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 return jsonify({"success": False, "error": "Missing download_id or username."}), 400
try: 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: if success:
return jsonify({"success": True, "message": "Download cancelled."}) return jsonify({"success": True, "message": "Download cancelled."})
return jsonify({"success": False, "error": "Failed to cancel download via slskd."}), 500 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.""" """Cancel all active downloads from slskd, then clear completed ones."""
try: try:
success, msg = _downloads_cancel.cancel_all_active( 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: if success:
return jsonify({"success": True, "message": msg}) return jsonify({"success": True, "message": msg})
@ -7687,7 +7688,7 @@ def clear_finished_downloads():
"""Clear all terminal (completed, cancelled, failed) downloads from slskd.""" """Clear all terminal (completed, cancelled, failed) downloads from slskd."""
try: try:
success = _downloads_cancel.clear_finished_active( success = _downloads_cancel.clear_finished_active(
soulseek_client, run_async, _sweep_empty_download_directories, download_orchestrator, run_async, _sweep_empty_download_directories,
) )
if success: if success:
return jsonify({"success": True, "message": "Finished downloads cleared."}) return jsonify({"success": True, "message": "Finished downloads cleared."})
@ -8074,7 +8075,7 @@ def clear_all_searches():
Clear all searches from slskd search history. Clear all searches from slskd search history.
""" """
try: try:
success = run_async(soulseek_client.clear_all_searches()) success = run_async(download_orchestrator.clear_all_searches())
if success: if success:
add_activity_item("", "Search Cleanup", "All search history cleared manually", "Now") add_activity_item("", "Search Cleanup", "All search history cleared manually", "Now")
return jsonify({"success": True, "message": "All searches cleared."}) return jsonify({"success": True, "message": "All searches cleared."})
@ -8094,7 +8095,7 @@ def maintain_search_history():
keep_searches = data.get('keep_searches', 50) keep_searches = data.get('keep_searches', 50)
trigger_threshold = data.get('trigger_threshold', 200) 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 keep_searches=keep_searches, trigger_threshold=trigger_threshold
)) ))
if success: if success:
@ -11674,7 +11675,7 @@ def redownload_search_sources(track_id):
# Get all available download source clients # Get all available download source clients
download_clients = {} download_clients = {}
try: try:
orch = soulseek_client # The download orchestrator orch = download_orchestrator # The download orchestrator
if hasattr(orch, 'soulseek') and orch.soulseek: if hasattr(orch, 'soulseek') and orch.soulseek:
if not (hasattr(orch.soulseek, 'is_configured') and not orch.soulseek.is_configured()): if not (hasattr(orch.soulseek, 'is_configured') and not orch.soulseek.is_configured()):
download_clients['soulseek'] = orch.soulseek download_clients['soulseek'] = orch.soulseek
@ -11698,7 +11699,7 @@ def redownload_search_sources(track_id):
if not download_clients: if not download_clients:
# Fallback: use orchestrator directly # 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())}") 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 continue
# Start download # 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: if download_id:
context_key = _make_context_key(username, filename) 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: if not username or not filename:
continue continue
download_id = run_async(soulseek_client.download(username, filename, size)) download_id = run_async(download_orchestrator.download(username, filename, size))
if download_id: if download_id:
context_key = _make_context_key(username, filename) 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) '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: if download_id:
context_key = _make_context_key(username, filename) context_key = _make_context_key(username, filename)
@ -12883,7 +12884,7 @@ def start_matched_download():
if not username or not filename: if not username or not filename:
return jsonify({"success": False, "error": "Missing username or filename"}), 400 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: if download_id:
context_key = _make_context_key(username, filename) 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['title'] = parsed_meta.get('title') or download_payload.get('title')
download_payload['artist'] = parsed_meta.get('artist') or download_payload.get('artist') 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: if download_id:
context_key = _make_context_key(username, filename) context_key = _make_context_key(username, filename)
@ -12972,7 +12973,7 @@ def start_matched_download():
def _parse_filename_metadata(filename: str) -> dict: 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. This is the crucial missing step that cleans filenames BEFORE Spotify matching.
""" """
return parse_filename_metadata(filename) 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, genius_worker=genius_worker,
spotify_enrichment_worker=spotify_enrichment_worker, spotify_enrichment_worker=spotify_enrichment_worker,
itunes_enrichment_worker=itunes_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, genius_worker=genius_worker,
spotify_enrichment_worker=spotify_enrichment_worker, spotify_enrichment_worker=spotify_enrichment_worker,
itunes_enrichment_worker=itunes_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, genius_worker=genius_worker,
spotify_enrichment_worker=spotify_enrichment_worker, spotify_enrichment_worker=spotify_enrichment_worker,
itunes_enrichment_worker=itunes_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( return _downloads_master.MasterDeps(
config_manager=config_manager, config_manager=config_manager,
soulseek_client=soulseek_client, download_orchestrator=download_orchestrator,
run_async=run_async, run_async=run_async,
mb_worker=mb_worker, mb_worker=mb_worker,
mb_release_cache=mb_release_cache, 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.""" """Build the PostProcessDeps bundle from web_server.py globals on each call."""
return _downloads_post_processing.PostProcessDeps( return _downloads_post_processing.PostProcessDeps(
config_manager=config_manager, config_manager=config_manager,
soulseek_client=soulseek_client, download_orchestrator=download_orchestrator,
run_async=run_async, run_async=run_async,
docker_resolve_path=docker_resolve_path, docker_resolve_path=docker_resolve_path,
extract_filename=extract_filename, extract_filename=extract_filename,
@ -16886,7 +16887,7 @@ from core.downloads import task_worker as _downloads_task_worker
def _build_task_worker_deps(): def _build_task_worker_deps():
"""Build TaskWorkerDeps bundle from web_server.py globals on each call.""" """Build TaskWorkerDeps bundle from web_server.py globals on each call."""
return _downloads_task_worker.TaskWorkerDeps( return _downloads_task_worker.TaskWorkerDeps(
soulseek_client=soulseek_client, download_orchestrator=download_orchestrator,
matching_engine=matching_engine, matching_engine=matching_engine,
run_async=run_async, run_async=run_async,
try_source_reuse=_try_source_reuse, try_source_reuse=_try_source_reuse,
@ -16912,7 +16913,7 @@ from core.downloads import candidates as _downloads_candidates
def _build_candidates_deps(): def _build_candidates_deps():
"""Build the CandidatesDeps bundle from web_server.py globals on each call.""" """Build the CandidatesDeps bundle from web_server.py globals on each call."""
return _downloads_candidates.CandidatesDeps( return _downloads_candidates.CandidatesDeps(
soulseek_client=soulseek_client, download_orchestrator=download_orchestrator,
spotify_client=spotify_client, spotify_client=spotify_client,
run_async=run_async, run_async=run_async,
get_database=get_database, get_database=get_database,
@ -17077,7 +17078,7 @@ def _try_source_reuse(task_id, batch_id, track):
# Sort by confidence, filter by quality preference # Sort by confidence, filter by quality preference
candidates.sort(key=lambda c: c.confidence, reverse=True) 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})") _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) filtered = slsk.filter_results_by_quality_preference(candidates)
if not filtered: if not filtered:
_sr.info(f"Quality filter rejected all candidates for task {task_id}") _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 return
try: try:
# Access SoulseekClient directly (soulseek_client is DownloadOrchestrator) # Access SoulseekClient directly (download_orchestrator is DownloadOrchestrator)
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
_sr.info(f"Browsing {username}:{folder_path}...") _sr.info(f"Browsing {username}:{folder_path}...")
files = run_async(slsk.browse_user_directory(username, folder_path)) files = run_async(slsk.browse_user_directory(username, folder_path))
if not files: if not files:
@ -17488,7 +17489,7 @@ def cancel_download_task():
if download_id and username: if download_id and username:
try: try:
# This is an async call, so we run it and wait # 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}") logger.warning(f"Successfully cancelled Soulseek download {download_id} for task {task_id}")
except Exception as e: except Exception as e:
logger.error(f"Failed to cancel download on slskd, but worker already moved on: {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 # username: youtube/tidal/qobuz/hifi/deezer_dl/lidarr go to
# their streaming clients, anything else goes to Soulseek. # 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 # raw SoulseekClient and accessed .base_url / ._make_request
# directly — crashed with AttributeError on the orchestrator # directly — crashed with AttributeError on the orchestrator
# and silently left streaming downloads running in background. # and silently left streaming downloads running in background.
try: try:
logger.info(f"[Atomic Cancel] Dispatching cancel to orchestrator: username={username} download_id={download_id}") logger.info(f"[Atomic Cancel] Dispatching cancel to orchestrator: username={username} download_id={download_id}")
cancel_success = run_async( 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: if cancel_success:
logger.info(f"[Atomic Cancel] Orchestrator cancelled download: {download_id}") logger.info(f"[Atomic Cancel] Orchestrator cancelled download: {download_id}")
@ -19725,7 +19726,7 @@ def get_discover_album(source, album_id):
def hifi_status(): def hifi_status():
"""Check if HiFi API instances are reachable.""" """Check if HiFi API instances are reachable."""
try: try:
hifi = soulseek_client.client("hifi") hifi = download_orchestrator.client("hifi")
available = hifi.is_available() available = hifi.is_available()
version = hifi.get_version() if available else None version = hifi.get_version() if available else None
return jsonify({ return jsonify({
@ -19749,8 +19750,8 @@ def soundcloud_status():
""" """
try: try:
sc = None sc = None
if soulseek_client and hasattr(soulseek_client, 'soundcloud'): if download_orchestrator and hasattr(download_orchestrator, 'soundcloud'):
sc = soulseek_client.client("soundcloud") sc = download_orchestrator.client("soundcloud")
if not sc: if not sc:
return jsonify({ return jsonify({
"available": False, "available": False,
@ -19778,7 +19779,7 @@ def hifi_instances():
"""Check availability of all HiFi API instances.""" """Check availability of all HiFi API instances."""
import requests as req import requests as req
try: try:
hifi = soulseek_client.client("hifi") hifi = download_orchestrator.client("hifi")
instances = list(hifi._instances) instances = list(hifi._instances)
results = [] results = []
for url in instances: for url in instances:
@ -19834,8 +19835,8 @@ def hifi_add_instance():
if not added: if not added:
return jsonify({'success': False, 'error': 'Instance already exists'}), 400 return jsonify({'success': False, 'error': 'Instance already exists'}), 400
# Reload the HiFi client # Reload the HiFi client
if soulseek_client: if download_orchestrator:
soulseek_client.reload_instances('hifi') download_orchestrator.reload_instances('hifi')
return jsonify({'success': True, 'url': url}) return jsonify({'success': True, 'url': url})
except Exception as e: except Exception as e:
logger.error(f"Error adding HiFi instance: {e}") logger.error(f"Error adding HiFi instance: {e}")
@ -19856,8 +19857,8 @@ def hifi_remove_instance():
if not removed: if not removed:
return jsonify({'success': False, 'error': 'Instance not found'}), 404 return jsonify({'success': False, 'error': 'Instance not found'}), 404
# Reload the HiFi client # Reload the HiFi client
if soulseek_client: if download_orchestrator:
soulseek_client.reload_instances('hifi') download_orchestrator.reload_instances('hifi')
return jsonify({'success': True, 'url': url}) return jsonify({'success': True, 'url': url})
except Exception as e: except Exception as e:
logger.error(f"Error removing HiFi instance: {e}") logger.error(f"Error removing HiFi instance: {e}")
@ -19877,8 +19878,8 @@ def hifi_toggle_instance():
from database.music_database import get_database from database.music_database import get_database
db = get_database() db = get_database()
db.toggle_hifi_instance(url, enabled) db.toggle_hifi_instance(url, enabled)
if soulseek_client: if download_orchestrator:
soulseek_client.reload_instances('hifi') download_orchestrator.reload_instances('hifi')
return jsonify({'success': True}) return jsonify({'success': True})
except Exception as e: except Exception as e:
logger.error(f"Error toggling HiFi instance: {e}") logger.error(f"Error toggling HiFi instance: {e}")
@ -19899,8 +19900,8 @@ def hifi_reorder_instances():
if not db.reorder_hifi_instances(urls): if not db.reorder_hifi_instances(urls):
return jsonify({'success': False, 'error': 'One or more URLs not found'}), 400 return jsonify({'success': False, 'error': 'One or more URLs not found'}), 400
# Reload the HiFi client # Reload the HiFi client
if soulseek_client: if download_orchestrator:
soulseek_client.reload_instances('hifi') download_orchestrator.reload_instances('hifi')
return jsonify({'success': True}) return jsonify({'success': True})
except Exception as e: except Exception as e:
logger.error(f"Error reordering HiFi instances: {e}") logger.error(f"Error reordering HiFi instances: {e}")
@ -20052,9 +20053,9 @@ def deezer_download_test_download():
def _get_tidal_download_client(): def _get_tidal_download_client():
"""Get Tidal download client from the orchestrator, with helpful error if unavailable.""" """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") 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: if not tidal:
raise RuntimeError("Tidal download client not available — ensure tidalapi is installed") raise RuntimeError("Tidal download client not available — ensure tidalapi is installed")
return tidal return tidal
@ -20126,7 +20127,7 @@ def qobuz_auth_login():
if not email or not password: if not email or not password:
return jsonify({"success": False, "error": "Email and password required"}), 400 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) result = qobuz.login(email, password)
if result['status'] == 'success': if result['status'] == 'success':
@ -20149,7 +20150,7 @@ def qobuz_auth_token():
if not token: if not token:
return jsonify({"success": False, "error": "Auth token required"}), 400 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) result = qobuz.login_with_token(token)
if result['status'] == 'success': if result['status'] == 'success':
@ -20166,7 +20167,7 @@ def qobuz_auth_token():
def qobuz_auth_status(): def qobuz_auth_status():
"""Check if Qobuz client is authenticated.""" """Check if Qobuz client is authenticated."""
try: try:
qobuz = soulseek_client.client("qobuz") qobuz = download_orchestrator.client("qobuz")
authenticated = qobuz.is_authenticated() authenticated = qobuz.is_authenticated()
user_info = {} user_info = {}
if authenticated and qobuz.user_info: if authenticated and qobuz.user_info:
@ -20183,7 +20184,7 @@ def qobuz_auth_status():
def qobuz_auth_logout(): def qobuz_auth_logout():
"""Logout from Qobuz.""" """Logout from Qobuz."""
try: try:
soulseek_client.client("qobuz").logout() download_orchestrator.client("qobuz").logout()
_sync_qobuz_credentials_to_worker() _sync_qobuz_credentials_to_worker()
return jsonify({"success": True}) return jsonify({"success": True})
except Exception as e: except Exception as e:
@ -21125,7 +21126,7 @@ def _get_metadata_fallback_client():
def get_deezer_arl_status(): def get_deezer_arl_status():
"""Check if Deezer ARL is configured and authenticated.""" """Check if Deezer ARL is configured and authenticated."""
try: 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(): if deezer_dl and deezer_dl.is_authenticated():
user_data = deezer_dl._user_data or {} user_data = deezer_dl._user_data or {}
return jsonify({ return jsonify({
@ -21142,7 +21143,7 @@ def get_deezer_arl_status():
def get_deezer_arl_playlists(): def get_deezer_arl_playlists():
"""Fetch user playlists via Deezer ARL authentication (like /api/spotify/playlists).""" """Fetch user playlists via Deezer ARL authentication (like /api/spotify/playlists)."""
try: 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(): 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 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): def get_deezer_arl_playlist_tracks(playlist_id):
"""Fetch full playlist with tracks via ARL (like /api/spotify/playlist/<id>).""" """Fetch full playlist with tracks via ARL (like /api/spotify/playlist/<id>)."""
try: 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(): if not deezer_dl or not deezer_dl.is_authenticated():
return jsonify({'error': 'Deezer ARL not authenticated.'}), 401 return jsonify({'error': 'Deezer ARL not authenticated.'}), 401
@ -27362,8 +27363,8 @@ def get_your_artists_sources():
try: try:
deezer_cl = _get_deezer_client() deezer_cl = _get_deezer_client()
deezer_oauth = deezer_cl and hasattr(deezer_cl, 'is_user_authenticated') and deezer_cl.is_user_authenticated() 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") deezer_arl = (hasattr(download_orchestrator, 'deezer_dl') and download_orchestrator.client("deezer_dl")
and soulseek_client.client("deezer_dl").is_authenticated()) and download_orchestrator.client("deezer_dl").is_authenticated())
if deezer_oauth or deezer_arl: if deezer_oauth or deezer_arl:
connected.append('deezer') connected.append('deezer')
except Exception: 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(): 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)...") logger.info("[Your Artists] Fetching favorite artists from Deezer (OAuth)...")
artists = deezer_cl.get_user_favorite_artists(limit=200) artists = deezer_cl.get_user_favorite_artists(limit=200)
elif (hasattr(soulseek_client, 'deezer_dl') and soulseek_client.client("deezer_dl") elif (hasattr(download_orchestrator, 'deezer_dl') and download_orchestrator.client("deezer_dl")
and soulseek_client.client("deezer_dl").is_authenticated()): and download_orchestrator.client("deezer_dl").is_authenticated()):
logger.info("[Your Artists] Fetching favorite artists from Deezer (ARL)...") 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: for a in artists:
database.upsert_liked_artist( database.upsert_liked_artist(
artist_name=a['name'], source_service='deezer', artist_name=a['name'], source_service='deezer',
@ -27634,8 +27635,8 @@ def get_your_albums_sources():
try: try:
deezer_cl = _get_deezer_client() deezer_cl = _get_deezer_client()
deezer_oauth = deezer_cl and hasattr(deezer_cl, 'is_user_authenticated') and deezer_cl.is_user_authenticated() 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") deezer_arl = (hasattr(download_orchestrator, 'deezer_dl') and download_orchestrator.client("deezer_dl")
and soulseek_client.client("deezer_dl").is_authenticated()) and download_orchestrator.client("deezer_dl").is_authenticated())
if deezer_oauth or deezer_arl: if deezer_oauth or deezer_arl:
connected.append('deezer') connected.append('deezer')
except Exception: 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(): 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)...") logger.info("[Your Albums] Fetching favorite albums from Deezer (OAuth)...")
albums = deezer_cl.get_user_favorite_albums(limit=500) albums = deezer_cl.get_user_favorite_albums(limit=500)
elif (hasattr(soulseek_client, 'deezer_dl') and soulseek_client.client("deezer_dl") elif (hasattr(download_orchestrator, 'deezer_dl') and download_orchestrator.client("deezer_dl")
and soulseek_client.client("deezer_dl").is_authenticated()): and download_orchestrator.client("deezer_dl").is_authenticated()):
logger.info("[Your Albums] Fetching favorite albums from Deezer (ARL)...") 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: for a in albums:
database.upsert_liked_album( database.upsert_liked_album(
album_name=a['album_name'], artist_name=a['artist_name'], album_name=a['album_name'], artist_name=a['artist_name'],
@ -32666,7 +32667,7 @@ register_runtime_clients(
) )
_init_connection_test( _init_connection_test(
soulseek_client_obj=soulseek_client, download_orchestrator_obj=download_orchestrator,
qobuz_worker=qobuz_enrichment_worker, qobuz_worker=qobuz_enrichment_worker,
hydrabase_client_obj=hydrabase_client, hydrabase_client_obj=hydrabase_client,
docker_resolve_url_fn=docker_resolve_url, 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( _init_download_validation(
matching_engine_obj=matching_engine, matching_engine_obj=matching_engine,
soulseek_client_obj=soulseek_client, download_orchestrator_obj=download_orchestrator,
) )
_init_wishlist_failed( _init_wishlist_failed(
engine=automation_engine, engine=automation_engine,
soulseek_client_obj=soulseek_client, download_orchestrator_obj=download_orchestrator,
sweep_fn=_sweep_empty_download_directories, sweep_fn=_sweep_empty_download_directories,
) )
@ -32703,7 +32704,7 @@ _init_debug_info(
sync_states_dict=sync_states, sync_states_dict=sync_states,
youtube_playlist_states_dict=youtube_playlist_states, youtube_playlist_states_dict=youtube_playlist_states,
tidal_discovery_states_dict=tidal_discovery_states, tidal_discovery_states_dict=tidal_discovery_states,
soulseek_client_obj=soulseek_client, download_orchestrator_obj=download_orchestrator,
log_path=_log_path, log_path=_log_path,
log_dir=_log_dir, log_dir=_log_dir,
flask_app=app, flask_app=app,
@ -32722,7 +32723,7 @@ _init_download_monitor(
start_next_batch_of_downloads=_start_next_batch_of_downloads, start_next_batch_of_downloads=_start_next_batch_of_downloads,
orphaned_download_keys=_orphaned_download_keys, orphaned_download_keys=_orphaned_download_keys,
missing_download_executor_obj=missing_download_executor, missing_download_executor_obj=missing_download_executor,
soulseek_client_obj=soulseek_client, download_orchestrator_obj=download_orchestrator,
) )
# --- Hydrabase Auto-Reconnect --- # --- Hydrabase Auto-Reconnect ---

View file

@ -3432,6 +3432,7 @@ const WHATS_NEW = {
'2.4.2': [ '2.4.2': [
// --- post-2.4.1 dev work — entries hidden by _getLatestWhatsNewVersion until the build version bumps --- // --- post-2.4.1 dev work — entries hidden by _getLatestWhatsNewVersion until the build version bumps ---
{ date: 'Unreleased — 2.4.2 dev cycle' }, { 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(\'<source>\')` 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: 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(\'<source>\')` 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.<source>` reaches in web_server.py to `client("<source>")`. 18 new tests pin every fix.' }, { 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.<source>` reaches in web_server.py to `client("<source>")`. 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' }, { 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' },