#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.
This commit is contained in:
BoulderBadgeDad 2026-06-13 14:36:30 -07:00
parent 030d9bf9ff
commit 177a4d8d05
10 changed files with 549 additions and 46 deletions

View file

@ -8,7 +8,15 @@ from datetime import datetime, timedelta
from utils.logging_config import get_logger
from database.music_database import MusicDatabase
from core.deezer_client import DeezerClient
from core.worker_utils import accept_artist_match, interruptible_sleep, set_album_api_track_count
from core.worker_utils import (
accept_artist_match,
artist_name_matches,
interruptible_sleep,
owned_album_titles,
pick_artist_by_catalog,
release_titles,
set_album_api_track_count,
)
from core.enrichment.manual_match_honoring import honor_stored_match
logger = get_logger("deezer_worker")
@ -401,7 +409,20 @@ class DeezerWorker:
logger.debug(f"Preserving existing Deezer ID for artist '{artist_name}': {existing_id}")
return
result = self.client.search_artist(artist_name)
# Multi-candidate search (was single search_artist) so same-name artists
# can be disambiguated: gate by name, then pick the one whose catalog
# overlaps the albums this library owns.
results = self.client.search_artists(artist_name, limit=5)
gated = [a for a in (results or []) if artist_name_matches(artist_name, getattr(a, 'name', ''))]
chosen, _overlap = pick_artist_by_catalog(
gated,
owned_album_titles(self.db, artist_id),
lambda a: release_titles(self.client.get_artist_albums_list(a.id)),
)
# search_artists returns lean Artist objects; fetch the full dict (same
# shape the old search_artist returned) for storage.
result = self.client.get_artist_info(chosen.id) if chosen else None
if result:
result_name = result.get('name', '')
ok, reason = accept_artist_match(

View file

@ -216,7 +216,22 @@ def build_reset_query(
table = meta['table']
ms = match_status_column(service)
la = last_attempted_column(service)
set_clause = f"SET {ms} = NULL, {la} = NULL"
set_parts = [f"{ms} = NULL", f"{la} = NULL"]
# Also forget the stored source ID so re-matching actually RE-RESOLVES the
# entity. Without this, the worker hits its existing-id short-circuit, sees
# the old (possibly WRONG) id and just re-confirms it — which is why "click
# to rematch" never fixed a mis-matched same-name artist (#868). Tracks keep
# their ids in file tags rather than a column, so only artist/album clear one.
if entity_type in ('artist', 'album'):
try:
from core.source_ids import id_column
id_col = id_column(service, entity_type)
except Exception:
id_col = None
if id_col:
set_parts.append(f"{id_col} = NULL")
set_clause = "SET " + ", ".join(set_parts)
if scope == 'item':
if not entity_id:

View file

@ -9,7 +9,15 @@ from datetime import datetime, timedelta
from utils.logging_config import get_logger
from database.music_database import MusicDatabase
from core.itunes_client import iTunesClient
from core.worker_utils import accept_artist_match, interruptible_sleep, set_album_api_track_count
from core.worker_utils import (
accept_artist_match,
artist_name_matches,
interruptible_sleep,
owned_album_titles,
pick_artist_by_catalog,
release_titles,
set_album_api_track_count,
)
from core.enrichment.manual_match_honoring import honor_stored_match
logger = get_logger("itunes_worker")
@ -392,20 +400,30 @@ class iTunesWorker:
logger.debug(f"No iTunes results for artist '{artist_name}'")
return
for artist_obj in results:
# Candidates clearing the name gate (results are source-ranked, so [0] is
# the legacy "first passing" pick), then disambiguate same-name artists by
# which one's catalog overlaps the albums this library owns.
gated = [a for a in results if artist_name_matches(artist_name, a.name)]
chosen, _overlap = pick_artist_by_catalog(
gated,
owned_album_titles(self.db, artist_id),
lambda a: release_titles(self.client.get_artist_albums(a.id)),
)
if chosen:
ok, reason = accept_artist_match(
self.db, 'itunes_artist_id', artist_obj.id, artist_id,
artist_name, artist_obj.name,
self.db, 'itunes_artist_id', chosen.id, artist_id,
artist_name, chosen.name,
)
if ok:
if not self._is_itunes_id(artist_obj.id):
logger.warning(f"Rejecting non-iTunes ID '{artist_obj.id}' for artist '{artist_name}'")
if not self._is_itunes_id(chosen.id):
logger.warning(f"Rejecting non-iTunes ID '{chosen.id}' for artist '{artist_name}'")
self._mark_status('artist', artist_id, 'error')
self.stats['errors'] += 1
return
self._update_artist(artist_id, artist_obj)
self._update_artist(artist_id, chosen)
self.stats['matched'] += 1
logger.info(f"Matched artist '{artist_name}' -> iTunes ID: {artist_obj.id}")
logger.info(f"Matched artist '{artist_name}' -> iTunes ID: {chosen.id}")
return
self._mark_status('artist', artist_id, 'not_found')

View file

@ -5,6 +5,7 @@ from datetime import datetime, timedelta
from difflib import SequenceMatcher
from utils.logging_config import get_logger
from core.musicbrainz_client import MusicBrainzClient
from core.worker_utils import catalog_overlap_score, pick_artist_by_catalog
from database.music_database import MusicDatabase
logger = get_logger("musicbrainz_service")
@ -117,23 +118,51 @@ class MusicBrainzService:
if conn:
conn.close()
def match_artist(self, artist_name: str) -> Optional[Dict[str, Any]]:
def _candidate_release_titles(self, mbid: str) -> list:
"""Release-group titles for a candidate MBID — the catalog side of
same-name artist disambiguation."""
if not mbid:
return []
try:
data = self.mb_client.get_artist(mbid, includes=['release-groups'])
except Exception:
return []
groups = (data or {}).get('release-groups') or []
return [g.get('title') for g in groups if isinstance(g, dict) and g.get('title')]
def match_artist(self, artist_name: str, owned_titles: Optional[list] = None) -> Optional[Dict[str, Any]]:
"""
Match an artist by name to MusicBrainz
Match an artist by name to MusicBrainz.
``owned_titles`` the library artist's owned album titles. When given and
more than one strong same-name candidate exists, the one whose release
groups overlap those owned titles is chosen (disambiguates the ~5 "Rone"s);
omitted falls back to the highest-confidence candidate as before.
Returns:
Dict with 'mbid', 'name', 'confidence' or None if no good match
"""
# Check cache first
cached = self._check_cache('artist', artist_name)
if cached:
logger.debug(f"Cache hit for artist '{artist_name}'")
return {
'mbid': cached['musicbrainz_id'],
'name': artist_name,
'confidence': cached['confidence'],
'cached': True
}
cached_mbid = cached.get('musicbrainz_id')
# Don't trust a cached mbid whose catalog has ZERO overlap with the
# albums this library owns — that's the wrong same-name artist (and a
# re-match would otherwise be blocked for up to the 90-day cache TTL,
# #868). Fall through to a fresh, disambiguated resolve in that case.
stale_wrong_match = bool(
cached_mbid and owned_titles
and catalog_overlap_score(owned_titles, self._candidate_release_titles(cached_mbid)) == 0
)
if not stale_wrong_match:
logger.debug(f"Cache hit for artist '{artist_name}'")
return {
'mbid': cached_mbid,
'name': artist_name,
'confidence': cached['confidence'],
'cached': True
}
logger.debug(f"Cached MB match for '{artist_name}' has no owned-catalog overlap — re-resolving")
# Search MusicBrainz
try:
@ -144,25 +173,30 @@ class MusicBrainzService:
self._save_to_cache('artist', artist_name, None, None, None, 0)
return None
# Find best match
best_match = None
best_confidence = 0
# Score every candidate (name similarity 60% + MB's own relevance 40%).
scored = []
for result in results:
mb_name = result.get('name', '')
mb_score = result.get('score', 0) # MusicBrainz search score
# Calculate our own similarity
similarity = self._calculate_similarity(artist_name, mb_name)
# Combine MusicBrainz score with our similarity (weighted)
# Cap at 100 to prevent edge cases where MB score > 100
confidence = min(100, int((similarity * 60) + (mb_score / 100 * 40)))
if confidence > best_confidence:
best_confidence = confidence
best_match = result
scored.append((confidence, result))
scored.sort(key=lambda s: s[0], reverse=True)
# Among the strong (>=70) candidates, disambiguate same-name artists by
# which one's release groups overlap the albums this library owns.
gated = [r for conf, r in scored if conf >= 70]
best_match = None
best_confidence = scored[0][0] if scored else 0
if gated:
chosen, _overlap = pick_artist_by_catalog(
gated, owned_titles or [],
lambda r: self._candidate_release_titles(r.get('id')),
)
best_match = chosen
best_confidence = next(conf for conf, r in scored if r is chosen)
# Only return matches with confidence >= 70%
if best_match and best_confidence >= 70:
mbid = best_match.get('id')

View file

@ -5,7 +5,7 @@ from datetime import datetime, timedelta
from utils.logging_config import get_logger
from database.music_database import MusicDatabase
from core.musicbrainz_service import MusicBrainzService
from core.worker_utils import interruptible_sleep, source_id_conflict
from core.worker_utils import interruptible_sleep, owned_album_titles, source_id_conflict
logger = get_logger("musicbrainz_worker")
@ -366,7 +366,8 @@ class MusicBrainzWorker:
return
if item_type == 'artist':
result = self.mb_service.match_artist(item_name)
result = self.mb_service.match_artist(
item_name, owned_titles=owned_album_titles(self.db, item_id))
mbid = result.get('mbid') if result else None
# MB's combined score can match a weak name ("Grant" -> "Amy
# Grant") when its own relevance rank is high. Guard against

View file

@ -12,6 +12,9 @@ from core.spotify_client import SpotifyClient, SpotifyRateLimitError
from core.worker_utils import (
ARTIST_NAME_MATCH_THRESHOLD,
interruptible_sleep,
owned_album_titles,
pick_artist_by_catalog,
release_titles,
set_album_api_track_count,
source_id_conflict,
)
@ -563,16 +566,23 @@ class SpotifyWorker:
logger.debug(f"No Spotify results for artist '{artist_name}'")
return
# Find best fuzzy match — score all candidates, pick highest above the
# (stricter, artist-specific) threshold so short-name false positives
# like "ODESZA"/"odessa" don't slip through.
best_obj = None
best_score = 0
for artist_obj in results:
score = self._name_similarity(artist_name, artist_obj.name)
if score >= ARTIST_NAME_MATCH_THRESHOLD and score > best_score:
best_obj = artist_obj
best_score = score
# Candidates clearing the (stricter, artist-specific) name gate, best
# name-score first so [0] is the legacy "first/highest" pick.
scored = [
(self._name_similarity(artist_name, a.name), a)
for a in results
]
gated = [a for score, a in sorted(scored, key=lambda s: s[0], reverse=True)
if score >= ARTIST_NAME_MATCH_THRESHOLD]
# Same-name disambiguation: when more than one "Rone" clears the gate,
# pick the one whose catalog overlaps the albums this library owns.
best_obj, _overlap = pick_artist_by_catalog(
gated,
owned_album_titles(self.db, artist_id),
lambda a: release_titles(self.client.get_artist_albums(a.id)),
)
best_score = self._name_similarity(artist_name, best_obj.name) if best_obj else 0
if best_obj:
if not self._is_spotify_id(best_obj.id):

View file

@ -101,6 +101,108 @@ def accept_artist_match(database, id_column: str, source_id, artist_id,
return True, ""
# --- Same-name artist disambiguation by owned-catalog overlap -------------
# The name gate (above) can't separate two artists who share a name ("Rone" has
# ~5). The decisive signal is the library itself: the user owns albums by the
# RIGHT one. So when several candidates clear the name gate, fetch each one's
# catalog and pick the one whose releases overlap the albums actually owned.
def normalize_release_title(title: str) -> str:
"""Collapse an album/release title for tolerant comparison — drop edition
suffixes ('(Deluxe)', ' - Remastered'), punctuation, and case."""
t = (title or '').lower().strip()
t = re.sub(r'\s*\(.*?\)\s*', ' ', t)
t = re.sub(r'\s*\[.*?\]\s*', ' ', t)
t = re.sub(r'\s+[-–—]\s+.*$', '', t)
t = re.sub(r'[^\w\s]', '', t)
t = re.sub(r'\s+', ' ', t).strip()
return t
def catalog_overlap_score(owned_titles, candidate_titles, threshold: float = 0.85) -> int:
"""How many OWNED album titles appear in the candidate's catalog (fuzzy,
edition-insensitive). The disambiguation signal higher = better match."""
owned = {normalize_release_title(t) for t in (owned_titles or []) if t}
owned.discard('')
cand = {normalize_release_title(t) for t in (candidate_titles or []) if t}
cand.discard('')
if not owned or not cand:
return 0
score = 0
for o in owned:
if o in cand or any(SequenceMatcher(None, o, c).ratio() >= threshold for c in cand):
score += 1
return score
def pick_artist_by_catalog(candidates, owned_titles, fetch_titles) -> tuple:
"""Choose, among same-name candidates, the one whose catalog best overlaps the
OWNED albums. Returns ``(chosen, overlap_score)``.
``candidates`` artist objects already past the name gate (order = the
worker's existing best-by-name order; candidates[0] is the
current behavior's pick).
``owned_titles`` the library artist's owned album titles.
``fetch_titles(candidate) -> list[str]`` that candidate's album titles;
called ONLY when disambiguation is actually needed (2+
candidates and we have owned albums), so the common
single-candidate path costs no extra API calls.
Falls back to candidates[0] (unchanged behavior) when there's nothing to
disambiguate or no candidate overlaps the owned catalog.
"""
candidates = list(candidates or [])
if not candidates:
return None, 0
if len(candidates) == 1:
return candidates[0], 0
owned = [t for t in (owned_titles or []) if t]
if not owned:
return candidates[0], 0
best, best_score = None, 0
for cand in candidates:
try:
titles = fetch_titles(cand) or []
except Exception as exc:
logger.debug("catalog disambiguation: fetch_titles failed: %s", exc)
titles = []
score = catalog_overlap_score(owned, titles)
if score > best_score:
best, best_score = cand, score
if best is not None and best_score > 0:
return best, best_score
return candidates[0], 0 # no overlap signal → keep the best-by-name pick
def owned_album_titles(database, artist_id) -> list:
"""The album titles the library actually has for this artist — the ground
truth used to disambiguate same-name source artists."""
try:
with database._get_connection() as conn:
rows = conn.execute(
"SELECT title FROM albums WHERE artist_id = ?", (artist_id,)
).fetchall()
return [r[0] for r in rows if r and r[0]]
except Exception as exc:
logger.debug("owned_album_titles(%s) failed: %s", artist_id, exc)
return []
def release_titles(albums) -> list:
"""Extract titles from a list of album objects/dicts (handles ``.title``/
``.name`` / dict keys) the candidate side of catalog disambiguation."""
out = []
for al in albums or []:
if isinstance(al, dict):
t = al.get('title') or al.get('name')
else:
t = getattr(al, 'title', None) or getattr(al, 'name', None)
if t:
out.append(t)
return out
def interruptible_sleep(stop_event: threading.Event, seconds: float, step: float = 0.5) -> bool:
"""Sleep in chunks so shutdown can interrupt long waits."""
if seconds <= 0:

View file

@ -187,6 +187,24 @@ def test_reset_builder_nulls_status_not_just_attempted():
assert "WHERE spotify_match_status = 'not_found'" in sql
def test_reset_builder_also_clears_artist_source_id():
# #868: a re-match must forget the stored id so the worker actually
# re-resolves (otherwise its existing-id short-circuit re-confirms the wrong
# same-name artist).
for service, col in [('spotify', 'spotify_artist_id'), ('itunes', 'itunes_artist_id'),
('deezer', 'deezer_id'), ('musicbrainz', 'musicbrainz_id')]:
sql, _ = build_reset_query(service, 'artist', 'item', entity_id=5)
assert f'{col} = NULL' in sql, f'{service}: expected {col} cleared'
assert f'{service}_match_status = NULL' in sql
def test_reset_builder_does_not_clear_track_id():
# Tracks have no source-id column (ids live in tags) — must not emit one.
sql, _ = build_reset_query('spotify', 'track', 'item', entity_id=5)
assert 'spotify_match_status = NULL' in sql
assert 'spotify_track_id = NULL' not in sql
def test_reset_item_requeues_to_pending(db):
n = db.reset_enrichment('spotify', 'artist', 'item', entity_id='a2') # was not_found
assert n == 1

View file

@ -0,0 +1,98 @@
"""Same-name artist disambiguation by owned-catalog overlap (#868).
Enrichment matched artists by NAME ONLY, so for a common name ("Rone" has ~5
artists) it grabbed whichever the source ranked first often the wrong one,
which then drove a wrong/sparse library discography. The fix: when several
candidates clear the name gate, pick the one whose catalog overlaps the albums
the user actually OWNS. These pin the source-agnostic selector.
"""
from __future__ import annotations
from core.worker_utils import (
catalog_overlap_score,
normalize_release_title,
pick_artist_by_catalog,
)
# --- normalization ---------------------------------------------------------
def test_normalize_strips_editions_and_punctuation():
assert normalize_release_title('Tohu Bohu (Deluxe Edition)') == 'tohu bohu'
assert normalize_release_title('Mirapolis - Remastered') == 'mirapolis'
assert normalize_release_title('Room with a View [2020]') == 'room with a view'
assert normalize_release_title('') == ''
# --- overlap scoring -------------------------------------------------------
def test_overlap_counts_matching_owned_titles():
owned = ['Tohu Bohu', 'Creatures', 'Mirapolis']
cand = ['Tohu Bohu (Deluxe)', 'Creatures', 'Spanish Breakfast', 'Motion']
assert catalog_overlap_score(owned, cand) == 2 # Tohu Bohu + Creatures
def test_overlap_zero_for_a_different_artists_catalog():
owned = ['Tohu Bohu', 'Creatures', 'Mirapolis']
cand = ['Some Other Record', 'Unrelated Album']
assert catalog_overlap_score(owned, cand) == 0
def test_overlap_zero_when_either_side_empty():
assert catalog_overlap_score([], ['A']) == 0
assert catalog_overlap_score(['A'], []) == 0
# --- the selector ----------------------------------------------------------
def _cand(cid, titles):
return {'id': cid, '_titles': titles}
def _fetch(cand):
return cand['_titles']
def test_single_candidate_returns_without_fetching():
calls = []
chosen, score = pick_artist_by_catalog(
[_cand('only', ['X'])], ['Tohu Bohu'],
lambda c: calls.append(c) or c['_titles'])
assert chosen['id'] == 'only'
assert calls == [] # never fetched — nothing to disambiguate
def test_no_owned_albums_keeps_name_order():
calls = []
chosen, score = pick_artist_by_catalog(
[_cand('first', ['A']), _cand('second', ['B'])], [],
lambda c: calls.append(c) or c['_titles'])
assert chosen['id'] == 'first' # candidates[0] — current behavior
assert calls == []
def test_picks_the_candidate_overlapping_owned_catalog():
# The WRONG Rone is ranked first; the right one overlaps the owned albums.
wrong = _cand('wrong', ['Rap Mixtape Vol 1', 'Some Single'])
right = _cand('right', ['Tohu Bohu', 'Creatures', 'Mirapolis'])
chosen, score = pick_artist_by_catalog(
[wrong, right], ['Tohu Bohu', 'Creatures', 'Spanish Breakfast'], _fetch)
assert chosen['id'] == 'right'
assert score == 2
def test_no_overlap_anywhere_falls_back_to_first():
a = _cand('a', ['Nope']); b = _cand('b', ['Also Nope'])
chosen, score = pick_artist_by_catalog([a, b], ['Tohu Bohu'], _fetch)
assert chosen['id'] == 'a'
assert score == 0
def test_fetch_failure_is_tolerated():
def _boom(_c):
raise RuntimeError('api down')
chosen, score = pick_artist_by_catalog(
[_cand('a', []), _cand('b', [])], ['Tohu Bohu'], _boom)
assert chosen['id'] == 'a' # both fail → fall back to first
assert score == 0

View file

@ -0,0 +1,186 @@
"""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