soulsync/core/downloads/candidates.py
BoulderBadgeDad 2604704a27 #797: stop AcoustID quarantining correct non-English-artist downloads
AcoustID returns a recording's title/artist in their ORIGINAL script
(e.g. "久石譲" for Joe Hisaishi) while SoulSync's expected metadata is
romanized/English. A correct download then fails verification on two
walls: the title can never clear the 0.70 similarity bar cross-script,
and the only skip path that ignores the title required a near-perfect
0.95 fingerprint plus a resolved alias. Result: every non-English
artist trips it. Two complementary fixes, per the reporter's two ideas.

Graceful fix (automatic):
- New pure core/matching/script_compat.py detects when two strings are
  in genuinely different writing systems (CJK/Hangul/Cyrillic/Greek/
  Arabic/Hebrew/Thai vs Latin). Accented Latin (Beyoncé, Sigur Rós)
  stays Latin — no false trigger.
- acoustid_verification.py: when the EXPECTED artist and the matched
  artist span scripts AND the artist is confirmed via the existing
  MusicBrainz alias bridge, SKIP instead of quarantine, without the
  0.95 floor (the 0.80 trust floor already gates the fingerprint).
- Deliberately narrow: keyed on the ARTIST spanning scripts + being
  confirmed. A same-script artist with only a cross-script title keeps
  the stricter 0.95 floor, so the #607 wrong-file protection (Kendrick
  R.O.T.C, low-fingerprint Japanese-title) is untouched.

Per-request toggle (manual escape hatch):
- New "Skip AcoustID verification" checkbox in the download-missing
  modal beside "Force Download All".
- skip_acoustid threads request -> batch -> per-track track_info ->
  download context (same path as _playlist_folder_mode), landing on
  the existing _skip_quarantine_check='acoustid' bypass. No new
  mechanism; only the AcoustID gate is bypassed (integrity/bit-depth
  still run).

Tests:
- tests/matching/test_script_compat.py — script-boundary cases.
- test_acoustid_skip_logic.py — Joe Hisaishi SKIPs at 0.85; unconfirmed
  cross-script artist still FAILs; same-script low-fingerprint still
  FAILs.
- test_downloads_candidates.py — toggle injects the bypass; absent
  toggle keeps verification.

Full suite: 5169 passed; only pre-existing soundcloud /app env failures
remain. Zero regressions.
2026-06-05 16:02:01 -07:00

395 lines
23 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.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})")
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