soulsync/tests/test_artist_catalog_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

98 lines
3.5 KiB
Python

"""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