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:
parent
58ee8d8a8a
commit
d34924e238
3 changed files with 105 additions and 53 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue