"""Candidate fallback download logic. `attempt_download_with_candidates(task_id, candidates, track, batch_id, deps)` is the function the search/match pipeline calls once it has a sorted list of Soulseek candidates for a track. It walks the candidates by descending confidence and starts the first one that: 1. Hasn't been tried for this task already (`used_sources` dedup). 2. Isn't blacklisted (user-flagged bad match). 3. Doesn't trigger a cancellation race (checked at three points). When a candidate accepts: - Stores rich post-processing context in `matched_downloads_context` keyed by `make_context_key(username, filename)` — clean Spotify metadata, album context (real or synthesized), `is_album_download` flag, batch/task IDs. - For tracks with clean Spotify data, resolves track_number / disc_number from (1) track_info → (2) track object → (3) Spotify API call, with album metadata backfilled from the API response when local context is incomplete. - Updates the task with the assigned `download_id`, falls through with a "searching" reset on failure so the next attempt finds a clean state. On cancellation mid-download, attempts to cancel the active Soulseek transfer and notifies the lifecycle via `on_download_completed(success=False)` so the worker slot frees up. Lifted verbatim from web_server.py. Wide dependency surface (download_orchestrator, spotify_client, lifecycle callback, context-key helper, status updater, DB) all injected via `CandidatesDeps`. """ from __future__ import annotations import logging import os from dataclasses import dataclass from typing import Any, Callable from core.downloads.track_metadata_backfill import hydrate_download_metadata from core.runtime_state import ( download_tasks, matched_context_lock, matched_downloads_context, tasks_lock, ) logger = logging.getLogger(__name__) @dataclass class CandidatesDeps: """Bundle of cross-cutting deps the candidate-fallback logic needs.""" download_orchestrator: Any spotify_client: Any run_async: Callable[..., Any] get_database: Callable[[], Any] update_task_status: Callable make_context_key: Callable[[str, str], str] on_download_completed: Callable def attempt_download_with_candidates(task_id, candidates, track, batch_id=None, deps: CandidatesDeps = None): """ Attempts to download with fallback candidate logic (matches GUI's retry_parallel_download_with_fallback). Returns True if successful, False if all candidates fail. """ # Sort candidates by match confidence first, then peer quality. Upstream # Soulseek validation already considers peer speed/slots/queue when scores # are close; preserve that signal here instead of flattening ties back to # arbitrary slskd response order. candidates.sort( key=lambda r: ( getattr(r, 'confidence', 0) or 0, getattr(r, 'quality_score', 0) or 0, getattr(r, 'upload_speed', 0) or 0, -(getattr(r, 'queue_length', 0) or 0), getattr(r, 'free_upload_slots', 0) or 0, getattr(r, 'size', 0) or 0, ), reverse=True, ) with tasks_lock: task = download_tasks.get(task_id) if not task: return False used_sources = task.get('used_sources', set()) # User-initiated manual picks (candidates modal) bypass quarantine # gates downstream. The user already accepted the risk by choosing # the file; we trust their selection over AcoustID disagreement so # repeated manual picks don't loop back into quarantine. user_manual_pick = bool(task.get('_user_manual_pick', False)) # Try each candidate until one succeeds (like GUI's fallback logic) for candidate_index, candidate in enumerate(candidates): # Check cancellation before each attempt with tasks_lock: if task_id not in download_tasks: logger.info(f"[Modal Worker] Task {task_id} was deleted during candidate {candidate_index + 1}") return False if download_tasks[task_id]['status'] == 'cancelled': logger.warning(f"[Modal Worker] Task {task_id} cancelled during candidate {candidate_index + 1}") # Don't call _on_download_completed for cancelled tasks as it can stop monitoring return False download_tasks[task_id]['current_candidate_index'] = candidate_index # Create source key to avoid duplicate attempts (like GUI) source_key = f"{candidate.username}_{candidate.filename}" if source_key in used_sources: logger.info(f"[Modal Worker] Skipping already tried source: {source_key}") continue # Blacklist check — skip sources the user has flagged as bad matches try: _bl_db = deps.get_database() if _bl_db.is_blacklisted(candidate.username, candidate.filename): logger.info(f"[Modal Worker] Skipping blacklisted source: {source_key}") continue except Exception as e: logger.debug("blacklist check failed: %s", e) # CRITICAL: Add source to used_sources IMMEDIATELY to prevent race conditions # This must happen BEFORE starting download to prevent multiple retries from picking same source with tasks_lock: if task_id in download_tasks: download_tasks[task_id]['used_sources'].add(source_key) logger.info(f"[Modal Worker] Marked source as used before download attempt: {source_key}") logger.info(f"[Modal Worker] Trying candidate {candidate_index + 1}/{len(candidates)}: {candidate.filename} (Confidence: {candidate.confidence:.2f})") try: # Update task status to downloading deps.update_task_status(task_id, 'downloading') # Prepare download - check if we have explicit album context from artist page track_info = {} with tasks_lock: if task_id in download_tasks: raw_track_info = download_tasks[task_id].get('track_info') track_info = raw_track_info if isinstance(raw_track_info, dict) else {} # Use explicit album/artist context if available (from artist album downloads) has_explicit_context = track_info and track_info.get('_is_explicit_album_download', False) if has_explicit_context: # Use the real Spotify album/artist data from the UI explicit_album = track_info.get('_explicit_album_context', {}) explicit_artist = track_info.get('_explicit_artist_context', {}) # Normalize artist context if it's a plain string (e.g. from wishlist spotify_data) if isinstance(explicit_artist, str): explicit_artist = {'name': explicit_artist} spotify_artist_context = { 'id': explicit_artist.get('id', 'explicit_artist'), 'name': explicit_artist.get('name', track.artists[0] if track.artists else 'Unknown'), 'genres': explicit_artist.get('genres', []) } # Handle both image_url formats (direct string or images array) album_image_url = None if explicit_album.get('image_url'): # Backend API returns image_url as direct string album_image_url = explicit_album.get('image_url') elif explicit_album.get('images'): # Fallback: images array format from Spotify API album_image_url = explicit_album.get('images', [{}])[0].get('url') spotify_album_context = { 'id': explicit_album.get('id', 'explicit_album'), 'name': explicit_album.get('name', track.album), 'release_date': explicit_album.get('release_date', ''), 'image_url': album_image_url, 'total_tracks': explicit_album.get('total_tracks', 0), 'total_discs': explicit_album.get('total_discs', 1), 'album_type': explicit_album.get('album_type', 'album'), 'artists': explicit_album.get('artists', [{'name': spotify_artist_context.get('name', '')}]) } logger.info(f"[Explicit Context] Using real album data: '{spotify_album_context['name']}' ({spotify_album_context['album_type']}, {spotify_album_context['total_discs']} disc(s))") else: # Fallback to generic context for playlists/wishlists # Extract album metadata from track_info if available (discovery enriches tracks with full album objects) fallback_album = track_info.get('album', {}) if track_info else {} if isinstance(fallback_album, str): fallback_album = {'name': fallback_album} elif not isinstance(fallback_album, dict): fallback_album = {} fallback_image_url = None fallback_images = fallback_album.get('images', []) if fallback_album.get('image_url'): fallback_image_url = fallback_album['image_url'] elif fallback_images and isinstance(fallback_images, list) and len(fallback_images) > 0: fallback_image_url = fallback_images[0].get('url') if isinstance(fallback_images[0], dict) else None spotify_artist_context = {'id': 'from_sync_modal', 'name': track.artists[0] if track.artists else 'Unknown', 'genres': []} # Preserve album-level artists for consistent folder naming _fallback_album_artists = fallback_album.get('artists', []) if not _fallback_album_artists: _fallback_album_artists = [{'name': track.artists[0]}] if track.artists else [] spotify_album_context = { 'id': fallback_album.get('id', 'from_sync_modal'), 'name': fallback_album.get('name', '') or track.album, 'release_date': fallback_album.get('release_date', ''), 'image_url': fallback_image_url, 'album_type': fallback_album.get('album_type', 'album'), 'total_tracks': fallback_album.get('total_tracks', 0), 'total_discs': fallback_album.get('total_discs', 1), 'artists': _fallback_album_artists } download_payload = candidate.__dict__ username = download_payload.get('username') filename = download_payload.get('filename') size = download_payload.get('size', 0) if not username or not filename: logger.error("[Modal Worker] Invalid candidate data: missing username or filename") continue # PROTECTION: Check if there's already an active download for this task current_download_id = None with tasks_lock: if task_id in download_tasks: current_download_id = download_tasks[task_id].get('download_id') if current_download_id: logger.info(f"[Modal Worker] Task {task_id} already has active download {current_download_id} - skipping new download attempt") logger.info("[Modal Worker] This prevents race condition where multiple retries start overlapping downloads") continue # Initiate download logger.info(f"[Modal Worker] Starting download: {username} / {os.path.basename(filename)}") download_id = deps.run_async(deps.download_orchestrator.download(username, filename, size)) if download_id: # Store context for post-processing with complete Spotify metadata (GUI PARITY) context_key = deps.make_context_key(username, filename) with matched_context_lock: # Create WebUI equivalent of GUI's SpotifyBasedSearchResult data structure enhanced_payload = download_payload.copy() # Extract clean Spotify metadata from track object (same as GUI) has_clean_spotify_data = track and hasattr(track, 'name') and hasattr(track, 'album') if has_clean_spotify_data: # Use clean Spotify metadata (matches GUI's SpotifyBasedSearchResult) enhanced_payload['spotify_clean_title'] = track.name enhanced_payload['spotify_clean_album'] = track.album enhanced_payload['spotify_clean_artist'] = track.artists[0] if track.artists else enhanced_payload.get('artist', '') # Preserve all artists for metadata tagging enhanced_payload['artists'] = [{'name': artist} for artist in track.artists] if track.artists else [] logger.info(f"[Context] Using clean Spotify metadata - Album: '{track.album}', Title: '{track.name}'") # Resolve track_number / disc_number and hydrate # lean album context. Extracted to # track_metadata_backfill.hydrate_download_metadata # — see that module for the precedence chain. # Why the extract: the inline pre-fix coupled # album-backfill to the "track_number missing" # branch. When wishlist payloads carried a poisoned # default-1 track_number (older routes.py used # ``.get('track_number', 1)``) the API call short- # circuited and the lean album_context (no # release_date / total_tracks for Deezer-sourced # discovery matches) survived untouched, producing # folders without a year subfolder. resolved = hydrate_download_metadata( track, track_info, spotify_album_context, deps.spotify_client, ) if resolved.track_number is not None: enhanced_payload['track_number'] = resolved.track_number enhanced_payload['disc_number'] = resolved.disc_number logger.info( f"[Context] Added track_number from {resolved.source}: " f"{resolved.track_number}, disc_number: {resolved.disc_number}" ) else: enhanced_payload.setdefault('track_number', 0) enhanced_payload.setdefault('disc_number', 1) logger.warning("[Context] No track_number found from any source") # Determine if this should be treated as album download # First check if we have explicit album context from artist page if has_explicit_context: is_album_context = True logger.info("[Context] Using explicit album context flag from artist page") else: # Fall back to guessing based on clean data is_album_context = ( track.album and track.album.strip() and track.album != "Unknown Album" and track.album.lower() != track.name.lower() # Album different from track ) else: # Fallback to original data enhanced_payload['spotify_clean_title'] = enhanced_payload.get('title', '') enhanced_payload['spotify_clean_album'] = enhanced_payload.get('album', '') enhanced_payload['spotify_clean_artist'] = enhanced_payload.get('artist', '') # Preserve existing artists array if available, otherwise create from single artist if 'artists' not in enhanced_payload and enhanced_payload.get('artist'): enhanced_payload['artists'] = [{'name': enhanced_payload['artist']}] enhanced_payload['track_number'] = track_info.get('track_number', 1) # Fallback when no clean Spotify data is_album_context = False logger.warning(f"[Context] Using fallback data - no clean Spotify metadata available, track_number={enhanced_payload['track_number']}") matched_downloads_context[context_key] = { "spotify_artist": spotify_artist_context, "spotify_album": spotify_album_context, "original_search_result": enhanced_payload, "is_album_download": is_album_context, # Critical fix: Use actual album context "has_clean_spotify_data": has_clean_spotify_data, # Flag for post-processing "task_id": task_id, # Add task_id for completion callbacks "batch_id": batch_id, # Add batch_id for completion callbacks "track_info": track_info, # Add track_info for playlist folder mode "_download_username": username, # Source username for AcoustID skip logic } if user_manual_pick: # The user explicitly picked this candidate via the # candidates modal — trust their metadata judgement # over AcoustID disagreement so manual picks don't # loop back into quarantine. Integrity + bit-depth # gates still run because those check the new file's # actual condition, not its identity. matched_downloads_context[context_key]['_skip_quarantine_check'] = 'acoustid' matched_downloads_context[context_key]['_user_manual_pick'] = True logger.info( "[Context] User manual pick — bypassing AcoustID for " "task=%s username=%s filename=%s", task_id, username, os.path.basename(filename), ) elif track_info and track_info.get('_skip_acoustid'): # Issue #797 — the album-download request had the # per-request "Skip AcoustID verification" toggle on. # Bypass only the AcoustID gate (same as a manual # pick); integrity + bit-depth still run. matched_downloads_context[context_key]['_skip_quarantine_check'] = 'acoustid' logger.info( "[Context] Skip-AcoustID toggle — bypassing AcoustID for " "task=%s filename=%s", task_id, os.path.basename(filename), ) logger.info(f"[Context] Set is_album_download: {is_album_context} (has clean data: {has_clean_spotify_data})") # Update task with successful download info with tasks_lock: if task_id in download_tasks: # PHASE 3: Final cancellation check after download started (GUI PARITY) if download_tasks[task_id]['status'] == 'cancelled': 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: logger.info( f"[CancelTrigger:candidates.worker_cancelled_during_download] " f"download_id={download_id} username={username} task_id={task_id}" ) deps.run_async(deps.download_orchestrator.cancel_download(download_id, username, remove=True)) logger.warning(f"Successfully cancelled active download {download_id}") except Exception as cancel_error: logger.error(f"Failed to cancel active download {download_id}: {cancel_error}") # Free worker slot if batch_id: deps.on_download_completed(batch_id, task_id, success=False) return False # Store download information - use real download ID from download_orchestrator # CRITICAL FIX: Trust the download ID returned by download_orchestrator.download() download_tasks[task_id]['download_id'] = download_id download_tasks[task_id]['username'] = username download_tasks[task_id]['filename'] = filename logger.info(f"[Modal Worker] Download started successfully for '{filename}'. Download ID: {download_id}") return True # Success! else: logger.error(f"[Modal Worker] Failed to start download for '{filename}'") # Reset status back to searching for next attempt with tasks_lock: if task_id in download_tasks: download_tasks[task_id]['status'] = 'searching' continue except Exception as e: import traceback logger.error(f"[Modal Worker] Error attempting download for '{candidate.filename}': {e}") traceback.print_exc() # Reset status back to searching for next attempt with tasks_lock: if task_id in download_tasks: download_tasks[task_id]['status'] = 'searching' continue # All candidates failed logger.error(f"[Modal Worker] All {len(candidates)} candidates failed for '{track.name}'") return False