better
This commit is contained in:
parent
3f04d7f984
commit
f1e4539936
2 changed files with 121 additions and 173 deletions
|
|
@ -2,6 +2,7 @@ from typing import List, Optional, Dict, Any, Tuple
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from difflib import SequenceMatcher
|
from difflib import SequenceMatcher
|
||||||
|
from unidecode import unidecode
|
||||||
from utils.logging_config import get_logger
|
from utils.logging_config import get_logger
|
||||||
from core.spotify_client import Track as SpotifyTrack
|
from core.spotify_client import Track as SpotifyTrack
|
||||||
from core.plex_client import PlexTrackInfo
|
from core.plex_client import PlexTrackInfo
|
||||||
|
|
@ -17,20 +18,28 @@ class MatchResult:
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_match(self) -> bool:
|
def is_match(self) -> bool:
|
||||||
return self.plex_track is not None and self.confidence >= 0.7
|
return self.plex_track is not None and self.confidence >= 0.8
|
||||||
|
|
||||||
class MusicMatchingEngine:
|
class MusicMatchingEngine:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
# More comprehensive patterns to strip extra info from titles
|
||||||
self.title_patterns = [
|
self.title_patterns = [
|
||||||
r'\(.*?\)',
|
r'\(feat\.?.*\)',
|
||||||
r'\[.*?\]',
|
r'\[feat\.?.*\]',
|
||||||
r'\s*-\s*remaster.*',
|
r'\(with.*\)',
|
||||||
r'\s*-\s*remix.*',
|
r'\(ft\.?.*\)',
|
||||||
r'\s*-\s*live.*',
|
r'\[ft\.?.*\]',
|
||||||
r'\s*-\s*acoustic.*',
|
r'\(remix\)',
|
||||||
r'\s*feat\..*',
|
r'\(live\)',
|
||||||
r'\s*ft\..*',
|
r'\(acoustic\)',
|
||||||
r'\s*featuring.*',
|
r'\(radio edit\)',
|
||||||
|
r'\(album version\)',
|
||||||
|
r'\(original mix\)',
|
||||||
|
r'-\s*single version',
|
||||||
|
r'-\s*remaster.*',
|
||||||
|
r'-\s*live.*',
|
||||||
|
r'-\s*remix',
|
||||||
|
r'-\s*radio edit',
|
||||||
]
|
]
|
||||||
|
|
||||||
self.artist_patterns = [
|
self.artist_patterns = [
|
||||||
|
|
@ -39,37 +48,51 @@ class MusicMatchingEngine:
|
||||||
r'\s*featuring.*',
|
r'\s*featuring.*',
|
||||||
r'\s*&.*',
|
r'\s*&.*',
|
||||||
r'\s*and.*',
|
r'\s*and.*',
|
||||||
|
r',.*'
|
||||||
]
|
]
|
||||||
|
|
||||||
def normalize_string(self, text: str) -> str:
|
def normalize_string(self, text: str) -> str:
|
||||||
|
"""
|
||||||
|
Normalizes string by converting to ASCII, lowercasing, and removing
|
||||||
|
specific punctuation while keeping alphanumeric characters.
|
||||||
|
"""
|
||||||
if not text:
|
if not text:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
text = text.lower().strip()
|
# Transliterate Unicode characters (e.g., ñ -> n, é -> e) to ASCII
|
||||||
|
text = unidecode(text)
|
||||||
|
|
||||||
text = re.sub(r'[^\w\s]', '', text)
|
# Convert to lowercase
|
||||||
|
text = text.lower()
|
||||||
|
|
||||||
text = re.sub(r'\s+', ' ', text)
|
# Remove specific punctuation but keep alphanumeric and spaces
|
||||||
|
text = re.sub(r'[^\w\s-]', '', text)
|
||||||
|
|
||||||
|
# Collapse multiple spaces into one
|
||||||
|
text = re.sub(r'\s+', ' ', text).strip()
|
||||||
|
|
||||||
return text
|
return text
|
||||||
|
|
||||||
def clean_title(self, title: str) -> str:
|
def clean_title(self, title: str) -> str:
|
||||||
|
"""Cleans title by removing common extra info using regex."""
|
||||||
cleaned = title
|
cleaned = title
|
||||||
|
|
||||||
for pattern in self.title_patterns:
|
for pattern in self.title_patterns:
|
||||||
cleaned = re.sub(pattern, '', cleaned, flags=re.IGNORECASE)
|
cleaned = re.sub(pattern, '', cleaned, flags=re.IGNORECASE).strip()
|
||||||
|
|
||||||
return self.normalize_string(cleaned)
|
return self.normalize_string(cleaned)
|
||||||
|
|
||||||
def clean_artist(self, artist: str) -> str:
|
def clean_artist(self, artist: str) -> str:
|
||||||
|
"""Cleans artist name by removing featured artists and other noise."""
|
||||||
cleaned = artist
|
cleaned = artist
|
||||||
|
|
||||||
for pattern in self.artist_patterns:
|
for pattern in self.artist_patterns:
|
||||||
cleaned = re.sub(pattern, '', cleaned, flags=re.IGNORECASE)
|
cleaned = re.sub(pattern, '', cleaned, flags=re.IGNORECASE).strip()
|
||||||
|
|
||||||
return self.normalize_string(cleaned)
|
return self.normalize_string(cleaned)
|
||||||
|
|
||||||
def extract_main_artist(self, artists: List[str]) -> str:
|
def extract_main_artist(self, artists: List[str]) -> str:
|
||||||
|
"""Extracts and cleans the primary artist from a list."""
|
||||||
if not artists:
|
if not artists:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
@ -77,68 +100,69 @@ class MusicMatchingEngine:
|
||||||
return self.clean_artist(main_artist)
|
return self.clean_artist(main_artist)
|
||||||
|
|
||||||
def similarity_score(self, str1: str, str2: str) -> float:
|
def similarity_score(self, str1: str, str2: str) -> float:
|
||||||
|
"""Calculates similarity score between two strings."""
|
||||||
if not str1 or not str2:
|
if not str1 or not str2:
|
||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
return SequenceMatcher(None, str1, str2).ratio()
|
return SequenceMatcher(None, str1, str2).ratio()
|
||||||
|
|
||||||
def duration_similarity(self, duration1: int, duration2: int) -> float:
|
def duration_similarity(self, duration1: int, duration2: int) -> float:
|
||||||
|
"""Calculates similarity score based on track duration (in ms)."""
|
||||||
if duration1 == 0 or duration2 == 0:
|
if duration1 == 0 or duration2 == 0:
|
||||||
return 0.5
|
return 0.5 # Neutral score if a duration is missing
|
||||||
|
|
||||||
max_duration = max(duration1, duration2)
|
# Allow a 5-second tolerance (5000 ms)
|
||||||
min_duration = min(duration1, duration2)
|
if abs(duration1 - duration2) <= 5000:
|
||||||
|
|
||||||
if max_duration == 0:
|
|
||||||
return 0.5
|
|
||||||
|
|
||||||
diff_ratio = abs(max_duration - min_duration) / max_duration
|
|
||||||
|
|
||||||
if diff_ratio <= 0.05:
|
|
||||||
return 1.0
|
return 1.0
|
||||||
elif diff_ratio <= 0.1:
|
|
||||||
return 0.8
|
# Penalize larger differences
|
||||||
elif diff_ratio <= 0.2:
|
diff_ratio = abs(duration1 - duration2) / max(duration1, duration2)
|
||||||
return 0.6
|
return max(0, 1.0 - diff_ratio * 5) # Scale penalty
|
||||||
else:
|
|
||||||
return 0.3
|
|
||||||
|
|
||||||
def calculate_match_confidence(self, spotify_track: SpotifyTrack, plex_track: PlexTrackInfo) -> Tuple[float, str]:
|
def calculate_match_confidence(self, spotify_track: SpotifyTrack, plex_track: PlexTrackInfo) -> Tuple[float, str]:
|
||||||
spotify_title = self.clean_title(spotify_track.name)
|
"""Calculates a confidence score for a potential match with weighted factors."""
|
||||||
plex_title = self.clean_title(plex_track.title)
|
|
||||||
|
|
||||||
spotify_artist = self.extract_main_artist(spotify_track.artists)
|
# Clean titles and artists for comparison
|
||||||
plex_artist = self.clean_artist(plex_track.artist)
|
spotify_title_cleaned = self.clean_title(spotify_track.name)
|
||||||
|
plex_title_cleaned = self.clean_title(plex_track.title)
|
||||||
|
|
||||||
spotify_album = self.normalize_string(spotify_track.album)
|
spotify_main_artist_cleaned = self.extract_main_artist(spotify_track.artists)
|
||||||
plex_album = self.normalize_string(plex_track.album)
|
plex_artist_normalized = self.normalize_string(plex_track.artist)
|
||||||
|
|
||||||
title_score = self.similarity_score(spotify_title, plex_title)
|
# --- Calculate individual scores ---
|
||||||
artist_score = self.similarity_score(spotify_artist, plex_artist)
|
title_score = self.similarity_score(spotify_title_cleaned, plex_title_cleaned)
|
||||||
album_score = self.similarity_score(spotify_album, plex_album)
|
|
||||||
|
|
||||||
# CORRECTED: Plex duration is already in milliseconds.
|
# Artist score: check if main Spotify artist is in the Plex artist string
|
||||||
duration_score = self.duration_similarity(
|
artist_score = 1.0 if spotify_main_artist_cleaned in plex_artist_normalized else self.similarity_score(spotify_main_artist_cleaned, self.clean_artist(plex_track.artist))
|
||||||
spotify_track.duration_ms,
|
|
||||||
plex_track.duration if plex_track.duration else 0
|
|
||||||
)
|
|
||||||
|
|
||||||
if title_score >= 0.9 and artist_score >= 0.9 and album_score >= 0.8:
|
duration_score = self.duration_similarity(spotify_track.duration_ms, plex_track.duration if plex_track.duration else 0)
|
||||||
return 0.95, "exact_match"
|
|
||||||
elif title_score >= 0.8 and artist_score >= 0.8:
|
# --- Weighted confidence calculation ---
|
||||||
return 0.85, "high_confidence"
|
# Weights: Title (50%), Artist (30%), Duration (20%)
|
||||||
elif title_score >= 0.7 and artist_score >= 0.7:
|
confidence = (title_score * 0.5) + (artist_score * 0.3) + (duration_score * 0.2)
|
||||||
return 0.75, "medium_confidence"
|
|
||||||
elif title_score >= 0.6 and artist_score >= 0.6:
|
# Determine match type based on scores
|
||||||
return 0.65, "low_confidence"
|
if title_score > 0.95 and artist_score > 0.9 and duration_score > 0.9:
|
||||||
|
match_type = "perfect_match"
|
||||||
|
confidence = max(confidence, 0.98) # Boost confidence for perfect matches
|
||||||
|
elif title_score > 0.85 and artist_score > 0.8:
|
||||||
|
match_type = "high_confidence"
|
||||||
|
elif title_score > 0.75:
|
||||||
|
match_type = "medium_confidence"
|
||||||
else:
|
else:
|
||||||
return 0.0, "no_match"
|
match_type = "low_confidence"
|
||||||
|
|
||||||
|
return confidence, match_type
|
||||||
|
|
||||||
def find_best_match(self, spotify_track: SpotifyTrack, plex_tracks: List[PlexTrackInfo]) -> MatchResult:
|
def find_best_match(self, spotify_track: SpotifyTrack, plex_tracks: List[PlexTrackInfo]) -> MatchResult:
|
||||||
|
"""Finds the best Plex track match from a list of candidates."""
|
||||||
best_match = None
|
best_match = None
|
||||||
best_confidence = 0.0
|
best_confidence = 0.0
|
||||||
best_match_type = "no_match"
|
best_match_type = "no_match"
|
||||||
|
|
||||||
|
if not plex_tracks:
|
||||||
|
return MatchResult(spotify_track, None, 0.0, "no_candidates")
|
||||||
|
|
||||||
for plex_track in plex_tracks:
|
for plex_track in plex_tracks:
|
||||||
confidence, match_type = self.calculate_match_confidence(spotify_track, plex_track)
|
confidence, match_type = self.calculate_match_confidence(spotify_track, plex_track)
|
||||||
|
|
||||||
|
|
@ -153,72 +177,3 @@ class MusicMatchingEngine:
|
||||||
confidence=best_confidence,
|
confidence=best_confidence,
|
||||||
match_type=best_match_type
|
match_type=best_match_type
|
||||||
)
|
)
|
||||||
|
|
||||||
def match_playlist_tracks(self, spotify_tracks: List[SpotifyTrack], plex_tracks: List[PlexTrackInfo]) -> List[MatchResult]:
|
|
||||||
results = []
|
|
||||||
|
|
||||||
logger.info(f"Matching {len(spotify_tracks)} Spotify tracks against {len(plex_tracks)} Plex tracks")
|
|
||||||
|
|
||||||
for spotify_track in spotify_tracks:
|
|
||||||
match_result = self.find_best_match(spotify_track, plex_tracks)
|
|
||||||
results.append(match_result)
|
|
||||||
|
|
||||||
if match_result.is_match:
|
|
||||||
logger.debug(f"Matched: {spotify_track.name} by {spotify_track.artists[0]} -> {match_result.plex_track.title} (confidence: {match_result.confidence:.2f})")
|
|
||||||
else:
|
|
||||||
logger.debug(f"No match found for: {spotify_track.name} by {spotify_track.artists[0]}")
|
|
||||||
|
|
||||||
matched_count = sum(1 for r in results if r.is_match)
|
|
||||||
logger.info(f"Successfully matched {matched_count}/{len(spotify_tracks)} tracks")
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
def get_match_statistics(self, match_results: List[MatchResult]) -> Dict[str, Any]:
|
|
||||||
total_tracks = len(match_results)
|
|
||||||
matched_tracks = sum(1 for r in match_results if r.is_match)
|
|
||||||
|
|
||||||
match_types = {}
|
|
||||||
for result in match_results:
|
|
||||||
if result.is_match:
|
|
||||||
match_types[result.match_type] = match_types.get(result.match_type, 0) + 1
|
|
||||||
|
|
||||||
confidence_distribution = {
|
|
||||||
"high (>0.8)": sum(1 for r in match_results if r.confidence > 0.8),
|
|
||||||
"medium (0.7-0.8)": sum(1 for r in match_results if 0.7 <= r.confidence <= 0.8),
|
|
||||||
"low (0.6-0.7)": sum(1 for r in match_results if 0.6 <= r.confidence < 0.7),
|
|
||||||
"no_match (<0.6)": sum(1 for r in match_results if r.confidence < 0.6)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"total_tracks": total_tracks,
|
|
||||||
"matched_tracks": matched_tracks,
|
|
||||||
"match_percentage": (matched_tracks / total_tracks * 100) if total_tracks > 0 else 0,
|
|
||||||
"match_types": match_types,
|
|
||||||
"confidence_distribution": confidence_distribution
|
|
||||||
}
|
|
||||||
|
|
||||||
def create_search_queries(self, spotify_track: SpotifyTrack) -> List[str]:
|
|
||||||
queries = []
|
|
||||||
|
|
||||||
main_artist = self.extract_main_artist(spotify_track.artists)
|
|
||||||
clean_title = self.clean_title(spotify_track.name)
|
|
||||||
clean_album = self.normalize_string(spotify_track.album)
|
|
||||||
|
|
||||||
queries.append(f"{clean_title} {main_artist}")
|
|
||||||
queries.append(f"{main_artist} {clean_title}")
|
|
||||||
queries.append(f"{clean_title} {main_artist} {clean_album}")
|
|
||||||
queries.append(f"{clean_album} {main_artist}")
|
|
||||||
|
|
||||||
if len(spotify_track.artists) > 1:
|
|
||||||
all_artists = " ".join([self.clean_artist(a) for a in spotify_track.artists])
|
|
||||||
queries.append(f"{clean_title} {all_artists}")
|
|
||||||
|
|
||||||
return queries
|
|
||||||
|
|
||||||
def generate_download_query(self, spotify_track: SpotifyTrack) -> str:
|
|
||||||
main_artist = self.extract_main_artist(spotify_track.artists)
|
|
||||||
clean_title = self.clean_title(spotify_track.name)
|
|
||||||
|
|
||||||
return f"{main_artist} {clean_title}"
|
|
||||||
|
|
||||||
matching_engine = MusicMatchingEngine()
|
|
||||||
|
|
|
||||||
|
|
@ -121,72 +121,65 @@ class PlaylistTrackAnalysisWorker(QRunnable):
|
||||||
def _check_track_in_plex(self, spotify_track):
|
def _check_track_in_plex(self, spotify_track):
|
||||||
"""
|
"""
|
||||||
Check if a Spotify track exists in Plex by trying several search strategies
|
Check if a Spotify track exists in Plex by trying several search strategies
|
||||||
and using the MusicMatchingEngine to find the best match.
|
across ALL artists associated with the track.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Use the first artist for the primary search query
|
|
||||||
artist_name = spotify_track.artists[0] if spotify_track.artists else ""
|
|
||||||
original_title = spotify_track.name
|
original_title = spotify_track.name
|
||||||
|
|
||||||
# --- Generate a list of search queries, from most specific to most broad ---
|
# --- Generate a list of title variations ---
|
||||||
search_queries = []
|
title_variations = []
|
||||||
|
title_variations.append(original_title) # Strategy 1: Original title
|
||||||
|
if " - " in original_title: # Strategy 2: Strip content after hyphen
|
||||||
|
title_variations.append(original_title.split(' - ')[0].strip())
|
||||||
|
|
||||||
# Strategy 1: Original, unmodified title. Catches exact matches.
|
cleaned_for_search = clean_track_name_for_search(original_title) # Strategy 3: Strip parenthetical content
|
||||||
search_queries.append(original_title)
|
|
||||||
|
|
||||||
# Strategy 2: Title with content after a hyphen removed.
|
|
||||||
# e.g., "Song Title - Remaster" -> "Song Title"
|
|
||||||
if " - " in original_title:
|
|
||||||
title_before_hyphen = original_title.split(' - ')[0].strip()
|
|
||||||
if title_before_hyphen:
|
|
||||||
search_queries.append(title_before_hyphen)
|
|
||||||
|
|
||||||
# Strategy 3: Title with parenthetical/bracketed content removed.
|
|
||||||
# (Uses the simple cleaner from this file for an intermediate search)
|
|
||||||
cleaned_for_search = clean_track_name_for_search(original_title)
|
|
||||||
if cleaned_for_search.lower() != original_title.lower():
|
if cleaned_for_search.lower() != original_title.lower():
|
||||||
search_queries.append(cleaned_for_search)
|
title_variations.append(cleaned_for_search)
|
||||||
|
|
||||||
# Strategy 4: A "base" title with all extra info removed (remixes, feats, etc.)
|
base_title = self.matching_engine.clean_title(original_title) # Strategy 4: Aggressively cleaned title
|
||||||
# using the more aggressive cleaning from the matching engine.
|
if base_title.lower() not in [t.lower() for t in title_variations]:
|
||||||
base_title = self.matching_engine.clean_title(original_title)
|
title_variations.append(base_title)
|
||||||
if base_title.lower() != cleaned_for_search.lower() and base_title.lower() != original_title.lower():
|
|
||||||
search_queries.append(base_title)
|
|
||||||
|
|
||||||
# Remove duplicate queries that might have resulted from the cleaning steps, preserving order.
|
unique_title_variations = list(dict.fromkeys(title_variations))
|
||||||
unique_queries = list(dict.fromkeys(search_queries))
|
|
||||||
|
|
||||||
print(f"🧠 Generated search queries for '{original_title}': {unique_queries}")
|
# --- Execute searches for EACH artist and collect all potential matches ---
|
||||||
|
|
||||||
# --- Execute searches and collect all potential matches ---
|
|
||||||
all_potential_matches = []
|
all_potential_matches = []
|
||||||
found_match_ids = set()
|
found_match_ids = set()
|
||||||
|
|
||||||
for query_title in unique_queries:
|
# Use all artists from Spotify, not just the first one
|
||||||
if self._cancelled:
|
artists_to_search = spotify_track.artists if spotify_track.artists else [""]
|
||||||
return None, 0.0
|
|
||||||
|
|
||||||
# Call the updated search_tracks with the query title and artist
|
for artist_name in artists_to_search:
|
||||||
potential_plex_matches = self.plex_client.search_tracks(
|
if self._cancelled: return None, 0.0
|
||||||
title=query_title,
|
|
||||||
artist=artist_name,
|
|
||||||
limit=15 # Increased limit to get more candidates
|
|
||||||
)
|
|
||||||
|
|
||||||
for track in potential_plex_matches:
|
print(f"🎤 Searching for artist: '{artist_name}'")
|
||||||
if track.id not in found_match_ids:
|
for query_title in unique_title_variations:
|
||||||
all_potential_matches.append(track)
|
if self._cancelled: return None, 0.0
|
||||||
found_match_ids.add(track.id)
|
|
||||||
|
potential_plex_matches = self.plex_client.search_tracks(
|
||||||
|
title=query_title,
|
||||||
|
artist=artist_name,
|
||||||
|
limit=15
|
||||||
|
)
|
||||||
|
|
||||||
|
for track in potential_plex_matches:
|
||||||
|
if track.id not in found_match_ids:
|
||||||
|
all_potential_matches.append(track)
|
||||||
|
found_match_ids.add(track.id)
|
||||||
|
|
||||||
if not all_potential_matches:
|
if not all_potential_matches:
|
||||||
print(f"❌ No Plex candidates found for '{original_title}' after trying all strategies.")
|
print(f"❌ No Plex candidates found for '{original_title}' after trying all artists and title variations.")
|
||||||
return None, 0.0
|
return None, 0.0
|
||||||
|
|
||||||
# --- Use the matching engine to find the best match among ALL candidates ---
|
# --- Use the matching engine to find the best match among ALL candidates ---
|
||||||
print(f"✅ Found {len(all_potential_matches)} potential Plex matches for '{original_title}'. Scoring now...")
|
print(f"✅ Found {len(all_potential_matches)} potential Plex matches for '{original_title}'. Scoring now...")
|
||||||
match_result = self.matching_engine.find_best_match(spotify_track, all_potential_matches)
|
match_result = self.matching_engine.find_best_match(spotify_track, all_potential_matches)
|
||||||
|
|
||||||
# Return the best Plex track found and its confidence score.
|
if match_result.is_match:
|
||||||
|
print(f"✔️ Best match for '{original_title}': '{match_result.plex_track.title}' with confidence {match_result.confidence:.2f}")
|
||||||
|
else:
|
||||||
|
print(f"⚠️ No confident match found for '{original_title}'. Best attempt scored {match_result.confidence:.2f}.")
|
||||||
|
|
||||||
return match_result.plex_track, match_result.confidence
|
return match_result.plex_track, match_result.confidence
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue