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 # Fallback: empty results
return ([], []) 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. Search and automatically download the best result.
Supports Hybrid mode (uses configured source priority). Supports Hybrid mode (uses configured source priority).
Args: Args:
query: Search query string query: Search query string
expected_track: Optional SpotifyTrack for match validation (title/artist/duration)
Returns: Returns:
Download ID (str) or None if failed Download ID (str) or None if failed
""" """
# 1. Search using configured mode/hybrid logic # 1. Search using configured mode/hybrid logic
results = await self.search(query) results = await self.search(query)
# Unpack tuple (tracks, albums) - defensive handling # Unpack tuple (tracks, albums) - defensive handling
if isinstance(results, tuple): if isinstance(results, tuple):
tracks = results[0] tracks = results[0]
else: else:
tracks = results # Should not happen based on search() return type, but safe tracks = results # Should not happen based on search() return type, but safe
if not tracks: if not tracks:
logger.warning(f"No results found for: {query}") logger.warning(f"No results found for: {query}")
return None return None
# 2. Filter using Soulseek's quality preferences (Soulseek only) # 2. Filter and validate results
# Streaming sources (YouTube/Tidal/Qobuz) handle quality internally _streaming_sources = ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl')
is_streaming = tracks[0].username in ('youtube', 'tidal', 'qobuz', 'hifi') if tracks else False is_streaming = tracks[0].username in _streaming_sources if tracks else False
if is_streaming:
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 filtered_results = tracks
else: else:
filtered_results = self.soulseek.filter_results_by_quality_preference(tracks) if self.soulseek else tracks 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 # 3. Download the best match
best_result = filtered_results[0] best_result = filtered_results[0]
quality_info = f"{best_result.quality.upper()}" quality_info = f"{best_result.quality.upper()}"
if best_result.bitrate: if best_result.bitrate:
quality_info += f" {best_result.bitrate}kbps" quality_info += f" {best_result.bitrate}kbps"
logger.info(f"Downloading best match: {best_result.filename} ({quality_info}) from {best_result.username}") logger.info(f"Downloading best match: {best_result.filename} ({quality_info}) from {best_result.username}")
# Use orchestrator's download method to route correctly # Use orchestrator's download method to route correctly
return await self.download(best_result.username, best_result.filename, best_result.size) 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) 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) download_id = await self.soulseek_client.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

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