better matching on slskd

This commit is contained in:
Broque Thomas 2025-08-07 00:37:44 -07:00
parent 689a488cbb
commit 35fdef76ce
6 changed files with 393 additions and 70 deletions

View file

@ -217,17 +217,165 @@ class MusicMatchingEngine:
match_type=best_match_type
)
def detect_album_in_title(self, track_title: str, album_name: str = None) -> 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:

View file

@ -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.")

View file

@ -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.")