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.
186 lines
6.7 KiB
Python
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
|