Closes the gap where similar artists only existed for WATCHLIST artists: a new
background worker populates them for the whole LIBRARY, slotting into the
existing enrichment-worker pattern (bubble + Manage Enrichment Workers modal,
status/pause/resume, matched/not_found/pending/errors).
Per source-matched library artist → get_musicmap_similar_artists(name, 25)
(the same matcher the artist-detail page uses: fetches MusicMap names, matches
each to the user's source chain — primary + active fallbacks — returns only
matched artists) → store via add_or_update_similar_artist keyed by the artist's
metadata source id, the SAME key the watchlist scanner + artist map use, so the
two cooperate (idempotent upsert + retry_days window).
- core/similar_artists_worker.py: pure seams (pick_source_artist_id,
map_payload_to_store_kwargs, process_artist) + the threaded worker; skips
artists not yet source-matched; classifies not_found vs transient error
(retry after 30d).
- DB migration: similar_artists_match_status / _last_attempted on artists
(mirrors every other source worker's tracking columns).
- Registered in EnrichmentService + instantiated in web_server, DEFAULT-PAUSED
(opt-in) like Amazon — MusicMap is scraped/outage-prone + this is library-wide.
- SERVICE_ENTITY_SUPPORT['similar_artists']=('artist',) so the modal breakdown
('artists with / without similars') + Retry work; manual-match (inapplicable
to a relationship) is gated out via relationship:true.
- 10 seam tests; existing 80 enrichment tests still pass.
Note: keys under profile 1 (single-profile setups); multi-profile is future work.
127 lines
5.6 KiB
Python
127 lines
5.6 KiB
Python
"""Seam tests for the Similar-Artists enrichment worker's pure logic.
|
|
|
|
The worker fills the similar_artists table for LIBRARY artists (the watchlist
|
|
scanner only does watchlist artists). These tests exercise the import-light
|
|
seams in isolation — no DB, no MusicMap — via injected fakes:
|
|
|
|
- pick_source_artist_id → keying priority (and skip un-matched artists)
|
|
- map_payload_to_store_kwargs → MusicMap {id,source} → the right id column
|
|
- process_artist → fetch→match→store orchestration + status codes
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import core.similar_artists_worker as w
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# pick_source_artist_id — which id keys the artist's similars (must match the
|
|
# watchlist scanner's priority so both write the SAME source_artist_id).
|
|
# --------------------------------------------------------------------------
|
|
|
|
def test_pick_source_artist_id_priority():
|
|
row = {'spotify_artist_id': 'sp1', 'itunes_artist_id': 'it1', 'deezer_id': 'dz1'}
|
|
assert w.pick_source_artist_id(row) == 'sp1' # spotify wins
|
|
assert w.pick_source_artist_id({'itunes_artist_id': 'it1', 'deezer_id': 'dz1'}) == 'it1'
|
|
assert w.pick_source_artist_id({'deezer_id': 'dz1'}) == 'dz1'
|
|
assert w.pick_source_artist_id({'musicbrainz_id': 'mb1'}) == 'mb1'
|
|
|
|
|
|
def test_pick_source_artist_id_none_when_unmatched():
|
|
# Library artist not matched to any metadata source yet → skip (None).
|
|
assert w.pick_source_artist_id({'spotify_artist_id': None, 'itunes_artist_id': ''}) is None
|
|
assert w.pick_source_artist_id({}) is None
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# map_payload_to_store_kwargs — MusicMap payload {id, source} → store kwarg.
|
|
# --------------------------------------------------------------------------
|
|
|
|
def test_map_payload_each_source():
|
|
assert w.map_payload_to_store_kwargs({'id': 'x', 'source': 'spotify'}) == {'similar_artist_spotify_id': 'x'}
|
|
assert w.map_payload_to_store_kwargs({'id': 'x', 'source': 'itunes'}) == {'similar_artist_itunes_id': 'x'}
|
|
assert w.map_payload_to_store_kwargs({'id': 'x', 'source': 'deezer'}) == {'similar_artist_deezer_id': 'x'}
|
|
assert w.map_payload_to_store_kwargs({'id': 'x', 'source': 'musicbrainz'}) == {'similar_artist_musicbrainz_id': 'x'}
|
|
|
|
|
|
def test_map_payload_unknown_source_or_no_id():
|
|
# discogs has no column → name-only (empty kwargs), not a crash.
|
|
assert w.map_payload_to_store_kwargs({'id': 'x', 'source': 'discogs'}) == {}
|
|
assert w.map_payload_to_store_kwargs({'source': 'spotify'}) == {} # no id
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# process_artist — fetch → store, status classification, keying.
|
|
# --------------------------------------------------------------------------
|
|
|
|
def _capture_store():
|
|
calls = []
|
|
|
|
def store(**kwargs):
|
|
calls.append(kwargs)
|
|
return True
|
|
return store, calls
|
|
|
|
|
|
def test_process_artist_matched_stores_with_keying():
|
|
store, calls = _capture_store()
|
|
payload = {
|
|
'success': True,
|
|
'similar_artists': [
|
|
{'name': 'B', 'id': 'sp_b', 'source': 'spotify', 'genres': ['rap'], 'popularity': 70},
|
|
{'name': 'C', 'id': 'it_c', 'source': 'itunes', 'image_url': 'http://x'},
|
|
],
|
|
}
|
|
status, count = w.process_artist('SRC1', 'A', lambda n, l: payload, store, limit=25, profile_id=1)
|
|
assert status == 'matched' and count == 2
|
|
# All similars keyed by the SOURCE artist id we passed (not the library PK).
|
|
assert all(c['source_artist_id'] == 'SRC1' for c in calls)
|
|
assert all(c['profile_id'] == 1 for c in calls)
|
|
# Provider id mapped to the right column; rank preserved in order.
|
|
assert calls[0]['similar_artist_spotify_id'] == 'sp_b' and calls[0]['similarity_rank'] == 1
|
|
assert calls[1]['similar_artist_itunes_id'] == 'it_c' and calls[1]['similarity_rank'] == 2
|
|
assert calls[0]['genres'] == ['rap'] and calls[0]['popularity'] == 70
|
|
|
|
|
|
def test_process_artist_not_found_when_no_matches():
|
|
store, calls = _capture_store()
|
|
status, count = w.process_artist('S', 'A', lambda n, l: {'success': True, 'similar_artists': []}, store)
|
|
assert status == 'not_found' and count == 0 and calls == []
|
|
|
|
|
|
def test_process_artist_not_found_on_404():
|
|
# Genuinely no MusicMap entry — shouldn't be retried as an error.
|
|
store, _ = _capture_store()
|
|
status, _ = w.process_artist('S', 'A', lambda n, l: {'success': False, 'status_code': 404}, store)
|
|
assert status == 'not_found'
|
|
|
|
|
|
def test_process_artist_error_on_outage_is_retriable():
|
|
# 5xx / no providers → transient error (worker retries after retry_days).
|
|
store, _ = _capture_store()
|
|
status, _ = w.process_artist('S', 'A', lambda n, l: {'success': False, 'status_code': 502}, store)
|
|
assert status == 'error'
|
|
|
|
|
|
def test_process_artist_error_when_fetch_raises():
|
|
store, _ = _capture_store()
|
|
|
|
def boom(n, l):
|
|
raise RuntimeError('musicmap down')
|
|
status, count = w.process_artist('S', 'A', boom, store)
|
|
assert status == 'error' and count == 0
|
|
|
|
|
|
def test_process_artist_skips_unstorable_but_counts_real():
|
|
# A store() that fails for one row shouldn't abort the rest.
|
|
calls = []
|
|
|
|
def store(**kwargs):
|
|
calls.append(kwargs)
|
|
return kwargs['similar_artist_name'] != 'B' # B fails to store
|
|
payload = {'success': True, 'similar_artists': [
|
|
{'name': 'B', 'id': '1', 'source': 'spotify'},
|
|
{'name': 'C', 'id': '2', 'source': 'spotify'},
|
|
]}
|
|
status, count = w.process_artist('S', 'A', lambda n, l: payload, store)
|
|
assert status == 'matched' and count == 1 and len(calls) == 2
|