Improve enrichment matching to pick best result instead of first (#210)

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.
This commit is contained in:
Broque Thomas 2026-03-29 16:13:17 -07:00
parent 213736e168
commit 20452859c5

View file

@ -431,18 +431,25 @@ class SpotifyWorker:
logger.debug(f"No Spotify results for artist '{artist_name}'") logger.debug(f"No Spotify results for artist '{artist_name}'")
return 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: for artist_obj in results:
if self._name_matches(artist_name, artist_obj.name): score = self._name_similarity(artist_name, artist_obj.name)
if not self._is_spotify_id(artist_obj.id): if score >= self.name_similarity_threshold and score > best_score:
logger.warning(f"Rejecting non-Spotify ID '{artist_obj.id}' for artist '{artist_name}' (iTunes fallback leak)") best_obj = artist_obj
self._mark_status('artist', artist_id, 'error') best_score = score
self.stats['errors'] += 1
return if best_obj:
self._update_artist(artist_id, artist_obj) if not self._is_spotify_id(best_obj.id):
self.stats['matched'] += 1 logger.warning(f"Rejecting non-Spotify ID '{best_obj.id}' for artist '{artist_name}' (iTunes fallback leak)")
logger.info(f"Matched artist '{artist_name}' -> Spotify ID: {artist_obj.id}") self._mark_status('artist', artist_id, 'error')
self.stats['errors'] += 1
return 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._mark_status('artist', artist_id, 'not_found')
self.stats['not_found'] += 1 self.stats['not_found'] += 1
@ -595,17 +602,24 @@ class SpotifyWorker:
logger.debug(f"No Spotify results for album '{album_name}'") logger.debug(f"No Spotify results for album '{album_name}'")
return return
best_obj = None
best_score = 0
for album_obj in results: for album_obj in results:
if self._name_matches(album_name, album_obj.name): score = self._name_similarity(album_name, album_obj.name)
if not self._is_spotify_id(album_obj.id): if score >= self.name_similarity_threshold and score > best_score:
logger.warning(f"Rejecting non-Spotify ID '{album_obj.id}' for album '{album_name}'") best_obj = album_obj
self._mark_status('album', album_id, 'error') best_score = score
self.stats['errors'] += 1
return if best_obj:
self._update_album(album_id, album_obj) if not self._is_spotify_id(best_obj.id):
self.stats['matched'] += 1 logger.warning(f"Rejecting non-Spotify ID '{best_obj.id}' for album '{album_name}'")
logger.info(f"Matched album '{album_name}' -> Spotify ID: {album_obj.id}") self._mark_status('album', album_id, 'error')
self.stats['errors'] += 1
return 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._mark_status('album', album_id, 'not_found')
self.stats['not_found'] += 1 self.stats['not_found'] += 1
@ -625,17 +639,24 @@ class SpotifyWorker:
logger.debug(f"No Spotify results for track '{track_name}'") logger.debug(f"No Spotify results for track '{track_name}'")
return return
best_obj = None
best_score = 0
for track_obj in results: for track_obj in results:
if self._name_matches(track_name, track_obj.name): score = self._name_similarity(track_name, track_obj.name)
if not self._is_spotify_id(track_obj.id): if score >= self.name_similarity_threshold and score > best_score:
logger.warning(f"Rejecting non-Spotify ID '{track_obj.id}' for track '{track_name}'") best_obj = track_obj
self._mark_status('track', track_id, 'error') best_score = score
self.stats['errors'] += 1
return if best_obj:
self._update_track_from_search(track_id, track_obj) if not self._is_spotify_id(best_obj.id):
self.stats['matched'] += 1 logger.warning(f"Rejecting non-Spotify ID '{best_obj.id}' for track '{track_name}'")
logger.info(f"Matched track '{track_name}' -> Spotify ID: {track_obj.id}") self._mark_status('track', track_id, 'error')
self.stats['errors'] += 1
return 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._mark_status('track', track_id, 'not_found')
self.stats['not_found'] += 1 self.stats['not_found'] += 1
@ -997,9 +1018,12 @@ class SpotifyWorker:
name = re.sub(r'\s+', ' ', name).strip() name = re.sub(r'\s+', ' ', name).strip()
return name 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_query = self._normalize_name(query_name)
norm_result = self._normalize_name(result_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}") logger.debug(f"Name similarity: '{query_name}' vs '{result_name}' = {similarity:.2f}")
return similarity >= self.name_similarity_threshold return similarity >= self.name_similarity_threshold