soulsync/tests/test_worker_artist_disambiguation.py
BoulderBadgeDad 177a4d8d05 #868: disambiguate same-name artists by owned-catalog overlap during enrichment
Enrichment matched artists by NAME ONLY (0.85 gate), so for a common name
('Rone' has ~5 artists) it stored whichever the source ranked first — often the
wrong one, which then drove a wrong/sparse library 'Standard' discography while
'Enhanced' (the real owned albums) showed the full set.

Fix — use the decisive signal the library already has (the albums you OWN):
- worker_utils: pick_artist_by_catalog() + catalog_overlap_score() +
  owned_album_titles()/release_titles(). When 2+ candidates clear the name gate,
  fetch each one's catalog and choose the one overlapping the owned albums; falls
  back to the current best-by-name pick when there's nothing to disambiguate or
  no overlap (so the common single-candidate path makes no extra API calls).
- Wired into Spotify (covers Spotify-Free, same client), iTunes, Deezer (now
  multi-candidate search_artists + get_artist_info store), and MusicBrainz
  (match_artist gains owned_titles; release-groups as the catalog).

Re-match path (#868):
- build_reset_query now also clears the stored source-ID column for artist/album
  item resets — previously a 're-match' only nulled match_status, so the worker's
  existing-id short-circuit re-confirmed the WRONG id and never re-resolved. Tracks
  excluded (ids live in tags, not a column).
- MusicBrainz also self-corrects its 90-day name->mbid cache: match_artist bypasses
  a cached mbid whose catalog has ZERO overlap with the owned albums, so a re-match
  isn't blocked by a stale wrong cache entry.

Tests: shared selector (9), per-worker disambiguation for all 4 sources + MB
backward-compat + MB cache-revalidation (8), reset-clears-id (2). 99 worker/
enrichment tests green.
2026-06-13 14:57:17 -07:00

186 lines
6.7 KiB
Python

"""Per-worker same-name artist disambiguation (#868).
Each enrichment worker, when several same-name candidates clear the name gate,
must pick the one whose catalog overlaps the albums the library actually owns —
not whichever the source ranked first. Covers Spotify (also the Spotify-Free
path, same client surface), iTunes, Deezer, and MusicBrainz.
"""
from __future__ import annotations
import types
# A query-aware fake DB: owned-albums query → owned titles; the source_id_conflict
# query (SELECT name FROM artists ...) → no conflict.
class _Cur:
def __init__(self, rows):
self._rows = rows
def fetchall(self):
return self._rows
class _Conn:
def __init__(self, owned):
self._owned = owned
def __enter__(self):
return self
def __exit__(self, *a):
return False
def execute(self, sql, params=()):
if 'FROM albums' in sql:
return _Cur([(t,) for t in self._owned])
return _Cur([]) # conflict check → none
def cursor(self):
return self
def commit(self):
pass
def close(self):
pass
class _DB:
def __init__(self, owned):
self._owned = owned
def _get_connection(self):
return _Conn(self._owned)
# The owned library: the RIGHT "Rone" made these.
OWNED = ['Tohu Bohu', 'Creatures', 'Mirapolis']
WRONG = types.SimpleNamespace(id='wrong_rone', name='Rone')
RIGHT = types.SimpleNamespace(id='right_rone', name='Rone')
ALBUMS = {
'wrong_rone': [{'title': 'Mixtape Vol 1'}, {'title': 'Random Single'}],
'right_rone': [{'title': 'Tohu Bohu (Deluxe)'}, {'title': 'Creatures'}],
}
def test_spotify_picks_artist_overlapping_owned_catalog():
from core.spotify_worker import SpotifyWorker
w = object.__new__(SpotifyWorker)
w.db = _DB(OWNED)
w.stats = {'matched': 0, 'not_found': 0, 'errors': 0}
w.client = types.SimpleNamespace(
search_artists=lambda name, limit=5: [WRONG, RIGHT], # wrong ranked first
get_artist_albums=lambda aid: ALBUMS.get(aid, []),
)
captured = {}
w._get_existing_id = lambda *a: None
w._mark_status = lambda *a: None
w._name_similarity = lambda a, b: 1.0 # both "Rone" clear the gate
w._is_spotify_id = lambda i: True
w._update_artist = lambda artist_id, obj: captured.update(id=obj.id)
w._process_artist({'id': 5, 'name': 'Rone'})
assert captured.get('id') == 'right_rone'
assert w.stats['matched'] == 1
def test_itunes_picks_artist_overlapping_owned_catalog():
from core.itunes_worker import iTunesWorker
w = object.__new__(iTunesWorker)
w.db = _DB(OWNED)
w.stats = {'matched': 0, 'not_found': 0, 'errors': 0}
w.client = types.SimpleNamespace(
search_artists=lambda name, limit=5: [WRONG, RIGHT],
get_artist_albums=lambda aid: ALBUMS.get(aid, []),
)
captured = {}
w._get_existing_id = lambda *a: None
w._mark_status = lambda *a: None
w._is_itunes_id = lambda i: True
w._update_artist = lambda artist_id, obj: captured.update(id=obj.id)
w._process_artist({'id': 5, 'name': 'Rone'})
assert captured.get('id') == 'right_rone'
assert w.stats['matched'] == 1
def test_deezer_picks_artist_overlapping_owned_catalog():
from core.deezer_worker import DeezerWorker
w = object.__new__(DeezerWorker)
w.db = _DB(OWNED)
w.stats = {'matched': 0, 'not_found': 0, 'errors': 0}
w.client = types.SimpleNamespace(
search_artists=lambda name, limit=5: [WRONG, RIGHT],
get_artist_albums_list=lambda aid: ALBUMS.get(aid, []),
get_artist_info=lambda aid: {'id': aid, 'name': 'Rone'},
)
captured = {}
w._get_existing_id = lambda *a: None
w._mark_status = lambda *a: None
w._update_artist = lambda artist_id, data: captured.update(id=data.get('id'))
w._process_artist(5, 'Rone')
assert captured.get('id') == 'right_rone'
assert w.stats['matched'] == 1
def _mb_service(owned_release_groups):
from core.musicbrainz_service import MusicBrainzService
s = object.__new__(MusicBrainzService)
s._check_cache = lambda *a, **k: None
s._save_to_cache = lambda *a, **k: None
s._calculate_similarity = lambda a, b: 1.0
s.mb_client = types.SimpleNamespace(
search_artist=lambda name, limit=5: [
{'id': 'wrong_mbid', 'name': 'Rone', 'score': 100}, # ranked first
{'id': 'right_mbid', 'name': 'Rone', 'score': 90},
],
get_artist=lambda mbid, includes=None: owned_release_groups.get(mbid),
)
return s
def test_musicbrainz_picks_artist_overlapping_owned_catalog():
rg = {
'wrong_mbid': {'release-groups': [{'title': 'Other Stuff'}]},
'right_mbid': {'release-groups': [{'title': 'Tohu Bohu'}, {'title': 'Creatures'}]},
}
result = _mb_service(rg).match_artist('Rone', owned_titles=OWNED)
assert result is not None
assert result['mbid'] == 'right_mbid'
def test_musicbrainz_without_owned_titles_keeps_legacy_behavior():
"""No owned titles → highest-confidence (name-order first) candidate, unchanged."""
rg = {'wrong_mbid': {'release-groups': []}, 'right_mbid': {'release-groups': []}}
result = _mb_service(rg).match_artist('Rone') # no owned_titles
assert result['mbid'] == 'wrong_mbid' # the top-confidence pick
def test_musicbrainz_cache_hit_used_when_catalog_overlaps():
"""A cached mbid whose catalog overlaps the owned albums is trusted (no re-resolve)."""
rg = {'cached_mbid': {'release-groups': [{'title': 'Tohu Bohu'}, {'title': 'Creatures'}]}}
s = _mb_service(rg)
s._check_cache = lambda *a, **k: {'musicbrainz_id': 'cached_mbid', 'confidence': 95}
s.mb_client.search_artist = lambda *a, **k: (_ for _ in ()).throw(
AssertionError("must not re-search when the cached match fits the library"))
result = s.match_artist('Rone', owned_titles=OWNED)
assert result['mbid'] == 'cached_mbid'
assert result['cached'] is True
def test_musicbrainz_stale_cache_is_bypassed_and_re_resolved():
"""A cached mbid with ZERO owned-catalog overlap (wrong same-name artist) is
bypassed → fresh disambiguated resolve. This is what makes a re-match work for
MB despite the 90-day cache TTL (#868)."""
rg = {
'cached_wrong': {'release-groups': [{'title': 'Unrelated'}]}, # the stale cache
'wrong_mbid': {'release-groups': [{'title': 'Other Stuff'}]},
'right_mbid': {'release-groups': [{'title': 'Tohu Bohu'}, {'title': 'Creatures'}]},
}
s = _mb_service(rg)
s._check_cache = lambda *a, **k: {'musicbrainz_id': 'cached_wrong', 'confidence': 95}
result = s.match_artist('Rone', owned_titles=OWNED)
assert result['mbid'] == 'right_mbid' # re-resolved + disambiguated
assert result.get('cached') is not True