From 85549197e62c61a73ac19371ffb51f1a4979850a Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Fri, 5 Jun 2026 07:47:59 -0700 Subject: [PATCH] Apply artist-id name-guard to audiodb/qobuz/tidal workers too The blind 'correct the parent artist's source id from an album/track match' logic was copy-pasted into four enrichment workers; the Deezer fix only covered one. AudioDB, Qobuz, and Tidal had the identical bug and would corrupt their own id columns (and re-corrupt after any cleanup). All three now gate the correction on a name match between the result's artist and the parent artist (audiodb reads result['strArtist']; qobuz/tidal thread the result artist name in from their callers, as Deezer does). Regression tests cover mismatch-skips and match-corrects for each. --- core/audiodb_worker.py | 19 ++++- core/qobuz_worker.py | 26 +++++- core/tidal_worker.py | 26 +++++- tests/test_enrichment_artist_id_guard.py | 100 +++++++++++++++++++++++ 4 files changed, 162 insertions(+), 9 deletions(-) create mode 100644 tests/test_enrichment_artist_id_guard.py diff --git a/core/audiodb_worker.py b/core/audiodb_worker.py index a8767cb8..fcb2bcb9 100644 --- a/core/audiodb_worker.py +++ b/core/audiodb_worker.py @@ -273,8 +273,13 @@ class AudioDBWorker: def _verify_artist_id(self, item: Dict[str, Any], result: Dict[str, Any]) -> bool: """Verify that the result's artist ID matches the parent artist's stored AudioDB ID. + If mismatched, the album/track search is more specific (uses artist+title), - so we trust it and correct the parent artist's audiodb_id.""" + so we trust it and correct the parent artist's audiodb_id — BUT only when + the result's artist *name* matches our parent artist. Without that guard, + a collaboration/compilation (a track our library credits to one artist + that lives on another artist's album) would stamp the wrong AudioDB id + onto our artist. See the Deezer fix for the full write-up.""" parent_audiodb_id = item.get('artist_audiodb_id') if not parent_audiodb_id: return True @@ -284,6 +289,18 @@ class AudioDBWorker: return True if str(result_artist_id) != str(parent_audiodb_id): + parent_name = item.get('artist') or '' + result_artist_name = result.get('strArtist') or '' + if (result_artist_name and parent_name + and not self._name_matches(parent_name, result_artist_name)): + logger.info( + f"Skipping artist-ID correction from {item['type']} " + f"'{item['name']}': result artist '{result_artist_name}' " + f"≠ parent '{parent_name}' (collab/compilation, not a " + f"correction)" + ) + return True + logger.info( f"Artist ID correction from {item['type']} '{item['name']}': " f"updating parent artist AudioDB ID from {parent_audiodb_id} to {result_artist_id}" diff --git a/core/qobuz_worker.py b/core/qobuz_worker.py index 706d6295..030014eb 100644 --- a/core/qobuz_worker.py +++ b/core/qobuz_worker.py @@ -301,13 +301,29 @@ class QobuzWorker: logger.debug(f"Name similarity: '{query_name}' vs '{result_name}' = {similarity:.2f}") return similarity >= self.name_similarity_threshold - def _verify_artist_id(self, item: Dict[str, Any], result_artist_id) -> bool: - """Verify/correct parent artist's Qobuz ID based on album/track match""" + def _verify_artist_id(self, item: Dict[str, Any], result_artist_id, + result_artist_name: Optional[str] = None) -> bool: + """Verify/correct parent artist's Qobuz ID based on album/track match. + + Only corrects when the result's artist *name* matches our parent artist — + otherwise a collaboration/compilation would stamp the wrong Qobuz id onto + our artist. See the Deezer fix for the full write-up.""" parent_qobuz_id = item.get('artist_qobuz_id') if not parent_qobuz_id or not result_artist_id: return True if str(result_artist_id) != str(parent_qobuz_id): + parent_name = item.get('artist') or '' + if (result_artist_name and parent_name + and not self._name_matches(parent_name, result_artist_name)): + logger.info( + f"Skipping artist-ID correction from {item['type']} " + f"'{item['name']}': result artist '{result_artist_name}' " + f"≠ parent '{parent_name}' (collab/compilation, not a " + f"correction)" + ) + return True + logger.info( f"Artist ID correction from {item['type']} '{item['name']}': " f"updating parent artist Qobuz ID from {parent_qobuz_id} to {result_artist_id}" @@ -467,7 +483,8 @@ class QobuzWorker: # Verify artist ID result_artist = result.get('artist', {}) result_artist_id = result_artist.get('id') if result_artist else None - self._verify_artist_id(item, result_artist_id) + result_artist_name = result_artist.get('name') if result_artist else None + self._verify_artist_id(item, result_artist_id, result_artist_name) # Fetch full album details qobuz_album_id = result.get('id') @@ -528,7 +545,8 @@ class QobuzWorker: # Verify artist ID result_artist = result.get('artist', result.get('performer', {})) result_artist_id = result_artist.get('id') if result_artist else None - self._verify_artist_id(item, result_artist_id) + result_artist_name = result_artist.get('name') if result_artist else None + self._verify_artist_id(item, result_artist_id, result_artist_name) # Fetch full track details qobuz_track_id = result.get('id') diff --git a/core/tidal_worker.py b/core/tidal_worker.py index 051f2843..9996596d 100644 --- a/core/tidal_worker.py +++ b/core/tidal_worker.py @@ -313,13 +313,29 @@ class TidalWorker: logger.debug(f"Name similarity: '{query_name}' vs '{result_name}' = {similarity:.2f}") return similarity >= self.name_similarity_threshold - def _verify_artist_id(self, item: Dict[str, Any], result_artist_id) -> bool: - """Verify/correct parent artist's Tidal ID based on album/track match""" + def _verify_artist_id(self, item: Dict[str, Any], result_artist_id, + result_artist_name: Optional[str] = None) -> bool: + """Verify/correct parent artist's Tidal ID based on album/track match. + + Only corrects when the result's artist *name* matches our parent artist — + otherwise a collaboration/compilation would stamp the wrong Tidal id onto + our artist. See the Deezer fix for the full write-up.""" parent_tidal_id = item.get('artist_tidal_id') if not parent_tidal_id or not result_artist_id: return True if str(result_artist_id) != str(parent_tidal_id): + parent_name = item.get('artist') or '' + if (result_artist_name and parent_name + and not self._name_matches(parent_name, result_artist_name)): + logger.info( + f"Skipping artist-ID correction from {item['type']} " + f"'{item['name']}': result artist '{result_artist_name}' " + f"≠ parent '{parent_name}' (collab/compilation, not a " + f"correction)" + ) + return True + logger.info( f"Artist ID correction from {item['type']} '{item['name']}': " f"updating parent artist Tidal ID from {parent_tidal_id} to {result_artist_id}" @@ -479,7 +495,8 @@ class TidalWorker: # Verify artist ID result_artist = result.get('artist', {}) result_artist_id = result_artist.get('id') if result_artist else None - self._verify_artist_id(item, result_artist_id) + result_artist_name = result_artist.get('name') if result_artist else None + self._verify_artist_id(item, result_artist_id, result_artist_name) # Fetch full album details tidal_album_id = result.get('id') @@ -533,7 +550,8 @@ class TidalWorker: # Verify artist ID result_artist = result.get('artist', {}) result_artist_id = result_artist.get('id') if result_artist else None - self._verify_artist_id(item, result_artist_id) + result_artist_name = result_artist.get('name') if result_artist else None + self._verify_artist_id(item, result_artist_id, result_artist_name) # Fetch full track details tidal_track_id = result.get('id') diff --git a/tests/test_enrichment_artist_id_guard.py b/tests/test_enrichment_artist_id_guard.py new file mode 100644 index 00000000..0bb374e4 --- /dev/null +++ b/tests/test_enrichment_artist_id_guard.py @@ -0,0 +1,100 @@ +"""Regression: the album/track-driven 'artist id correction' in every +enrichment worker that has it must NOT overwrite an artist's source id unless +the result's artist name actually matches. + +This is the same Kendrick/Jorja bug fixed in the Deezer worker (see +tests/test_deezer_worker_artist_id_guard.py), proven here to be closed in the +three other workers that copy-pasted the pattern: AudioDB, Qobuz, Tidal. + +A track our library credits to Jorja Smith that lives on Kendrick's curated +'Black Panther' album resolves to that album, whose primary artist is Kendrick. +Without a name check, each worker would 'correct' Jorja's source id to +Kendrick's — corrupting it (and sharing one id across unrelated artists). +""" + +from __future__ import annotations + +from core.audiodb_worker import AudioDBWorker +from core.qobuz_worker import QobuzWorker +from core.tidal_worker import TidalWorker + + +def _stub(cls, correct_attr): + """Build a bare worker instance (no __init__/clients) wired to record + corrections instead of writing to a db.""" + w = cls.__new__(cls) + w.name_similarity_threshold = 0.80 + w._corrections = [] + setattr(w, correct_attr, lambda item, cid: w._corrections.append((item['id'], cid))) + return w + + +def _item(artist_name, parent_id, id_key): + return {'type': 'track', 'id': 1, 'name': 'Some Track', + 'artist': artist_name, id_key: parent_id} + + +# -------------------------------------------------------------------------- +# AudioDB — _verify_artist_id(item, result_dict); name is result['strArtist']. +# -------------------------------------------------------------------------- + +def test_audiodb_skips_correction_on_name_mismatch(): + w = _stub(AudioDBWorker, '_correct_artist_audiodb_id') + item = _item('Jorja Smith', '111', 'artist_audiodb_id') + w._verify_artist_id(item, {'idArtist': '999', 'strArtist': 'Kendrick Lamar'}) + assert w._corrections == [] + + +def test_audiodb_corrects_on_name_match(): + w = _stub(AudioDBWorker, '_correct_artist_audiodb_id') + item = _item('Kendrick Lamar', '111', 'artist_audiodb_id') + w._verify_artist_id(item, {'idArtist': '999', 'strArtist': 'Kendrick Lamar'}) + assert w._corrections == [(1, '999')] + + +# -------------------------------------------------------------------------- +# Qobuz — _verify_artist_id(item, result_artist_id, result_artist_name). +# -------------------------------------------------------------------------- + +def test_qobuz_skips_correction_on_name_mismatch(): + w = _stub(QobuzWorker, '_correct_artist_qobuz_id') + item = _item('Jorja Smith', '111', 'artist_qobuz_id') + w._verify_artist_id(item, '999', 'Kendrick Lamar') + assert w._corrections == [] + + +def test_qobuz_corrects_on_name_match(): + w = _stub(QobuzWorker, '_correct_artist_qobuz_id') + item = _item('Kendrick Lamar', '111', 'artist_qobuz_id') + w._verify_artist_id(item, '999', 'Kendrick Lamar') + assert w._corrections == [(1, '999')] + + +# -------------------------------------------------------------------------- +# Tidal — _verify_artist_id(item, result_artist_id, result_artist_name). +# -------------------------------------------------------------------------- + +def test_tidal_skips_correction_on_name_mismatch(): + w = _stub(TidalWorker, '_correct_artist_tidal_id') + item = _item('Jorja Smith', '111', 'artist_tidal_id') + w._verify_artist_id(item, '999', 'Kendrick Lamar') + assert w._corrections == [] + + +def test_tidal_corrects_on_name_match(): + w = _stub(TidalWorker, '_correct_artist_tidal_id') + item = _item('Kendrick Lamar', '111', 'artist_tidal_id') + w._verify_artist_id(item, '999', 'Kendrick Lamar') + assert w._corrections == [(1, '999')] + + +# -------------------------------------------------------------------------- +# Shared: a missing result name preserves the old "trust the search" behavior +# (only the workers that pass an id+name — qobuz/tidal — exercise this path). +# -------------------------------------------------------------------------- + +def test_qobuz_missing_result_name_preserves_old_behavior(): + w = _stub(QobuzWorker, '_correct_artist_qobuz_id') + item = _item('Kendrick Lamar', '111', 'artist_qobuz_id') + w._verify_artist_id(item, '999', None) + assert w._corrections == [(1, '999')]