good
This commit is contained in:
parent
44c262c7b4
commit
6358de554f
3 changed files with 83 additions and 65 deletions
|
|
@ -27,7 +27,7 @@ class MusicMatchingEngine:
|
|||
# General patterns to remove all content in brackets/parentheses
|
||||
r'\(.*\)',
|
||||
r'\[.*\]',
|
||||
# General pattern to remove everything after a hyphen
|
||||
# General pattern to remove everything after a hyphen, which is common for version info
|
||||
r'\s-\s.*',
|
||||
# Patterns to remove featuring artists from the title itself
|
||||
r'\sfeat\.?.*',
|
||||
|
|
@ -60,8 +60,16 @@ class MusicMatchingEngine:
|
|||
|
||||
return text
|
||||
|
||||
def get_core_string(self, text: str) -> str:
|
||||
"""Returns a 'core' version of a string with only letters and numbers for a strict comparison."""
|
||||
if not text:
|
||||
return ""
|
||||
# Transliterate, lowercase, and remove everything that isn't a letter or digit.
|
||||
text = unidecode(text).lower()
|
||||
return re.sub(r'[^a-z0-9]', '', text)
|
||||
|
||||
def clean_title(self, title: str) -> str:
|
||||
"""Cleans title by removing common extra info using regex."""
|
||||
"""Cleans title by removing common extra info using regex for fuzzy matching."""
|
||||
cleaned = title
|
||||
|
||||
for pattern in self.title_patterns:
|
||||
|
|
@ -98,48 +106,43 @@ class MusicMatchingEngine:
|
|||
return max(0, 1.0 - diff_ratio * 5)
|
||||
|
||||
def calculate_match_confidence(self, spotify_track: SpotifyTrack, plex_track: PlexTrackInfo) -> Tuple[float, str]:
|
||||
"""Calculates a confidence score for a potential match with a more robust, prioritized logic."""
|
||||
"""Calculates a confidence score using a prioritized model, starting with a strict 'core' title check."""
|
||||
|
||||
spotify_title_cleaned = self.clean_title(spotify_track.name)
|
||||
plex_title_cleaned = self.clean_title(plex_track.title)
|
||||
|
||||
# --- Artist Scoring ---
|
||||
# --- Artist Scoring (calculated once) ---
|
||||
spotify_artists_cleaned = [self.clean_artist(a) for a in spotify_track.artists if a]
|
||||
plex_artist_normalized = self.normalize_string(plex_track.artist)
|
||||
plex_artist_cleaned = self.clean_artist(plex_track.artist)
|
||||
|
||||
best_artist_score = 0.0
|
||||
for spotify_artist in spotify_artists_cleaned:
|
||||
if spotify_artist and spotify_artist in plex_artist_normalized:
|
||||
best_artist_score = 1.0
|
||||
break
|
||||
score = self.similarity_score(spotify_artist, self.clean_artist(plex_track.artist))
|
||||
score = self.similarity_score(spotify_artist, plex_artist_cleaned)
|
||||
if score > best_artist_score:
|
||||
best_artist_score = score
|
||||
|
||||
artist_score = best_artist_score
|
||||
|
||||
# --- Title and Duration Scoring ---
|
||||
# --- Priority 1: Core Title Match (for exact matches like "Girls", "APT.", "LIL DEMON") ---
|
||||
spotify_core_title = self.get_core_string(spotify_track.name)
|
||||
plex_core_title = self.get_core_string(plex_track.title)
|
||||
|
||||
if spotify_core_title and spotify_core_title == plex_core_title:
|
||||
# If the core titles are identical, we are highly confident.
|
||||
# The final score is a high base (0.9) plus a bonus for artist similarity.
|
||||
confidence = 0.90 + (artist_score * 0.09) # Max score of 0.99
|
||||
return confidence, "core_title_match"
|
||||
|
||||
# --- Priority 2: Fuzzy Title Match (for variations, typos, etc.) ---
|
||||
spotify_title_cleaned = self.clean_title(spotify_track.name)
|
||||
plex_title_cleaned = self.clean_title(plex_track.title)
|
||||
|
||||
title_score = self.similarity_score(spotify_title_cleaned, plex_title_cleaned)
|
||||
duration_score = self.duration_similarity(spotify_track.duration_ms, plex_track.duration if plex_track.duration else 0)
|
||||
|
||||
# --- Prioritized Confidence Logic ---
|
||||
# Priority 1: Near-perfect title and artist match is a very strong signal.
|
||||
if title_score > 0.98 and artist_score > 0.9:
|
||||
confidence = 0.98
|
||||
match_type = "strong_match"
|
||||
# Priority 2: Exact title match, even with a weaker artist match, should have high confidence.
|
||||
# This helps with short titles like "Girls" or "LIL DEMON".
|
||||
elif title_score > 0.98:
|
||||
confidence = 0.90 + (artist_score * 0.05) # Base of 0.9, with a small artist bonus
|
||||
match_type = "exact_title_match"
|
||||
# Priority 3: High title similarity is still a good indicator.
|
||||
elif title_score > 0.9:
|
||||
confidence = (title_score * 0.6) + (artist_score * 0.3) + (duration_score * 0.1)
|
||||
match_type = "high_confidence"
|
||||
# Default: Standard weighted calculation for all other cases.
|
||||
else:
|
||||
confidence = (title_score * 0.5) + (artist_score * 0.3) + (duration_score * 0.2)
|
||||
match_type = "standard_match"
|
||||
|
||||
# Use a standard weighted calculation if the core titles didn't match
|
||||
confidence = (title_score * 0.60) + (artist_score * 0.30) + (duration_score * 0.10)
|
||||
match_type = "standard_match"
|
||||
|
||||
return confidence, match_type
|
||||
|
||||
|
|
|
|||
|
|
@ -250,43 +250,41 @@ class PlexClient:
|
|||
|
||||
def search_tracks(self, title: str, artist: str, limit: int = 15) -> List[PlexTrackInfo]:
|
||||
"""
|
||||
Searches for tracks in the Plex music library using a more robust, two-step method
|
||||
that is more compatible with different Plex server versions.
|
||||
Searches for tracks in the Plex music library. If an artist is provided, it
|
||||
searches within that artist's scope. If the artist is empty or not found,
|
||||
it falls back to a library-wide search for the title.
|
||||
"""
|
||||
if not self.music_library:
|
||||
logger.warning("Plex music library not found. Cannot perform search.")
|
||||
return []
|
||||
|
||||
try:
|
||||
# Step 1: Search for the artist first. This is generally reliable.
|
||||
artist_results = self.music_library.searchArtists(title=artist, limit=1)
|
||||
|
||||
candidate_tracks = []
|
||||
if artist_results:
|
||||
# If artist is found, get all their tracks and filter by title in Python.
|
||||
# This avoids potential API filter issues where special characters in the title
|
||||
# might cause the search to fail.
|
||||
plex_artist = artist_results[0]
|
||||
all_artist_tracks = plex_artist.tracks()
|
||||
|
||||
# Use a case-insensitive substring match to find potential tracks.
|
||||
# The matching engine will do the final, more precise comparison later.
|
||||
lower_title = title.lower()
|
||||
for track in all_artist_tracks:
|
||||
if lower_title in track.title.lower():
|
||||
candidate_tracks.append(track)
|
||||
|
||||
# If an artist is provided, perform a targeted search.
|
||||
if artist:
|
||||
artist_results = self.music_library.searchArtists(title=artist, limit=1)
|
||||
if artist_results:
|
||||
plex_artist = artist_results[0]
|
||||
all_artist_tracks = plex_artist.tracks()
|
||||
lower_title = title.lower()
|
||||
for track in all_artist_tracks:
|
||||
if lower_title in track.title.lower():
|
||||
candidate_tracks.append(track)
|
||||
else:
|
||||
# If artist not found, still fall back to a general title search
|
||||
logger.debug(f"Artist '{artist}' not found. Falling back to title search for '{title}'.")
|
||||
candidate_tracks = self.music_library.searchTracks(title=title, limit=limit)
|
||||
|
||||
# If no artist is provided, perform a library-wide title search directly.
|
||||
else:
|
||||
# Fallback: If the artist wasn't found, search for the track title
|
||||
# across the entire library. This is less precise but better than nothing.
|
||||
logger.debug(f"Artist '{artist}' not found. Falling back to title search for '{title}'.")
|
||||
logger.debug(f"Performing title-only search for '{title}'.")
|
||||
candidate_tracks = self.music_library.searchTracks(title=title, limit=limit)
|
||||
|
||||
# Convert the raw Plex track objects to our simplified PlexTrackInfo dataclass.
|
||||
# Apply the limit here to the final list of candidates.
|
||||
tracks = [PlexTrackInfo.from_plex_track(track) for track in candidate_tracks[:limit]]
|
||||
|
||||
if tracks:
|
||||
logger.debug(f"Plex search for title='{title}' by artist='{artist}' found {len(tracks)} potential matches.")
|
||||
logger.debug(f"Plex search for title='{title}', artist='{artist or 'N/A'}' found {len(tracks)} potential matches.")
|
||||
|
||||
return tracks
|
||||
|
||||
|
|
@ -296,6 +294,7 @@ class PlexClient:
|
|||
traceback.print_exc()
|
||||
return []
|
||||
|
||||
|
||||
def get_library_stats(self) -> Dict[str, int]:
|
||||
if not self.music_library:
|
||||
return {}
|
||||
|
|
|
|||
|
|
@ -120,39 +120,38 @@ class PlaylistTrackAnalysisWorker(QRunnable):
|
|||
|
||||
def _check_track_in_plex(self, spotify_track):
|
||||
"""
|
||||
Check if a Spotify track exists in Plex by trying several search strategies
|
||||
across ALL artists associated with the track.
|
||||
Check if a Spotify track exists in Plex by trying a multi-stage search strategy:
|
||||
1. Search using Artist + Title variations.
|
||||
2. Fallback to searching with only the Title variations across the entire library.
|
||||
"""
|
||||
try:
|
||||
original_title = spotify_track.name
|
||||
|
||||
# --- Generate a list of title variations ---
|
||||
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)
|
||||
if " - " in original_title:
|
||||
title_variations.append(original_title.split(' - ')[0].strip())
|
||||
|
||||
cleaned_for_search = clean_track_name_for_search(original_title) # Strategy 3: Strip parenthetical content
|
||||
cleaned_for_search = clean_track_name_for_search(original_title)
|
||||
if cleaned_for_search.lower() != original_title.lower():
|
||||
title_variations.append(cleaned_for_search)
|
||||
|
||||
base_title = self.matching_engine.clean_title(original_title) # Strategy 4: Aggressively cleaned title
|
||||
base_title = self.matching_engine.clean_title(original_title)
|
||||
if base_title.lower() not in [t.lower() for t in title_variations]:
|
||||
title_variations.append(base_title)
|
||||
|
||||
unique_title_variations = list(dict.fromkeys(title_variations))
|
||||
|
||||
# --- Execute searches for EACH artist and collect all potential matches ---
|
||||
# --- Execute searches and collect all potential matches ---
|
||||
all_potential_matches = []
|
||||
found_match_ids = set()
|
||||
|
||||
# Use all artists from Spotify, not just the first one
|
||||
# STAGE 1: Search with Artist + Title
|
||||
artists_to_search = spotify_track.artists if spotify_track.artists else [""]
|
||||
|
||||
for artist_name in artists_to_search:
|
||||
if self._cancelled: return None, 0.0
|
||||
|
||||
print(f"🎤 Searching for artist: '{artist_name}'")
|
||||
for query_title in unique_title_variations:
|
||||
if self._cancelled: return None, 0.0
|
||||
|
||||
|
|
@ -166,13 +165,28 @@ class PlaylistTrackAnalysisWorker(QRunnable):
|
|||
if track.id not in found_match_ids:
|
||||
all_potential_matches.append(track)
|
||||
found_match_ids.add(track.id)
|
||||
|
||||
# STAGE 2: Fallback search with Title Only (as suggested)
|
||||
print(f"🎤 Performing title-only fallback search for '{original_title}'")
|
||||
for query_title in unique_title_variations:
|
||||
if self._cancelled: return None, 0.0
|
||||
# Pass an empty artist to trigger title-only search logic in plex_client
|
||||
title_only_matches = self.plex_client.search_tracks(
|
||||
title=query_title,
|
||||
artist="",
|
||||
limit=10
|
||||
)
|
||||
for track in title_only_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:
|
||||
print(f"❌ No Plex candidates found for '{original_title}' after trying all artists and title variations.")
|
||||
print(f"❌ No Plex candidates found for '{original_title}' after all search strategies.")
|
||||
return None, 0.0
|
||||
|
||||
# --- 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)} total potential Plex matches for '{original_title}'. Scoring now...")
|
||||
match_result = self.matching_engine.find_best_match(spotify_track, all_potential_matches)
|
||||
|
||||
if match_result.is_match:
|
||||
|
|
@ -188,6 +202,8 @@ class PlaylistTrackAnalysisWorker(QRunnable):
|
|||
traceback.print_exc()
|
||||
return None, 0.0
|
||||
|
||||
|
||||
|
||||
class TrackDownloadWorkerSignals(QObject):
|
||||
"""Signals for track download worker"""
|
||||
download_started = pyqtSignal(int, int, str) # download_index, track_index, download_id
|
||||
|
|
|
|||
Loading…
Reference in a new issue