Apply same match validation to streaming download sources as Soulseek

Tidal, Qobuz, HiFi, and Deezer results were blindly taking the first
API result with minimal validation. Now all streaming sources use
score_track_match() — same 60% title / 30% artist / 10% duration
weighting as Soulseek, plus version detection penalties.

- web_server.py get_valid_candidates(): replaced loose title-sim check
  with matching engine scoring, version penalty for live/remix/acoustic
- download_orchestrator.py: optional expected_track param enables
  scoring in search_and_download_best (backward compatible)
- sync_service.py: passes spotify_track for validation
- Fixed wrong class name (MusicMatchingEngine not MatchingEngine)
This commit is contained in:
Broque Thomas 2026-04-04 22:01:29 -07:00
parent 58ee8d8a8a
commit d34924e238
3 changed files with 105 additions and 53 deletions

View file

@ -217,34 +217,80 @@ class DownloadOrchestrator:
# Fallback: empty results
return ([], [])
async def search_and_download_best(self, query: str) -> Optional[str]:
async def search_and_download_best(self, query: str, expected_track=None) -> Optional[str]:
"""
Search and automatically download the best result.
Supports Hybrid mode (uses configured source priority).
Args:
query: Search query string
expected_track: Optional SpotifyTrack for match validation (title/artist/duration)
Returns:
Download ID (str) or None if failed
"""
# 1. Search using configured mode/hybrid logic
results = await self.search(query)
# Unpack tuple (tracks, albums) - defensive handling
if isinstance(results, tuple):
tracks = results[0]
else:
tracks = results # Should not happen based on search() return type, but safe
if not tracks:
logger.warning(f"No results found for: {query}")
return None
# 2. Filter using Soulseek's quality preferences (Soulseek only)
# Streaming sources (YouTube/Tidal/Qobuz) handle quality internally
is_streaming = tracks[0].username in ('youtube', 'tidal', 'qobuz', 'hifi') if tracks else False
if is_streaming:
# 2. Filter and validate results
_streaming_sources = ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl')
is_streaming = tracks[0].username in _streaming_sources if tracks else False
if is_streaming and expected_track:
# Score streaming results against expected track metadata
from core.matching_engine import MusicMatchingEngine
me = MusicMatchingEngine()
expected_title = expected_track.name if hasattr(expected_track, 'name') else ''
expected_artists = expected_track.artists if hasattr(expected_track, 'artists') else []
expected_duration = expected_track.duration_ms if hasattr(expected_track, 'duration_ms') else 0
expected_title_lower = (expected_title or '').lower()
_version_kw = ['remix', 'live', 'acoustic', 'instrumental', 'radio edit',
'extended', 'slowed', 'sped up', 'reverb', 'karaoke']
expected_is_version = any(kw in expected_title_lower for kw in _version_kw)
scored = []
for r in tracks:
confidence, _ = me.score_track_match(
source_title=expected_title,
source_artists=expected_artists,
source_duration_ms=expected_duration,
candidate_title=r.title or '',
candidate_artists=[r.artist] if r.artist else [],
candidate_duration_ms=r.duration or 0,
)
# Version penalty
r_title_lower = (r.title or '').lower()
if not expected_is_version:
for kw in _version_kw:
if kw in r_title_lower and kw not in expected_title_lower:
confidence *= 0.4
break
if confidence >= 0.55:
r._match_confidence = confidence
scored.append(r)
if scored:
scored.sort(key=lambda x: x._match_confidence, reverse=True)
filtered_results = scored
logger.info(f"🎵 Streaming validation: {len(scored)}/{len(tracks)} passed "
f"(best: {scored[0]._match_confidence:.2f})")
else:
logger.warning(f"⚠️ No streaming results passed validation for: {query}")
return None
elif is_streaming:
filtered_results = tracks
else:
filtered_results = self.soulseek.filter_results_by_quality_preference(tracks) if self.soulseek else tracks
@ -255,13 +301,13 @@ class DownloadOrchestrator:
# 3. Download the best match
best_result = filtered_results[0]
quality_info = f"{best_result.quality.upper()}"
if best_result.bitrate:
quality_info += f" {best_result.bitrate}kbps"
logger.info(f"Downloading best match: {best_result.filename} ({quality_info}) from {best_result.username}")
# Use orchestrator's download method to route correctly
return await self.download(best_result.username, best_result.filename, best_result.size)

View file

@ -629,7 +629,7 @@ class PlaylistSyncService:
query = self.matching_engine.generate_download_query(match_result.spotify_track)
logger.info(f"Attempting to download: {query}")
download_id = await self.soulseek_client.search_and_download_best(query)
download_id = await self.soulseek_client.search_and_download_best(query, expected_track=match_result.spotify_track)
if download_id:
downloaded_count += 1

View file

@ -24851,59 +24851,65 @@ def get_valid_candidates(results, spotify_track, query):
return []
# Streaming sources (YouTube, Tidal, Qobuz, HiFi, Deezer) return structured API results
# with proper artist/title metadata — bypass Soulseek filename-parsing matching engine
# but still verify artist+title match to avoid wrong-track downloads (e.g. same title, different artist)
# with proper artist/title metadata — score using the same matching engine as Soulseek
_streaming_sources = ("youtube", "tidal", "qobuz", "hifi", "deezer_dl")
if results[0].username in _streaming_sources:
source_label = results[0].username.replace('_dl', '').title()
# Verify artist/title against expected track before trusting
expected_artists = [a.lower().strip() for a in (spotify_track.artists or [])] if spotify_track else []
expected_title = matching_engine.normalize_string(spotify_track.name) if spotify_track and spotify_track.name else ''
expected_artists = spotify_track.artists if spotify_track else []
expected_title = spotify_track.name if spotify_track else ''
expected_duration = spotify_track.duration_ms if spotify_track else 0
from difflib import SequenceMatcher
verified = []
# Detect if the expected track is a specific version (live, remix, acoustic, etc.)
expected_title_lower = (expected_title or '').lower()
_version_keywords = ['remix', 'live', 'acoustic', 'instrumental', 'radio edit',
'extended', 'slowed', 'sped up', 'reverb', 'karaoke']
expected_is_version = any(kw in expected_title_lower for kw in _version_keywords)
scored = []
for r in results:
r_artist = matching_engine.normalize_string(r.artist or '')
r_title = matching_engine.normalize_string(r.title or '')
# Score using matching engine's generic scorer (same weights as Soulseek)
confidence, match_type = matching_engine.score_track_match(
source_title=expected_title,
source_artists=expected_artists,
source_duration_ms=expected_duration,
candidate_title=r.title or '',
candidate_artists=[r.artist] if r.artist else [],
candidate_duration_ms=r.duration or 0,
)
# Title must be a reasonable match
if expected_title and r_title:
title_sim = SequenceMatcher(None, expected_title, r_title).ratio()
else:
title_sim = 0.5 # No title to compare — allow through
# Artist must overlap with at least one expected artist
artist_match = False
if not expected_artists:
artist_match = True # No artist info — can't filter
else:
r_artist_norm = r_artist.lower()
for exp_artist in expected_artists:
exp_norm = matching_engine.normalize_string(exp_artist)
if not exp_norm or not r_artist_norm:
continue
# Check substring containment or similarity
if exp_norm in r_artist_norm or r_artist_norm in exp_norm:
artist_match = True
# Version detection penalty — reject live/remix/acoustic when expecting original
r_title_lower = (r.title or '').lower()
is_wrong_version = False
if not expected_is_version:
# Expecting original — penalize versions
for kw in _version_keywords:
if kw in r_title_lower and kw not in expected_title_lower:
confidence *= 0.4 # Heavy penalty
is_wrong_version = True
break
artist_sim = SequenceMatcher(None, exp_norm, r_artist_norm).ratio()
if artist_sim >= 0.6:
artist_match = True
else:
# Expecting specific version — penalize results that don't have it
for kw in _version_keywords:
if kw in expected_title_lower and kw not in r_title_lower:
confidence *= 0.5
is_wrong_version = True
break
if title_sim >= 0.5 and artist_match:
r.confidence = title_sim
r.version_type = 'original'
verified.append(r)
r.confidence = confidence
r.version_type = 'wrong_version' if is_wrong_version else match_type
if confidence >= 0.55:
scored.append(r)
if verified:
# Sort by confidence (best title match first)
verified.sort(key=lambda x: x.confidence, reverse=True)
print(f"🎵 [{source_label}] Streaming source — {len(verified)}/{len(results)} candidates passed artist/title verification")
return verified
if scored:
# Sort by confidence (best match first)
scored.sort(key=lambda x: x.confidence, reverse=True)
best = scored[0]
print(f"🎵 [{source_label}] {len(scored)}/{len(results)} candidates passed validation "
f"(best: {best.confidence:.2f} '{best.artist} - {best.title}')")
return scored
else:
print(f"⚠️ [{source_label}] No streaming results matched expected artist/title — falling through to matching engine")
print(f"⚠️ [{source_label}] No streaming results passed validation (threshold: 0.55) — falling through to matching engine")
# Fall through to standard matching engine below
# Uses the existing, powerful matching engine for scoring (Soulseek P2P results)