When a task failed AcoustID verification and got quarantined, opening the candidates modal and manually picking a different file would just re-quarantine it. The manual-pick path through `_attempt_download_with_candidates` ran full post-processing with no quarantine bypass — so if the alternate file disagreed with AcoustID's stored metadata too (common for live versions, remasters, regional title differences, fingerprint coverage gaps) the file landed right back in quarantine. User got stuck in the loop. The Approve button on quarantined rows already handles the "I want this exact file" case via `_skip_quarantine_check='all'`. The candidates modal handles the "I want a different file" case — same user intent, opposite direction, but the bypass plumbing didn't carry through. `/api/downloads/task/<id>/download-candidate` already sets `task['_user_manual_pick'] = True`. `attempt_download_with_candidates` now reads that flag under tasks_lock alongside `used_sources` and, when set, injects `_skip_quarantine_check='acoustid'` plus `_user_manual_pick=True` into the stored `matched_downloads_context` entry. The acoustid-only scope is deliberate: integrity + bit-depth gates still run because those check the new file's actual condition (corruption, sample rate) rather than its identity — only the metadata-mismatch gate is the user-override case. Auto-search picks (the normal task-worker path) leave the flag unset and continue to run full AcoustID verification, preserving the existing safety net for non-user-initiated downloads. Tests: - positive: manual-pick task → stored context has `_skip_quarantine_check='acoustid'` and `_user_manual_pick=True` - negative: auto-search task → stored context has neither key, AcoustID still runs as before Full suite 3976 pass.
408 lines
24 KiB
Python
408 lines
24 KiB
Python
"""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.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}'")
|
|
|
|
# Get track_number and disc_number — prefer track data we already have,
|
|
# fall back to detailed API call only if needed
|
|
got_track_number = False
|
|
|
|
# 1. Try track_info (from frontend, has album track data)
|
|
tn = track_info.get('track_number', 0) if isinstance(track_info, dict) else 0
|
|
dn = (track_info.get('disc_number') or 1) if isinstance(track_info, dict) else 1
|
|
if tn and tn > 0:
|
|
enhanced_payload['track_number'] = tn
|
|
enhanced_payload['disc_number'] = dn
|
|
got_track_number = True
|
|
logger.info(f"[Context] Added track_number from track_info: {tn}, disc_number: {dn}")
|
|
|
|
# 2. Try the track object itself (from album tracks response)
|
|
if not got_track_number and hasattr(track, 'track_number') and track.track_number:
|
|
enhanced_payload['track_number'] = track.track_number
|
|
enhanced_payload['disc_number'] = getattr(track, 'disc_number', 1) or 1
|
|
got_track_number = True
|
|
logger.info(f"[Context] Added track_number from track object: {track.track_number}, disc_number: {enhanced_payload['disc_number']}")
|
|
|
|
# 3. Last resort — fetch from metadata source API
|
|
if not got_track_number and hasattr(track, 'id') and track.id:
|
|
try:
|
|
detailed_track = deps.spotify_client.get_track_details(track.id)
|
|
if detailed_track and detailed_track.get('track_number'):
|
|
enhanced_payload['track_number'] = detailed_track['track_number']
|
|
enhanced_payload['disc_number'] = detailed_track.get('disc_number') or 1
|
|
got_track_number = True
|
|
logger.info(f"[Context] Added track_number from API: {detailed_track['track_number']}, disc_number: {enhanced_payload['disc_number']}")
|
|
|
|
# Backfill album metadata from detailed track when context
|
|
# has incomplete data (missing release_date, total_tracks, etc.)
|
|
if isinstance(detailed_track.get('album'), dict):
|
|
dt_album = detailed_track['album']
|
|
if not spotify_album_context.get('release_date') and dt_album.get('release_date'):
|
|
spotify_album_context['release_date'] = dt_album['release_date']
|
|
logger.info(f"[Context] Backfilled release_date from API: {dt_album['release_date']}")
|
|
if not spotify_album_context.get('album_type') and dt_album.get('album_type'):
|
|
spotify_album_context['album_type'] = dt_album['album_type']
|
|
if not spotify_album_context.get('total_tracks') and dt_album.get('total_tracks'):
|
|
spotify_album_context['total_tracks'] = dt_album['total_tracks']
|
|
if not spotify_album_context.get('id') and dt_album.get('id'):
|
|
spotify_album_context['id'] = dt_album['id']
|
|
if not spotify_album_context.get('image_url') and dt_album.get('images'):
|
|
spotify_album_context['image_url'] = dt_album['images'][0].get('url', '')
|
|
except Exception as e:
|
|
logger.error(f"[Context] API track details failed: {e}")
|
|
|
|
if not got_track_number:
|
|
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),
|
|
)
|
|
|
|
logger.info(f"[Context] Set is_album_download: {is_album_context} (has clean data: {has_clean_spotify_data})")
|
|
logger.debug(f"[Debug] Context creation - track_info: {track_info is not None}, playlist_folder_mode: {track_info.get('_playlist_folder_mode', False) if track_info else False}")
|
|
|
|
# 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
|