From 20452859c5af62d4195654aca085a05d53bfaae9 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 29 Mar 2026 16:13:17 -0700 Subject: [PATCH] Improve enrichment matching to pick best result instead of first (#210) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Artist/album/track matching previously took the first Spotify search result above the 0.80 similarity threshold. If Spotify returned a near-match before the exact match (e.g. "Brother's Keeper" before "Brothers Keepers"), the wrong entity would be selected. Now scores all candidates and picks the highest, so an exact match (1.0) always wins over a near-match (0.94). No change to threshold or batch matching logic — strictly better or equal results. --- core/spotify_worker.py | 84 +++++++++++++++++++++++++++--------------- 1 file changed, 54 insertions(+), 30 deletions(-) diff --git a/core/spotify_worker.py b/core/spotify_worker.py index 5386ce49..62283ebd 100644 --- a/core/spotify_worker.py +++ b/core/spotify_worker.py @@ -431,18 +431,25 @@ class SpotifyWorker: logger.debug(f"No Spotify results for artist '{artist_name}'") return - # Find best fuzzy match + # Find best fuzzy match — score all candidates, pick highest above threshold + best_obj = None + best_score = 0 for artist_obj in results: - if self._name_matches(artist_name, artist_obj.name): - if not self._is_spotify_id(artist_obj.id): - logger.warning(f"Rejecting non-Spotify ID '{artist_obj.id}' for artist '{artist_name}' (iTunes fallback leak)") - self._mark_status('artist', artist_id, 'error') - self.stats['errors'] += 1 - return - self._update_artist(artist_id, artist_obj) - self.stats['matched'] += 1 - logger.info(f"Matched artist '{artist_name}' -> Spotify ID: {artist_obj.id}") + score = self._name_similarity(artist_name, artist_obj.name) + if score >= self.name_similarity_threshold and score > best_score: + best_obj = artist_obj + best_score = score + + if best_obj: + if not self._is_spotify_id(best_obj.id): + logger.warning(f"Rejecting non-Spotify ID '{best_obj.id}' for artist '{artist_name}' (iTunes fallback leak)") + self._mark_status('artist', artist_id, 'error') + self.stats['errors'] += 1 return + self._update_artist(artist_id, best_obj) + self.stats['matched'] += 1 + logger.info(f"Matched artist '{artist_name}' -> Spotify ID: {best_obj.id} (score: {best_score:.2f})") + return self._mark_status('artist', artist_id, 'not_found') self.stats['not_found'] += 1 @@ -595,17 +602,24 @@ class SpotifyWorker: logger.debug(f"No Spotify results for album '{album_name}'") return + best_obj = None + best_score = 0 for album_obj in results: - if self._name_matches(album_name, album_obj.name): - if not self._is_spotify_id(album_obj.id): - logger.warning(f"Rejecting non-Spotify ID '{album_obj.id}' for album '{album_name}'") - self._mark_status('album', album_id, 'error') - self.stats['errors'] += 1 - return - self._update_album(album_id, album_obj) - self.stats['matched'] += 1 - logger.info(f"Matched album '{album_name}' -> Spotify ID: {album_obj.id}") + score = self._name_similarity(album_name, album_obj.name) + if score >= self.name_similarity_threshold and score > best_score: + best_obj = album_obj + best_score = score + + if best_obj: + if not self._is_spotify_id(best_obj.id): + logger.warning(f"Rejecting non-Spotify ID '{best_obj.id}' for album '{album_name}'") + self._mark_status('album', album_id, 'error') + self.stats['errors'] += 1 return + self._update_album(album_id, best_obj) + self.stats['matched'] += 1 + logger.info(f"Matched album '{album_name}' -> Spotify ID: {best_obj.id} (score: {best_score:.2f})") + return self._mark_status('album', album_id, 'not_found') self.stats['not_found'] += 1 @@ -625,17 +639,24 @@ class SpotifyWorker: logger.debug(f"No Spotify results for track '{track_name}'") return + best_obj = None + best_score = 0 for track_obj in results: - if self._name_matches(track_name, track_obj.name): - if not self._is_spotify_id(track_obj.id): - logger.warning(f"Rejecting non-Spotify ID '{track_obj.id}' for track '{track_name}'") - self._mark_status('track', track_id, 'error') - self.stats['errors'] += 1 - return - self._update_track_from_search(track_id, track_obj) - self.stats['matched'] += 1 - logger.info(f"Matched track '{track_name}' -> Spotify ID: {track_obj.id}") + score = self._name_similarity(track_name, track_obj.name) + if score >= self.name_similarity_threshold and score > best_score: + best_obj = track_obj + best_score = score + + if best_obj: + if not self._is_spotify_id(best_obj.id): + logger.warning(f"Rejecting non-Spotify ID '{best_obj.id}' for track '{track_name}'") + self._mark_status('track', track_id, 'error') + self.stats['errors'] += 1 return + self._update_track_from_search(track_id, best_obj) + self.stats['matched'] += 1 + logger.info(f"Matched track '{track_name}' -> Spotify ID: {best_obj.id} (score: {best_score:.2f})") + return self._mark_status('track', track_id, 'not_found') self.stats['not_found'] += 1 @@ -997,9 +1018,12 @@ class SpotifyWorker: name = re.sub(r'\s+', ' ', name).strip() return name - def _name_matches(self, query_name: str, result_name: str) -> bool: + def _name_similarity(self, query_name: str, result_name: str) -> float: norm_query = self._normalize_name(query_name) norm_result = self._normalize_name(result_name) - similarity = SequenceMatcher(None, norm_query, norm_result).ratio() + return SequenceMatcher(None, norm_query, norm_result).ratio() + + def _name_matches(self, query_name: str, result_name: str) -> bool: + similarity = self._name_similarity(query_name, result_name) logger.debug(f"Name similarity: '{query_name}' vs '{result_name}' = {similarity:.2f}") return similarity >= self.name_similarity_threshold