diff --git a/core/deezer_worker.py b/core/deezer_worker.py index 9b592627..63958da5 100644 --- a/core/deezer_worker.py +++ b/core/deezer_worker.py @@ -278,10 +278,19 @@ class DeezerWorker: 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: + def _verify_artist_id(self, item: Dict[str, Any], result_artist_id, + result_artist_name: Optional[str] = None) -> bool: """Verify that the result's artist ID matches the parent artist's stored Deezer ID. + If mismatched, the album/track search is more specific (uses artist+title), - so we trust it and correct the parent artist's deezer_id.""" + so we trust it and correct the parent artist's deezer_id — BUT only when + the result's artist *name* actually matches our parent artist. Without + that guard, a collaboration or compilation track (e.g. a track our + library credits to Jorja Smith that lives on Kendrick Lamar's curated + "Black Panther" album) would search up to an album whose Deezer primary + artist is someone else (Kendrick), and we'd stamp that wrong Deezer ID + onto our artist — corrupting it (and causing duplicate ids shared across + unrelated artists).""" parent_deezer_id = item.get('artist_deezer_id') if not parent_deezer_id: return True @@ -290,6 +299,20 @@ class DeezerWorker: return True if str(result_artist_id) != str(parent_deezer_id): + # Guard: only correct when the album/track's primary artist is the + # SAME artist by name. A mismatch means it's a collab/compilation, + # not a stale-id correction. + 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 Deezer ID from {parent_deezer_id} to {result_artist_id}" @@ -430,7 +453,8 @@ class DeezerWorker: # 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 for label, genres, explicit deezer_album_id = result.get('id') @@ -481,7 +505,8 @@ class DeezerWorker: # 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 for BPM deezer_track_id = result.get('id') diff --git a/tests/test_deezer_worker_artist_id_guard.py b/tests/test_deezer_worker_artist_id_guard.py new file mode 100644 index 00000000..2c46ed00 --- /dev/null +++ b/tests/test_deezer_worker_artist_id_guard.py @@ -0,0 +1,76 @@ +"""Regression: Deezer enrichment must not overwrite an artist's deezer_id from a +collaboration/compilation track whose primary artist is someone else. + +The Kendrick/Jorja bug: a track our library credits to Jorja Smith lives on +Kendrick Lamar's curated "Black Panther" album. The album/track search resolves +to that album, whose Deezer primary artist is Kendrick (id 525046). The old +``_verify_artist_id`` "corrected" Jorja's deezer_id to 525046 with no name +check — stamping one Deezer id across several unrelated artists, which later +broke the artist-detail page (it matched the wrong library artist by id). + +The fix gates the correction on a name match between the result's primary +artist and our parent artist. +""" + +from __future__ import annotations + +from core.deezer_worker import DeezerWorker + + +def _worker(): + # Bypass __init__ — it wants real clients/db. We only exercise the pure + # _verify_artist_id / _name_matches / _normalize_name logic. + w = DeezerWorker.__new__(DeezerWorker) + w.name_similarity_threshold = 0.80 + w._corrections = [] + w._correct_artist_deezer_id = lambda item, cid: w._corrections.append((item['id'], cid)) + return w + + +def _item(artist_name, parent_deezer_id): + return { + 'type': 'track', 'id': 1, 'name': 'Some Track', + 'artist': artist_name, 'artist_deezer_id': parent_deezer_id, + } + + +def test_no_correction_when_result_artist_name_differs(): + # Jorja Smith (deezer 999) but the track resolved to Kendrick's album + # (Deezer artist 525046, 'Kendrick Lamar') → must NOT overwrite. + w = _worker() + w._verify_artist_id(_item('Jorja Smith', '999'), '525046', 'Kendrick Lamar') + assert w._corrections == [] + + +def test_correction_when_names_match(): + # Same artist, stale/wrong stored id → legitimate correction proceeds. + w = _worker() + w._verify_artist_id(_item('Kendrick Lamar', '111'), '525046', 'Kendrick Lamar') + assert w._corrections == [(1, '525046')] + + +def test_name_match_tolerates_minor_variation(): + # Fuzzy match (feat. suffix / casing) still counts as the same artist. + w = _worker() + w._verify_artist_id(_item('Kendrick Lamar', '111'), '525046', 'KENDRICK LAMAR') + assert w._corrections == [(1, '525046')] + + +def test_no_correction_when_ids_already_equal(): + w = _worker() + w._verify_artist_id(_item('Whoever', '525046'), '525046', 'Anyone') + assert w._corrections == [] + + +def test_no_parent_id_is_noop(): + w = _worker() + w._verify_artist_id(_item('X', None), '525046', 'Y') + assert w._corrections == [] + + +def test_missing_result_name_preserves_old_behavior(): + # No artist name on the result → can't name-check; keep the original + # "trust the more specific album/track search" behavior. + w = _worker() + w._verify_artist_id(_item('Kendrick Lamar', '111'), '525046', None) + assert w._corrections == [(1, '525046')]