From 35fdef76cee3e0565431146cf388253c489ec61c Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Thu, 7 Aug 2025 00:37:44 -0700 Subject: [PATCH] better matching on slskd --- .../matching_engine.cpython-312.pyc | Bin 14972 -> 25956 bytes core/matching_engine.py | 291 +++++++++++++++++- ui/pages/__pycache__/artists.cpython-312.pyc | Bin 238974 -> 240427 bytes ui/pages/__pycache__/sync.cpython-312.pyc | Bin 232052 -> 233645 bytes ui/pages/artists.py | 77 +++-- ui/pages/sync.py | 95 ++++-- 6 files changed, 393 insertions(+), 70 deletions(-) diff --git a/core/__pycache__/matching_engine.cpython-312.pyc b/core/__pycache__/matching_engine.cpython-312.pyc index 877fd32df47fe8fca26f20affd97dd1c2d915d81..8605fe694d3877d4eae2561f970e22746811f06d 100644 GIT binary patch delta 10642 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 Tuple[str, bool]: + """ + Detect if album name appears in track title and return cleaned version. + Returns (cleaned_title, album_detected) tuple. + """ + if not track_title: + return "", False + + original_title = track_title + title_lower = track_title.lower() + + # Common patterns where album name appears in track titles + album_patterns = [ + r'\s*-\s*(.+)$', # "Track - Album" (most common) + r'\s*\|\s*(.+)$', # "Track | Album" + r'\s*\(\s*(.+)\s*\)$' # "Track (Album)" + ] + + # If we have album name, check if it appears in the title + if album_name: + album_clean = album_name.lower().strip() + + for pattern in album_patterns: + match = re.search(pattern, track_title) + if match: + potential_album = match.group(1).lower().strip() + + # Check if the extracted part matches the album name with better fuzzy matching + similarity_threshold = 0.8 + + # Calculate similarity between potential album and actual album + if potential_album == album_clean: + similarity = 1.0 # Exact match + elif potential_album in album_clean or album_clean in potential_album: + # Substring match - calculate how much overlap + shorter = min(len(potential_album), len(album_clean)) + longer = max(len(potential_album), len(album_clean)) + similarity = shorter / longer if longer > 0 else 0.0 + else: + # Use string similarity for fuzzy matching + similarity = self.similarity_score(potential_album, album_clean) + + if similarity >= similarity_threshold: + # Remove the album part from the title + cleaned_title = re.sub(pattern, '', track_title).strip() + + # SAFETY CHECK: Don't return empty or too-short titles + if not cleaned_title or len(cleaned_title.strip()) < 2: + print(f"⚠️ Album removal would create empty title: '{original_title}' → '{cleaned_title}' - keeping original") + return track_title, False + + # SAFETY CHECK: Don't remove if it would leave only articles or very short words + words = cleaned_title.split() + meaningful_words = [w for w in words if len(w) > 2 and w.lower() not in ['the', 'and', 'or', 'of', 'a', 'an']] + if not meaningful_words: + print(f"⚠️ Album removal would leave only short words: '{original_title}' → '{cleaned_title}' - keeping original") + return track_title, False + + print(f"🎵 Detected album in title: '{original_title}' → '{cleaned_title}' (removed: '{match.group(1)}', similarity: {similarity:.2f})") + return cleaned_title, True + + # Fallback: detect common album-like suffixes even without album context + # Look for patterns that might be album names (usually after dash) + dash_pattern = r'\s*-\s*([A-Za-z][A-Za-z0-9\s&\-\']{3,30})$' + match = re.search(dash_pattern, track_title) + if match: + potential_album_part = match.group(1).strip() + + # Heuristics: likely an album name if it: + # - Doesn't contain common track descriptors + # - Is reasonable length (4-30 chars) + # - Doesn't look like a feature/remix indicator + exclude_patterns = [ + r'\b(remix|mix|edit|version|live|acoustic|instrumental|demo|feat|ft|featuring)\b' + ] + + is_likely_album = True + for exclude_pattern in exclude_patterns: + if re.search(exclude_pattern, potential_album_part.lower()): + is_likely_album = False + break + + if is_likely_album and 4 <= len(potential_album_part) <= 30: + cleaned_title = re.sub(dash_pattern, '', track_title).strip() + print(f"🎵 Heuristic album detection: '{original_title}' → '{cleaned_title}' (removed: '{potential_album_part}')") + return cleaned_title, True + + return track_title, False + + def generate_download_queries(self, spotify_track: SpotifyTrack) -> List[str]: + """ + Generate multiple search query variations for better matching. + Returns queries in order of preference (cleaned titles first, then original). + """ + queries = [] + + if not spotify_track.artists: + # No artist info - just use track name variations + queries.append(self.clean_title(spotify_track.name)) + return queries + + artist = self.clean_artist(spotify_track.artists[0]) + original_title = spotify_track.name + + # Get album name if available - try multiple attribute names + album_name = None + for attr in ['album', 'album_name', 'album_title']: + album_name = getattr(spotify_track, attr, None) + if album_name: + break + + # PRIORITY 1: Try removing potential album from title FIRST + cleaned_title, album_detected = self.detect_album_in_title(original_title, album_name) + if album_detected and cleaned_title != original_title: + cleaned_track = self.clean_title(cleaned_title) + if cleaned_track: + queries.append(f"{artist} {cleaned_track}".strip()) + print(f"🎯 PRIORITY 1: Album-cleaned query: '{artist} {cleaned_track}'") + + # PRIORITY 2: Try with just the first part before any dash/parentheses + simple_patterns = [ + r'^([^-\(]+)', # Everything before first dash or parenthesis + r'^([^-]+)', # Everything before first dash only + ] + + for pattern in simple_patterns: + match = re.search(pattern, original_title.strip()) + if match: + simple_title = match.group(1).strip() + if simple_title and len(simple_title) >= 3: # Avoid too-short titles + simple_clean = self.clean_title(simple_title) + if simple_clean and simple_clean not in [self.clean_title(q.split(' ', 1)[1]) for q in queries if ' ' in q]: + queries.append(f"{artist} {simple_clean}".strip()) + print(f"🎯 PRIORITY 2: Simple-cleaned query: '{artist} {simple_clean}'") + + # PRIORITY 3: Original query (ONLY if no album was detected or if it's different) + original_track_clean = self.clean_title(original_title) + if not album_detected or not queries: # Only add original if no album detected or no other queries + if original_track_clean not in [q.split(' ', 1)[1] for q in queries if ' ' in q]: + queries.append(f"{artist} {original_track_clean}".strip()) + print(f"🎯 PRIORITY 3: Original query: '{artist} {original_track_clean}'") + + # Remove duplicates while preserving order + unique_queries = [] + seen = set() + for query in queries: + if query.lower() not in seen: + unique_queries.append(query) + seen.add(query.lower()) + + return unique_queries + def generate_download_query(self, spotify_track: SpotifyTrack) -> str: - """Generate optimized search query for downloading tracks""" - # Use artist + track name for more precise matching - if spotify_track.artists: - # Use first artist and clean track name - artist = self.clean_artist(spotify_track.artists[0]) - track = self.clean_title(spotify_track.name) - return f"{artist} {track}".strip() - else: - # Fallback to just track name if no artist - return self.clean_title(spotify_track.name) + """ + Generate optimized search query for downloading tracks. + Returns the most specific query (backward compatibility). + """ + queries = self.generate_download_queries(spotify_track) + return queries[0] if queries else "" def calculate_slskd_match_confidence(self, spotify_track: SpotifyTrack, slskd_track: TrackResult) -> float: @@ -306,6 +454,129 @@ class MusicMatchingEngine: return confident_results + def detect_version_type(self, filename: str) -> Tuple[str, float]: + """ + Detect version type from filename and return (version_type, penalty). + Penalties are applied to prefer original versions over variants. + """ + if not filename: + return 'original', 0.0 + + filename_lower = filename.lower() + + # Define version patterns and their penalties (higher penalty = lower priority) + version_patterns = { + 'remix': { + 'patterns': [r'\bremix\b', r'\brmx\b', r'\brework\b', r'\bedit\b(?!ion)'], + 'penalty': 0.15 # -15% penalty for remixes + }, + 'live': { + 'patterns': [r'\blive\b', r'\bconcert\b', r'\btour\b', r'\bperformance\b'], + 'penalty': 0.20 # -20% penalty for live versions + }, + 'acoustic': { + 'patterns': [r'\bacoustic\b', r'\bunplugged\b', r'\bstripped\b'], + 'penalty': 0.12 # -12% penalty for acoustic + }, + 'instrumental': { + 'patterns': [r'\binstrumental\b', r'\bkaraoke\b', r'\bminus one\b'], + 'penalty': 0.25 # -25% penalty for instrumentals (most different from original) + }, + 'radio': { + 'patterns': [r'\bradio\s*edit\b', r'\bradio\s*version\b', r'\bclean\s*edit\b'], + 'penalty': 0.08 # -8% penalty for radio edits (minor difference) + }, + 'extended': { + 'patterns': [r'\bextended\b', r'\bfull\s*version\b', r'\blong\s*version\b'], + 'penalty': 0.05 # -5% penalty for extended (close to original) + }, + 'demo': { + 'patterns': [r'\bdemo\b', r'\broughcut\b', r'\bunreleased\b'], + 'penalty': 0.18 # -18% penalty for demos + }, + 'explicit': { + 'patterns': [r'\bexplicit\b', r'\buncensored\b'], + 'penalty': 0.02 # -2% minor penalty (might be preferred by some) + } + } + + # Check each version type + for version_type, config in version_patterns.items(): + for pattern in config['patterns']: + if re.search(pattern, filename_lower): + return version_type, config['penalty'] + + # No version indicators found - assume original + return 'original', 0.0 + + def calculate_slskd_match_confidence_enhanced(self, spotify_track: SpotifyTrack, slskd_track: TrackResult) -> Tuple[float, str]: + """ + Enhanced version of calculate_slskd_match_confidence with version-aware scoring. + Returns (confidence, version_type) tuple. + """ + # Get base confidence using existing logic + base_confidence = self.calculate_slskd_match_confidence(spotify_track, slskd_track) + + # Detect version type and get penalty + version_type, penalty = self.detect_version_type(slskd_track.filename) + + # Apply version penalty + if version_type != 'original': + adjusted_confidence = max(0.0, base_confidence - penalty) + # Store version info on the track object for UI display + slskd_track.version_type = version_type + slskd_track.version_penalty = penalty + else: + adjusted_confidence = base_confidence + slskd_track.version_type = 'original' + slskd_track.version_penalty = 0.0 + + return adjusted_confidence, version_type + + def find_best_slskd_matches_enhanced(self, spotify_track: SpotifyTrack, slskd_results: List[TrackResult]) -> List[TrackResult]: + """ + Enhanced version of find_best_slskd_matches with version-aware scoring. + Returns candidates sorted by adjusted confidence (preferring originals). + """ + if not slskd_results: + return [] + + scored_results = [] + for slskd_track in slskd_results: + # Use enhanced confidence calculation + confidence, version_type = self.calculate_slskd_match_confidence_enhanced(spotify_track, slskd_track) + + # Store the adjusted confidence and version info + slskd_track.confidence = confidence + slskd_track.version_type = getattr(slskd_track, 'version_type', 'original') + scored_results.append(slskd_track) + + # Sort by confidence score (descending), then by version preference, then by size + def sort_key(r): + # Primary: confidence score + # Secondary: prefer originals (original=0, others=penalty value for tie-breaking) + version_priority = 0.0 if r.version_type == 'original' else getattr(r, 'version_penalty', 0.1) + # Tertiary: file size + return (r.confidence, -version_priority, r.size) + + sorted_results = sorted(scored_results, key=sort_key, reverse=True) + + # Filter out very low-confidence results + # Lower the threshold to 0.45 to account for version penalties and album-in-title scenarios + confident_results = [r for r in sorted_results if r.confidence > 0.45] + + # Debug logging for troubleshooting + if scored_results and not confident_results: + print(f"⚠️ DEBUG: Found {len(scored_results)} scored results but none met confidence threshold 0.45") + for i, result in enumerate(sorted_results[:3]): # Show top 3 + print(f" {i+1}. {result.confidence:.3f} - {getattr(result, 'version_type', 'unknown')} - {result.filename[:60]}...") + elif confident_results: + print(f"✅ DEBUG: {len(confident_results)} results passed confidence threshold 0.45") + for i, result in enumerate(confident_results[:3]): # Show top 3 + print(f" {i+1}. {result.confidence:.3f} - {getattr(result, 'version_type', 'unknown')} - {result.filename[:60]}...") + + return confident_results + def calculate_album_confidence(self, spotify_album, plex_album_info: Dict[str, Any]) -> float: """Calculate confidence score for album matching""" if not spotify_album or not plex_album_info: diff --git a/ui/pages/__pycache__/artists.cpython-312.pyc b/ui/pages/__pycache__/artists.cpython-312.pyc index 305a229986d0017611eb3681b14795d3d38c79ed..d7d56fcf65719407a7121f261ccb38075e945e41 100644 GIT binary patch delta 9264 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
R7iU;U*YVSLASU9ov9We-o zVE73U8<9%0UhiF$GdC-*q_9+f#06B8@N+cb^hTyiYzj zCAzosVpD@X?)A_{duVdK4A}LwSQR^!S2>%Mg5MVf2JWV!M)VHh z_4KTfiK%zlzI;4WBR0{=H}eBJ`L1fueIQc2gr60Ue=OX>YSk+1g#m=W6*qh$u89CE z{^h)w%_@!m693cV9@{aCH-O5ZU-+7%L=o3LGy*;CnT$XGi}Jh0?7G4D)F&cX7_8c& zPsLU*Sb?^S;?J@Ay6WonUDvF%1^e+3{M27YekW$e>Z8x(G3#2J73%sYFOnDp3`9#E_`#a$UPiW`96YmJq8lJIA z-0`F6Kx( zfA`-!!;`sB{!c>VF4UVIG~(f1xahe~vK_2DG*u!*H80UO z51FQQ@Rb!HOcM9|$r9LxHmke?EiuwxzDWy4-qLzlHaqJ+cAu9R%xvFcc8C|bc!FD( zGS$3K^z?TXv-DwC?#)15j0})n;X3{?KqkU%+!-L-a>pMA$X2GmveIn`fifB9(_n(} zidBZgZf!!Kyl#X&I3i3IK)m*9m@G4x_A&QM*eOb$5@rMbJ4&VzOYdkI4=?ZtBXDlC z^o91?l4!Y^rQ;LiJ$MJ>6J=}RZNSG8Wlz|SOA_Te)8Ewjq>!rQG{;(r|jw5r) z$bp(OS1tj%f!@iZ;ujcar~b!dhFu;E(;tj3^AO4Fl%|XZ!yGgPleEVivY$Ei0?*DD z3?6HlTFeXf7E}6v?{w4k(EB!4m&ioR*WA=Ft(Qt<7r2N~Pto?6s%?Hs_BQ)GPAfuh zX*7rnwq(#R5Mj8y zLbjy*(>d&r;@6+7$Xgf5c@=$&|x*bSZJ>W8G3YS$GF&UH&B78)(Dk zE|>Nv^*yP3i0&&d@Ibe7FN>Md@B3$%(jQXU5V{|6Vp#82>L}G;O!U_ReenEm9=(qC zWAq9+9{OqO3YjWs%WhjKvtXPCt7L00L1${pIyt6`f&2LfZ}EJl+)U{=m*&$_53_t) zt9Ka`Bfhkbw!tl|TSvp5sQt1|&Xv(^xa~_4uQxe!W;)WHY6ypwPzD>W+(@j`asNh{ z1S_?VHpbh9ZVp3}p;@D4)VqCc|Qe zB@BFCs(j|D3mFzM%w}k-R1nT*QvWy9ex_bv@L{NCIK-eayv|U=pnLUeOnuIvJHEaJ zu5s~ghCU1p^G}rE8N0=G^wQSgm=>y&YMiL-)Gpj|m;IH&%c6~}qUZ~EHEo-`0q{rM zvqOf*>J0pv;d$KXPt+anw4xGwS)rq^8m@i0L(T_VMF6y`ntns!?~MWR*)b5my7f(k Izq;K20tc&YRR910 delta 8048 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 plhnh$^(YY78Fnyei$|GCVz4uuWH`aFN3JXq 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 diff --git a/ui/pages/artists.py b/ui/pages/artists.py index 5bc082ce..7aea5362 100644 --- a/ui/pages/artists.py +++ b/ui/pages/artists.py @@ -1782,15 +1782,28 @@ class DownloadMissingAlbumTracksModal(QDialog): print("✅ Album modal initialization complete") def generate_smart_search_queries(self, artist_name, track_name): - """Generate multiple search query variations for better matching""" - queries = [] - - # Step 1: Use the original, full track name + """Generate smart search query variations with album-in-title detection""" + # Create a mock spotify track object for the matching engine + class MockSpotifyTrack: + def __init__(self, name, artists, album=None): + self.name = name + self.artists = artists if isinstance(artists, list) else [artists] if artists else [] + self.album = album + + # Pass album information if we're in the context of an album + album_name = getattr(self, 'album', None) + album_title = album_name.name if hasattr(album_name, 'name') else str(album_name) if album_name else None + + mock_track = MockSpotifyTrack(track_name, [artist_name] if artist_name else [], album_title) + + # Use the enhanced matching engine to generate queries + queries = self.matching_engine.generate_download_queries(mock_track) + + # Add some legacy fallback queries for compatibility + legacy_queries = [] + + # Add first word of artist approach (legacy compatibility) if artist_name: - # Attempt 1: Full Artist + Full Track Name - queries.append(f"{artist_name} {track_name}".strip()) - - # Attempt 2: Full Track Name + First Word of Artist artist_words = artist_name.split() if artist_words: first_word = artist_words[0] @@ -1798,26 +1811,26 @@ class DownloadMissingAlbumTracksModal(QDialog): first_word = artist_words[1] if len(first_word) > 1: - queries.append(f"{track_name} {first_word}".strip()) - - # Attempt 3: Full Track Name only - queries.append(track_name.strip()) - - # Step 2: Clean the track name for the final fallback - cleaned_name = re.sub(r'\s*\([^)]*\)', '', track_name).strip() - cleaned_name = re.sub(r'\s*\[[^\]]*\]', '', cleaned_name).strip() - - # Attempt 4: Cleaned Track Name only (if different from original) - if cleaned_name and cleaned_name.lower() != track_name.lower(): - queries.append(cleaned_name.strip()) - + legacy_queries.append(f"{track_name} {first_word}".strip()) + + # Add track-only query + legacy_queries.append(track_name.strip()) + + # Combine enhanced queries with legacy fallbacks + all_queries = queries + legacy_queries + # Remove duplicates while preserving order unique_queries = [] - for query in queries: - if query and query not in unique_queries: + seen = set() + for query in all_queries: + if query and query.lower() not in seen: unique_queries.append(query) + seen.add(query.lower()) + + print(f"🧠 Generated {len(unique_queries)} smart queries for '{track_name}' (enhanced with album detection)") + for i, query in enumerate(unique_queries): + print(f" {i+1}. '{query}'") - print(f"🧠 Generated {len(unique_queries)} smart queries for '{track_name}'. Sequence: {unique_queries}") return unique_queries def setup_ui(self): @@ -2663,8 +2676,8 @@ class DownloadMissingAlbumTracksModal(QDialog): if not results: return [] - # Get initial confident matches based on title, bitrate, etc. - initial_candidates = self.matching_engine.find_best_slskd_matches(spotify_track, results) + # Get initial confident matches with version-aware scoring + initial_candidates = self.matching_engine.find_best_slskd_matches_enhanced(spotify_track, results) if not initial_candidates: print(f"⚠️ No initial candidates found for '{spotify_track.name}' from query '{query}'.") @@ -2695,7 +2708,17 @@ class DownloadMissingAlbumTracksModal(QDialog): if verified_candidates: best_confidence = verified_candidates[0].confidence - print(f"✅ Found {len(verified_candidates)} VERIFIED matches for '{spotify_track.name}'. Best score: {best_confidence:.2f}") + best_version = getattr(verified_candidates[0], 'version_type', 'unknown') + print(f"✅ Found {len(verified_candidates)} VERIFIED matches for '{spotify_track.name}'. Best: {best_confidence:.2f} ({best_version})") + + # Log version breakdown for debugging + version_counts = {} + for candidate in verified_candidates[:5]: # Show top 5 + version = getattr(candidate, 'version_type', 'unknown') + version_counts[version] = version_counts.get(version, 0) + 1 + penalty = getattr(candidate, 'version_penalty', 0.0) + print(f" 🎵 {candidate.confidence:.2f} - {version} (penalty: {penalty:.2f}) - {candidate.filename[:100]}...") + else: print(f"⚠️ No verified matches found for '{spotify_track.name}' after checking file paths.") diff --git a/ui/pages/sync.py b/ui/pages/sync.py index 06a2f95c..fa7b6d6d 100644 --- a/ui/pages/sync.py +++ b/ui/pages/sync.py @@ -3580,45 +3580,66 @@ class DownloadMissingTracksModal(QDialog): def generate_smart_search_queries(self, artist_name, track_name): """ - Generate multiple search query variations in the specific fallback order - requested by the user. + Generate smart search query variations with album-in-title detection. + Enhanced version with fallback strategies. """ - import re - queries = [] - - # --- Step 1: Use the original, full track name --- + # Create a mock spotify track object for the matching engine + class MockSpotifyTrack: + def __init__(self, name, artists, album=None): + self.name = name + self.artists = artists if isinstance(artists, list) else [artists] if artists else [] + self.album = album + + # Try to get album information from the track context if available + # In sync context, we might not always have album info, but try to extract it + album_title = None + # If track_name contains potential album info, we'll let the detection handle it + + mock_track = MockSpotifyTrack(track_name, [artist_name] if artist_name else [], album_title) + + # Use the enhanced matching engine to generate queries + queries = self.matching_engine.generate_download_queries(mock_track) + + # Add some legacy fallback queries for compatibility + legacy_queries = [] + + # Add first word of artist approach (legacy compatibility) if artist_name: - # Attempt 1: Full Artist + Full Track Name - queries.append(f"{artist_name} {track_name}".strip()) - - # Attempt 2: Full Track Name + First Word of Artist artist_words = artist_name.split() if artist_words: first_word = artist_words[0] if first_word.lower() == 'the' and len(artist_words) > 1: - first_word = artist_words[1] # Use second word if first is "the" + first_word = artist_words[1] - if len(first_word) > 1: # Avoid single-letter words - queries.append(f"{track_name} {first_word}".strip()) - - # Attempt 3: Full Track Name only - queries.append(track_name.strip()) - - # --- Step 2: Clean the track name for the final fallback --- + if len(first_word) > 1: + legacy_queries.append(f"{track_name} {first_word}".strip()) + + # Add track-only query + legacy_queries.append(track_name.strip()) + + # Add traditional cleaned queries + import re cleaned_name = re.sub(r'\s*\([^)]*\)', '', track_name).strip() cleaned_name = re.sub(r'\s*\[[^\]]*\]', '', cleaned_name).strip() - - # Attempt 4: Cleaned Track Name only (if it's different from the original) - if cleaned_name and cleaned_name.lower() != track_name.lower(): - queries.append(cleaned_name.strip()) - - # --- Finalize: Remove duplicates while preserving the fallback order --- - unique_queries = [] - for query in queries: - if query and query not in unique_queries: - unique_queries.append(query) - print(f"🧠 Generated {len(unique_queries)} smart queries for '{track_name}'. Sequence: {unique_queries}") + if cleaned_name and cleaned_name.lower() != track_name.lower(): + legacy_queries.append(cleaned_name.strip()) + + # Combine enhanced queries with legacy fallbacks + all_queries = queries + legacy_queries + + # Remove duplicates while preserving order + unique_queries = [] + seen = set() + for query in all_queries: + if query and query.lower() not in seen: + unique_queries.append(query) + seen.add(query.lower()) + + print(f"🧠 Generated {len(unique_queries)} smart queries for '{track_name}' (enhanced with album detection)") + for i, query in enumerate(unique_queries): + print(f" {i+1}. '{query}'") + return unique_queries def setup_ui(self): @@ -4527,9 +4548,9 @@ class DownloadMissingTracksModal(QDialog): if not results: return [] - # Step 1: Get initial confident matches based on title, bitrate, etc. - # This gives us a sorted list of potential candidates. - initial_candidates = self.matching_engine.find_best_slskd_matches(spotify_track, results) + # Step 1: Get initial confident matches with version-aware scoring + # This gives us a sorted list of potential candidates, preferring originals. + initial_candidates = self.matching_engine.find_best_slskd_matches_enhanced(spotify_track, results) if not initial_candidates: print(f"⚠️ No initial candidates found for '{spotify_track.name}' from query '{query}'.") @@ -4564,7 +4585,15 @@ class DownloadMissingTracksModal(QDialog): if verified_candidates: best_confidence = verified_candidates[0].confidence - print(f"✅ Found {len(verified_candidates)} VERIFIED matches for '{spotify_track.name}'. Best score: {best_confidence:.2f}") + best_version = getattr(verified_candidates[0], 'version_type', 'unknown') + print(f"✅ Found {len(verified_candidates)} VERIFIED matches for '{spotify_track.name}'. Best: {best_confidence:.2f} ({best_version})") + + # Log version breakdown for debugging + for candidate in verified_candidates[:3]: # Show top 3 + version = getattr(candidate, 'version_type', 'unknown') + penalty = getattr(candidate, 'version_penalty', 0.0) + print(f" 🎵 {candidate.confidence:.2f} - {version} (penalty: {penalty:.2f}) - {candidate.filename[:80]}...") + else: print(f"⚠️ No verified matches found for '{spotify_track.name}' after checking file paths.")