Sixth lift in the PR5 discovery-workers series. Pulls the 323-line
Beatport chart discovery worker out of `web_server.py` into its own
focused module under `core/discovery/`. Pure 1:1 lift — wrapper keeps
the original entry-point name.
What the Beatport discovery worker does:
1. Pause enrichment workers (release shared resources).
2. For each Beatport track:
- Cancellation gate (state['phase'] != 'discovering').
- Clean Beatport text (artist/title) of common annotations via
`clean_beatport_text` helper.
- Single-string artist normalization for "CID,Taylr Renee"-style
entries — split on comma, take the first.
- Discovery cache lookup; cache hit short-circuits the search and
normalizes cached artists from ['str'] → [{'name': 'str'}] to
match the frontend's expected list-of-objects shape.
- matching_engine search-query generation (with high min_confidence
of 0.9 to avoid bad matches).
- Strategy 1: scored candidates from initial Spotify/iTunes searches.
- Strategy 4: extended search with limit=50 if no high-confidence
match found.
- On Spotify match: format artists as [{'name': str}] objects, pull
full album object from raw cache when available, fallback to
reconstructed album dict otherwise.
- On iTunes match: format with image_url-derived album.images entry
(300x300 spec), source set to discovery_source.
- Save matched result to discovery cache when confidence >= 0.75
(note: lower than search threshold; discovery still benefits from
these less-confident matches as user-visible suggestions).
- On miss: Wing It stub stored as 'wing-it' status (success ticked).
3. After all tracks: phase='discovered', activity feed entry, sync
discovery results back to mirrored playlist via
`_sync_discovery_results_to_mirrored` with 'beatport' tag.
4. On error: state['phase']='fresh' + status='error'.
5. Finally: resume enrichment workers.
Dependencies injected via `BeatportDiscoveryDeps` (17 fields) —
beatport_chart_states, spotify_client, matching_engine, plus 14
callable helpers (pause/resume enrichment, get_active_discovery_source,
get_metadata_fallback_client, clean_beatport_text,
get_discovery_cache_key, get_database, validate_discovery_cache_artist,
spotify_rate_limited, discovery_score_candidates, get_metadata_cache,
build_discovery_wing_it_stub, add_activity_item,
sync_discovery_results_to_mirrored).
Diff vs original after `deps.X` → global X normalization is **zero
differences** — 323 lines orig = 323 lines lifted, byte-identical body
(including all whitespace, comments, log strings).
Tests: 12 new under tests/discovery/test_discovery_beatport.py covering
cache hit short-circuit (with cached-artist normalization), Spotify
match formatting (list and string artist inputs), iTunes match
(image_url to album.images), Wing It fallback, cancellation
(phase change), completion phase update, activity feed entry, mirrored
sync invocation, top-level error handler, per-track error handling,
comma-separated artist split.
Full suite: 1130 passing (was 1118). Ruff clean.
356 lines
12 KiB
Python
356 lines
12 KiB
Python
"""Tests for core/discovery/beatport.py — Beatport chart discovery worker."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
|
|
import pytest
|
|
|
|
from core.discovery import beatport as db
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fakes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@dataclass
|
|
class _FakeMatch:
|
|
id: str = 'spt-1'
|
|
name: str = 'Found'
|
|
artists: list = None
|
|
album: str = 'Found Album'
|
|
duration_ms: int = 200000
|
|
image_url: str = ''
|
|
release_date: str = '2024-01-01'
|
|
|
|
def __post_init__(self):
|
|
if self.artists is None:
|
|
self.artists = ['Found Artist']
|
|
|
|
|
|
class _FakeSpotifyClient:
|
|
def __init__(self, results=None, authenticated=True):
|
|
self._results = results if results is not None else []
|
|
self._authenticated = authenticated
|
|
self.search_calls = []
|
|
|
|
def is_spotify_authenticated(self):
|
|
return self._authenticated
|
|
|
|
def search_tracks(self, query, limit=10):
|
|
self.search_calls.append((query, limit))
|
|
return self._results
|
|
|
|
|
|
class _FakeITunesClient:
|
|
def __init__(self, results=None):
|
|
self._results = results if results is not None else []
|
|
self.search_calls = []
|
|
|
|
def search_tracks(self, query, limit=10):
|
|
self.search_calls.append((query, limit))
|
|
return self._results
|
|
|
|
|
|
class _FakeMatchingEngine:
|
|
def generate_download_queries(self, t):
|
|
return [f"{t.artists[0]} {t.name}"]
|
|
|
|
|
|
class _FakeDB:
|
|
def __init__(self, cache_match=None):
|
|
self._cache_match = cache_match
|
|
self.cache_saves = []
|
|
|
|
def get_discovery_cache_match(self, t, a, src):
|
|
return self._cache_match
|
|
|
|
def save_discovery_cache_match(self, t, a, src, conf, data, raw_t, raw_a):
|
|
self.cache_saves.append((t, a, src, conf))
|
|
|
|
|
|
class _FakeMetadataCache:
|
|
def get_entity(self, source, kind, entity_id):
|
|
return None
|
|
|
|
|
|
def _build_deps(
|
|
*,
|
|
states=None,
|
|
spotify_results=None,
|
|
spotify_auth=True,
|
|
itunes_results=None,
|
|
discovery_source='spotify',
|
|
cache_match=None,
|
|
rate_limited=False,
|
|
score_result=(None, 0.0, 0),
|
|
activity_log=None,
|
|
sync_calls=None,
|
|
):
|
|
activity_log = activity_log if activity_log is not None else []
|
|
sync_calls = sync_calls if sync_calls is not None else []
|
|
db_inst = _FakeDB(cache_match=cache_match)
|
|
spotify = _FakeSpotifyClient(results=spotify_results or [], authenticated=spotify_auth)
|
|
itunes = _FakeITunesClient(results=itunes_results or [])
|
|
|
|
deps = db.BeatportDiscoveryDeps(
|
|
beatport_chart_states=states if states is not None else {},
|
|
spotify_client=spotify,
|
|
matching_engine=_FakeMatchingEngine(),
|
|
pause_enrichment_workers=lambda label: {'paused': True},
|
|
resume_enrichment_workers=lambda state, label: None,
|
|
get_active_discovery_source=lambda: discovery_source,
|
|
get_metadata_fallback_client=lambda: itunes,
|
|
clean_beatport_text=lambda s: (s or '').strip(),
|
|
get_discovery_cache_key=lambda title, artist: (title.lower(), artist.lower()),
|
|
get_database=lambda: db_inst,
|
|
validate_discovery_cache_artist=lambda artist, m: True,
|
|
spotify_rate_limited=lambda: rate_limited,
|
|
discovery_score_candidates=lambda *args, **kw: score_result,
|
|
get_metadata_cache=lambda: _FakeMetadataCache(),
|
|
build_discovery_wing_it_stub=lambda title, artist: {
|
|
'name': title, 'artists': [artist], 'wing_it': True
|
|
},
|
|
add_activity_item=lambda *a, **kw: activity_log.append((a, kw)),
|
|
sync_discovery_results_to_mirrored=lambda *a, **kw: sync_calls.append((a, kw)),
|
|
)
|
|
deps._db = db_inst
|
|
deps._spotify = spotify
|
|
deps._itunes = itunes
|
|
deps._activity_log = activity_log
|
|
deps._sync_calls = sync_calls
|
|
return deps
|
|
|
|
|
|
def _seed_state(url_hash, states, *, tracks=None, phase='discovering'):
|
|
states[url_hash] = {
|
|
'chart': {'name': 'Top 100', 'tracks': tracks or []},
|
|
'phase': phase,
|
|
'spotify_matches': 0,
|
|
'discovery_results': [],
|
|
'discovery_progress': 0,
|
|
}
|
|
|
|
|
|
def _track(name='Track', artists=None):
|
|
return {'name': name, 'artists': artists or ['Artist']}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cache hit
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_cache_hit_short_circuits():
|
|
"""Cache hit appends Found result, normalizes artists ['str'] → [{'name'}]."""
|
|
states = {}
|
|
cached = {'name': 'Cached', 'artists': ['CA']}
|
|
_seed_state('h1', states, tracks=[_track()])
|
|
deps = _build_deps(states=states, cache_match=cached)
|
|
|
|
db.run_beatport_discovery_worker('h1', deps)
|
|
|
|
state = states['h1']
|
|
assert state['spotify_matches'] == 1
|
|
result = state['discovery_results'][0]
|
|
assert result['status'] == 'found'
|
|
assert deps._spotify.search_calls == [] # no live search
|
|
# artists normalized in the cached_match passed by reference
|
|
assert cached['artists'] == [{'name': 'CA'}]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Spotify match path
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_spotify_match_formats_artists_as_objects():
|
|
"""Spotify match → artists formatted as [{'name': str}] for frontend."""
|
|
states = {}
|
|
match = _FakeMatch(artists=['A1', 'A2'])
|
|
_seed_state('h2', states, tracks=[_track()])
|
|
deps = _build_deps(states=states, spotify_results=[match],
|
|
score_result=(match, 0.95, 0))
|
|
|
|
db.run_beatport_discovery_worker('h2', deps)
|
|
|
|
result = states['h2']['discovery_results'][0]
|
|
assert result['status'] == 'found'
|
|
assert result['confidence'] == 0.95
|
|
assert result['spotify_data']['artists'] == [{'name': 'A1'}, {'name': 'A2'}]
|
|
assert deps._db.cache_saves # cached
|
|
|
|
|
|
def test_spotify_artists_string_format_normalized():
|
|
"""Spotify result with single string artist → list-of-objects format."""
|
|
states = {}
|
|
match = _FakeMatch(artists='SoloArtist') # single string
|
|
_seed_state('h3', states, tracks=[_track()])
|
|
deps = _build_deps(states=states, spotify_results=[match],
|
|
score_result=(match, 0.95, 0))
|
|
|
|
db.run_beatport_discovery_worker('h3', deps)
|
|
|
|
result = states['h3']['discovery_results'][0]
|
|
assert result['spotify_data']['artists'] == [{'name': 'SoloArtist'}]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# iTunes match path
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_itunes_match_includes_image_url():
|
|
"""iTunes match → album.images includes image_url object."""
|
|
states = {}
|
|
match = _FakeMatch(image_url='http://it', name='iName', artists=['iA'])
|
|
_seed_state('h4', states, tracks=[_track()])
|
|
deps = _build_deps(
|
|
states=states, spotify_auth=False, discovery_source='itunes',
|
|
itunes_results=[match], score_result=(match, 0.95, 0),
|
|
)
|
|
|
|
db.run_beatport_discovery_worker('h4', deps)
|
|
|
|
result = states['h4']['discovery_results'][0]
|
|
assert result['spotify_data']['source'] == 'itunes'
|
|
images = result['spotify_data']['album']['images']
|
|
assert images[0]['url'] == 'http://it'
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Wing It fallback
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_no_match_falls_back_to_wing_it():
|
|
"""No high-confidence match → Wing It stub stored."""
|
|
states = {}
|
|
_seed_state('h5', states, tracks=[_track()])
|
|
deps = _build_deps(states=states, score_result=(None, 0.0, 0))
|
|
|
|
db.run_beatport_discovery_worker('h5', deps)
|
|
|
|
state = states['h5']
|
|
assert state.get('wing_it_count') == 1
|
|
result = state['discovery_results'][0]
|
|
assert result['wing_it_fallback'] is True
|
|
assert result['status_class'] == 'wing-it'
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cancellation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_phase_changed_aborts():
|
|
"""state['phase'] != 'discovering' aborts immediately."""
|
|
states = {}
|
|
_seed_state('h6', states, tracks=[_track(), _track('T2')], phase='cancelled')
|
|
deps = _build_deps(states=states)
|
|
|
|
db.run_beatport_discovery_worker('h6', deps)
|
|
|
|
assert states['h6']['discovery_results'] == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Completion
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_completion_marks_discovered():
|
|
"""Completion → phase='discovered', status='discovered', progress=100."""
|
|
states = {}
|
|
_seed_state('h7', states, tracks=[_track()])
|
|
deps = _build_deps(states=states)
|
|
|
|
db.run_beatport_discovery_worker('h7', deps)
|
|
|
|
assert states['h7']['phase'] == 'discovered'
|
|
assert states['h7']['status'] == 'discovered'
|
|
assert states['h7']['discovery_progress'] == 100
|
|
|
|
|
|
def test_activity_feed_logged():
|
|
"""Completion appends activity feed entry mentioning Beatport Discovery Complete."""
|
|
states = {}
|
|
_seed_state('h8', states, tracks=[_track()])
|
|
deps = _build_deps(states=states)
|
|
|
|
db.run_beatport_discovery_worker('h8', deps)
|
|
|
|
args, _ = deps._activity_log[0]
|
|
title = args[1]
|
|
assert 'Beatport Discovery Complete' in title
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Mirrored sync
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_sync_to_mirrored_invoked():
|
|
"""Completion calls sync_discovery_results_to_mirrored with 'beatport' tag."""
|
|
states = {}
|
|
_seed_state('h9', states, tracks=[_track()])
|
|
states['h9']['_profile_id'] = 3
|
|
deps = _build_deps(states=states)
|
|
|
|
db.run_beatport_discovery_worker('h9', deps)
|
|
|
|
assert len(deps._sync_calls) == 1
|
|
args, kwargs = deps._sync_calls[0]
|
|
assert args[0] == 'beatport'
|
|
assert args[1] == 'h9'
|
|
assert kwargs.get('profile_id') == 3
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Error handling
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_top_level_error_marks_state():
|
|
"""Exception in main try (state lookup ok) → phase='fresh', status='error'."""
|
|
states = {}
|
|
_seed_state('herr', states, tracks=[_track()])
|
|
deps = _build_deps(states=states)
|
|
deps.get_active_discovery_source = lambda: (_ for _ in ()).throw(RuntimeError("boom"))
|
|
|
|
db.run_beatport_discovery_worker('herr', deps)
|
|
|
|
assert states['herr']['phase'] == 'fresh'
|
|
assert states['herr']['status'] == 'error'
|
|
|
|
|
|
def test_per_track_error_appends_error_entry():
|
|
"""Per-track exception → 'error' result entry appended, loop continues."""
|
|
states = {}
|
|
_seed_state('h10', states, tracks=[_track('A'), _track('B')])
|
|
deps = _build_deps(states=states)
|
|
|
|
call_count = [0]
|
|
|
|
def raising_score(*args, **kw):
|
|
call_count[0] += 1
|
|
if call_count[0] == 1:
|
|
raise RuntimeError("score boom")
|
|
return (None, 0.0, 0)
|
|
|
|
deps.discovery_score_candidates = raising_score
|
|
|
|
db.run_beatport_discovery_worker('h10', deps)
|
|
|
|
state = states['h10']
|
|
# First track errored mid-search but loop continues; second processes Wing It path
|
|
assert len(state['discovery_results']) == 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Artist normalization (Beatport "CID,Taylr Renee" comma-split)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_comma_separated_artists_split_to_first():
|
|
"""Beatport 'CID,Taylr Renee' single-string-artists → first artist used."""
|
|
states = {}
|
|
track = _track(name='ABC', artists=['CID,Taylr Renee'])
|
|
_seed_state('h11', states, tracks=[track])
|
|
deps = _build_deps(states=states)
|
|
|
|
db.run_beatport_discovery_worker('h11', deps)
|
|
|
|
result = states['h11']['discovery_results'][0]
|
|
assert result['beatport_track']['artist'] == 'CID'
|