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.
98 lines
3.5 KiB
Python
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
|