From 6358de554ff80a1aa750ef62edc77838232d14c0 Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Thu, 24 Jul 2025 17:45:42 -0700 Subject: [PATCH] good --- core/matching_engine.py | 61 +++++++++++++++++++++-------------------- core/plex_client.py | 47 ++++++++++++++++--------------- ui/pages/sync.py | 40 +++++++++++++++++++-------- 3 files changed, 83 insertions(+), 65 deletions(-) diff --git a/core/matching_engine.py b/core/matching_engine.py index 54e30b22..7b0dfb73 100644 --- a/core/matching_engine.py +++ b/core/matching_engine.py @@ -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 diff --git a/core/plex_client.py b/core/plex_client.py index dbc25383..1a8b5bb7 100644 --- a/core/plex_client.py +++ b/core/plex_client.py @@ -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 {} diff --git a/ui/pages/sync.py b/ui/pages/sync.py index 2e59bf77..9d3bdeb9 100644 --- a/ui/pages/sync.py +++ b/ui/pages/sync.py @@ -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