diff --git a/core/discovery/listenbrainz.py b/core/discovery/listenbrainz.py new file mode 100644 index 00000000..83b0b03c --- /dev/null +++ b/core/discovery/listenbrainz.py @@ -0,0 +1,342 @@ +"""Background worker for ListenBrainz playlist discovery. + +`run_listenbrainz_discovery_worker(state_key, deps)` is the function +the listenbrainz discovery start-endpoint submits to its executor to +match each ListenBrainz playlist track against Spotify (preferred) or +iTunes (fallback): + +1. Pause enrichment workers (release shared resources). +2. For each ListenBrainz track: + - Cancellation gate (state['phase'] != 'discovering'). + - Discovery cache lookup; cache hit short-circuits the search. + - Strategy 1: matching_engine search queries with confidence scoring. + - Strategy 2: swapped artist/title query. + - Strategy 3: album-based query (if album_name available). + - Strategy 4: extended search with limit=50. + - On match → save to discovery cache. + - On miss → build Wing It stub from raw source data. +3. After loop: phase='discovered', activity feed entry. +4. On error → state['status']='error', phase='fresh'. +5. Finally: resume enrichment workers. + +Lifted verbatim from web_server.py. Wide dependency surface (Spotify +and iTunes clients, matching engine, multiple metadata helpers, state +dict, database access) all injected via `ListenbrainzDiscoveryDeps`. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any, Callable + +logger = logging.getLogger(__name__) + + +@dataclass +class ListenbrainzDiscoveryDeps: + """Bundle of cross-cutting deps the ListenBrainz discovery worker needs.""" + listenbrainz_playlist_states: dict + spotify_client: Any + matching_engine: Any + pause_enrichment_workers: Callable[[str], dict] + resume_enrichment_workers: Callable[[dict, str], None] + get_active_discovery_source: Callable[[], str] + get_metadata_fallback_client: Callable[[], Any] + get_discovery_cache_key: Callable + get_database: Callable[[], Any] + validate_discovery_cache_artist: Callable + extract_artist_name: Callable + spotify_rate_limited: Callable[[], bool] + discovery_score_candidates: Callable + get_metadata_cache: Callable[[], Any] + build_discovery_wing_it_stub: Callable + add_activity_item: Callable + + +def run_listenbrainz_discovery_worker(state_key, deps: ListenbrainzDiscoveryDeps): + """Background worker for ListenBrainz music discovery process (Spotify preferred, iTunes fallback)""" + playlist_mbid = state_key.split(':', 1)[1] if ':' in state_key else state_key + _ew_state = {} + try: + _ew_state = deps.pause_enrichment_workers('ListenBrainz discovery') + state = deps.listenbrainz_playlist_states[state_key] + playlist = state['playlist'] + tracks = playlist['tracks'] + + # Determine which provider to use (Spotify preferred, iTunes fallback) + discovery_source = deps.get_active_discovery_source() + use_spotify = (discovery_source == 'spotify') and deps.spotify_client and deps.spotify_client.is_spotify_authenticated() + + # Get fallback client + itunes_client = deps.get_metadata_fallback_client() + + logger.info(f"Starting {discovery_source} discovery for {len(tracks)} ListenBrainz tracks...") + + # Store the discovery source in state + state['discovery_source'] = discovery_source + + # Process each track for discovery + for i, track in enumerate(tracks): + try: + # Check for cancellation + if state.get('phase') != 'discovering': + logger.warning(f"ListenBrainz discovery cancelled (phase changed to '{state.get('phase')}')") + return + + # Update progress + state['discovery_progress'] = int((i / len(tracks)) * 100) + + # Get cleaned track data from ListenBrainz + cleaned_title = track['track_name'] + cleaned_artist = track['artist_name'] + album_name = track.get('album_name', '') + duration_ms = track.get('duration_ms', 0) + + logger.info(f"Searching {discovery_source} for: '{cleaned_artist}' - '{cleaned_title}'") + + # Check discovery cache first + cache_key = deps.get_discovery_cache_key(cleaned_title, cleaned_artist) + try: + cache_db = deps.get_database() + cached_match = cache_db.get_discovery_cache_match(cache_key[0], cache_key[1], discovery_source) + if cached_match and deps.validate_discovery_cache_artist(cleaned_artist, cached_match): + logger.debug(f"CACHE HIT [{i+1}/{len(tracks)}]: {cleaned_artist} - {cleaned_title}") + result = { + 'index': i, + 'lb_track': cleaned_title, + 'lb_artist': cleaned_artist, + 'status': 'Found', + 'status_class': 'found', + 'spotify_track': cached_match.get('name', ''), + 'spotify_artist': deps.extract_artist_name(cached_match.get('artists', [''])[0]) if cached_match.get('artists') else '', + 'spotify_album': cached_match.get('album', {}).get('name', '') if isinstance(cached_match.get('album'), dict) else cached_match.get('album', ''), + 'duration': f"{int(duration_ms) // 60000}:{(int(duration_ms) % 60000) // 1000:02d}" if duration_ms else '0:00', + 'discovery_source': discovery_source, + 'matched_data': cached_match, + 'spotify_data': cached_match + } + state['spotify_matches'] += 1 + state['discovery_results'].append(result) + continue + except Exception as cache_err: + logger.error(f"Cache lookup error: {cache_err}") + + # Try multiple search strategies using matching engine + matched_track = None + best_confidence = 0.0 + best_raw_track = None + min_confidence = 0.9 + source_duration = duration_ms or 0 + + # Strategy 1: Use matching_engine search queries + try: + temp_track = type('TempTrack', (), { + 'name': cleaned_title, + 'artists': [cleaned_artist], + 'album': album_name if album_name else None + })() + search_queries = deps.matching_engine.generate_download_queries(temp_track) + logger.info(f"Generated {len(search_queries)} search queries for ListenBrainz track") + except Exception as e: + logger.error(f"Matching engine failed for ListenBrainz, falling back to basic query: {e}") + search_queries = [f"{cleaned_artist} {cleaned_title}", cleaned_title] + + for query_idx, search_query in enumerate(search_queries): + try: + logger.debug(f"ListenBrainz query {query_idx + 1}/{len(search_queries)}: {search_query}") + + search_results = None + + if use_spotify and not deps.spotify_rate_limited(): + search_results = deps.spotify_client.search_tracks(search_query, limit=10) + else: + search_results = itunes_client.search_tracks(search_query, limit=10) + + if not search_results: + continue + + # Score all results using the matching engine + match, confidence, match_idx = deps.discovery_score_candidates( + cleaned_title, cleaned_artist, source_duration, search_results + ) + + if match and confidence > best_confidence and confidence >= min_confidence: + best_confidence = confidence + matched_track = match + if use_spotify and match.id: + _cache = deps.get_metadata_cache() + best_raw_track = _cache.get_entity('spotify', 'track', match.id) + else: + best_raw_track = None + logger.info(f"New best ListenBrainz match: {match.artists[0]} - {match.name} (confidence: {confidence:.3f})") + + if best_confidence >= 0.9: + logger.info(f"High confidence ListenBrainz match found ({best_confidence:.3f}), stopping search") + break + + except Exception as e: + logger.debug(f"Error in ListenBrainz search for query '{search_query}': {e}") + continue + + if matched_track: + logger.info(f"Strategy 1 ListenBrainz match: {matched_track.artists[0]} - {matched_track.name} (confidence: {best_confidence:.3f})") + + # Strategy 2: Swapped search (if first failed) - score results properly + if not matched_track: + logger.info("ListenBrainz Strategy 2: Trying swapped search (artist/title reversed)") + if use_spotify: + query = f"artist:{cleaned_title} track:{cleaned_artist}" + fallback_results = deps.spotify_client.search_tracks(query, limit=5) + else: + query = f"{cleaned_title} {cleaned_artist}" + fallback_results = itunes_client.search_tracks(query, limit=5) + if fallback_results: + match, confidence, _ = deps.discovery_score_candidates( + cleaned_title, cleaned_artist, source_duration, fallback_results + ) + if match and confidence >= min_confidence: + matched_track = match + best_confidence = confidence + logger.info(f"Strategy 2 ListenBrainz match (swapped): {match.artists[0]} - {match.name} (confidence: {confidence:.3f})") + + # Strategy 3: Album-based search (if still failed and we have album name) - score results properly + if not matched_track and album_name: + logger.info(f"ListenBrainz Strategy 3: Trying album-based search: '{cleaned_artist} {album_name} {cleaned_title}'") + if use_spotify: + query = f"artist:{cleaned_artist} album:{album_name} track:{cleaned_title}" + fallback_results = deps.spotify_client.search_tracks(query, limit=5) + else: + query = f"{cleaned_artist} {album_name} {cleaned_title}" + fallback_results = itunes_client.search_tracks(query, limit=5) + if fallback_results: + match, confidence, _ = deps.discovery_score_candidates( + cleaned_title, cleaned_artist, source_duration, fallback_results + ) + if match and confidence >= min_confidence: + matched_track = match + best_confidence = confidence + logger.info(f"Strategy 3 ListenBrainz match (album): {match.artists[0]} - {match.name} (confidence: {confidence:.3f})") + + # Strategy 4: Extended search with higher limit (last resort) + if not matched_track: + logger.info("ListenBrainz Strategy 4: Extended search with limit=50") + query = f"{cleaned_artist} {cleaned_title}" + if use_spotify: + extended_results = deps.spotify_client.search_tracks(query, limit=50) + else: + extended_results = itunes_client.search_tracks(query, limit=50) + if extended_results: + match, confidence, _ = deps.discovery_score_candidates( + cleaned_title, cleaned_artist, source_duration, extended_results + ) + if match and confidence >= min_confidence: + matched_track = match + best_confidence = confidence + logger.info(f"Strategy 4 ListenBrainz match (extended): {match.artists[0]} - {match.name} (confidence: {confidence:.3f})") + + # Create result entry + result = { + 'index': i, + 'lb_track': cleaned_title, + 'lb_artist': cleaned_artist, + 'status': 'Found' if matched_track else 'Not Found', + 'status_class': 'found' if matched_track else 'not-found', + 'spotify_track': matched_track.name if matched_track else '', + 'spotify_artist': deps.extract_artist_name(matched_track.artists[0]) if matched_track else '', + 'spotify_album': matched_track.album if matched_track else '', + 'duration': f"{int(duration_ms) // 60000}:{(int(duration_ms) % 60000) // 1000:02d}" if duration_ms else '0:00', + 'discovery_source': discovery_source, + 'confidence': best_confidence + } + + if matched_track: + state['spotify_matches'] += 1 + + # Build album data based on provider + if use_spotify and best_raw_track: + album_data = best_raw_track.get('album', {}) + else: + album_data = { + 'name': matched_track.album, + 'album_type': 'album', + 'release_date': getattr(matched_track, 'release_date', '') or '', + 'images': [{'url': matched_track.image_url}] if hasattr(matched_track, 'image_url') and matched_track.image_url else [] + } + + # Extract image URL for discovery pool display + _yt_album_images = album_data.get('images', []) + _yt_image_url = _yt_album_images[0].get('url', '') if _yt_album_images else (getattr(matched_track, 'image_url', '') or '') + + result['matched_data'] = { + 'id': matched_track.id, + 'name': matched_track.name, + 'artists': matched_track.artists, + 'album': album_data, + 'duration_ms': matched_track.duration_ms, + 'image_url': _yt_image_url, + 'source': discovery_source + } + result['spotify_data'] = result['matched_data'] + + # Save to discovery cache (only high-confidence matches) + if best_confidence >= 0.7: + try: + cache_db = deps.get_database() + cache_db.save_discovery_cache_match( + cache_key[0], cache_key[1], discovery_source, best_confidence, + result['matched_data'], cleaned_title, cleaned_artist + ) + logger.info(f"CACHE SAVED: {cleaned_artist} - {cleaned_title} (confidence: {best_confidence:.3f})") + except Exception as cache_err: + logger.error(f"Cache save error: {cache_err}") + + else: + # Auto Wing It fallback — build stub from raw source data + stub = deps.build_discovery_wing_it_stub(cleaned_title, cleaned_artist, duration_ms) + result['status'] = 'Wing It' + result['status_class'] = 'wing-it' + result['spotify_track'] = cleaned_title + result['spotify_artist'] = cleaned_artist + result['spotify_album'] = '' + result['matched_data'] = stub + result['spotify_data'] = stub + result['wing_it_fallback'] = True + state['wing_it_count'] = state.get('wing_it_count', 0) + 1 + + state['discovery_results'].append(result) + + logger.info(f" {'' if matched_track else ''} Track {i+1}/{len(tracks)}: {result['status']}") + + except Exception as e: + logger.error(f"Error processing track {i}: {e}") + result = { + 'index': i, + 'lb_track': track['track_name'], + 'lb_artist': track['artist_name'], + 'status': 'Error', + 'status_class': 'error', + 'spotify_track': '', + 'spotify_artist': '', + 'spotify_album': '', + 'duration': '0:00' + } + state['discovery_results'].append(result) + + # Complete discovery + state['phase'] = 'discovered' + state['status'] = 'complete' + state['discovery_progress'] = 100 + + playlist_name = playlist.get('name') or playlist.get('title') or 'Unknown Playlist' + source_label = discovery_source.upper() + deps.add_activity_item("", f"ListenBrainz Discovery Complete ({source_label})", f"'{playlist_name}' - {state['spotify_matches']}/{len(tracks)} tracks found", "Now") + + logger.info(f"ListenBrainz discovery complete ({discovery_source}): {state['spotify_matches']}/{len(tracks)} tracks matched") + + except Exception as e: + logger.error(f"Error in ListenBrainz discovery worker: {e}") + state['status'] = 'error' + state['phase'] = 'fresh' + finally: + deps.resume_enrichment_workers(_ew_state, 'ListenBrainz discovery') diff --git a/tests/discovery/test_discovery_listenbrainz.py b/tests/discovery/test_discovery_listenbrainz.py new file mode 100644 index 00000000..1be3cb40 --- /dev/null +++ b/tests/discovery/test_discovery_listenbrainz.py @@ -0,0 +1,346 @@ +"""Tests for core/discovery/listenbrainz.py — ListenBrainz discovery worker.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pytest + +from core.discovery import listenbrainz as dl + + +# --------------------------------------------------------------------------- +# Fakes +# --------------------------------------------------------------------------- + +@dataclass +class _FakeMatch: + id: str = 'spt-1' + name: str = 'Found Title' + 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, track): + return [f"{track.artists[0]} {track.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, +): + activity_log = activity_log if activity_log is not None else [] + db = _FakeDB(cache_match=cache_match) + spotify = _FakeSpotifyClient(results=spotify_results or [], authenticated=spotify_auth) + itunes = _FakeITunesClient(results=itunes_results or []) + + deps = dl.ListenbrainzDiscoveryDeps( + listenbrainz_playlist_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, + get_discovery_cache_key=lambda title, artist: (title.lower(), artist.lower()), + get_database=lambda: db, + validate_discovery_cache_artist=lambda artist, m: True, + extract_artist_name=lambda a: a if isinstance(a, str) else a.get('name', ''), + 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, dur: { + 'name': title, 'artists': [artist], 'duration_ms': dur, 'wing_it': True + }, + add_activity_item=lambda *a, **kw: activity_log.append((a, kw)), + ) + deps._db = db + deps._spotify = spotify + deps._itunes = itunes + deps._activity_log = activity_log + return deps + + +def _seed_state(state_key, states, *, tracks=None, phase='discovering'): + states[state_key] = { + 'phase': phase, + 'playlist': {'name': 'My LB Playlist', 'tracks': tracks or []}, + 'spotify_matches': 0, + 'discovery_results': [], + 'discovery_progress': 0, + } + + +def _track(track_name='LB Track', artist_name='LB Artist', album_name='LB Album', duration_ms=180000): + return { + 'track_name': track_name, + 'artist_name': artist_name, + 'album_name': album_name, + 'duration_ms': duration_ms, + } + + +# --------------------------------------------------------------------------- +# Cache hit +# --------------------------------------------------------------------------- + +def test_cache_hit_short_circuits(): + """Cache hit appends Found result, no live search.""" + states = {} + cached = { + 'name': 'Cached Match', + 'artists': ['Cached Artist'], + 'album': {'name': 'Cached Album'}, + } + _seed_state('lb1', states, tracks=[_track()]) + deps = _build_deps(states=states, cache_match=cached) + + dl.run_listenbrainz_discovery_worker('lb1', deps) + + state = states['lb1'] + assert state['spotify_matches'] == 1 + result = state['discovery_results'][0] + assert result['status'] == 'Found' + assert result['spotify_track'] == 'Cached Match' + assert deps._spotify.search_calls == [] + + +# --------------------------------------------------------------------------- +# Strategy 1 match +# --------------------------------------------------------------------------- + +def test_strategy1_match_records_found(): + """Strategy 1 match >= 0.9 → result['status']='Found' with confidence.""" + states = {} + match = _FakeMatch() + _seed_state('lb2', states, tracks=[_track()]) + deps = _build_deps(states=states, spotify_results=[match], + score_result=(match, 0.95, 0)) + + dl.run_listenbrainz_discovery_worker('lb2', deps) + + state = states['lb2'] + assert state['spotify_matches'] == 1 + result = state['discovery_results'][0] + assert result['status'] == 'Found' + assert result['confidence'] == 0.95 + assert deps._db.cache_saves + + +# --------------------------------------------------------------------------- +# Wing It fallback +# --------------------------------------------------------------------------- + +def test_no_match_wing_it_fallback(): + """No match in any strategy → Wing It stub.""" + states = {} + _seed_state('lb3', states, tracks=[_track()]) + deps = _build_deps(states=states) + + dl.run_listenbrainz_discovery_worker('lb3', deps) + + state = states['lb3'] + assert state.get('wing_it_count') == 1 + result = state['discovery_results'][0] + assert result['status'] == 'Wing It' + assert result['wing_it_fallback'] is True + + +# --------------------------------------------------------------------------- +# iTunes fallback +# --------------------------------------------------------------------------- + +def test_itunes_fallback_when_spotify_unauthenticated(): + """spotify unauthenticated → iTunes searched.""" + states = {} + match = _FakeMatch() + _seed_state('lb4', states, tracks=[_track()]) + deps = _build_deps( + states=states, spotify_auth=False, discovery_source='itunes', + itunes_results=[match], score_result=(match, 0.95, 0), + ) + + dl.run_listenbrainz_discovery_worker('lb4', deps) + + assert deps._itunes.search_calls + assert deps._spotify.search_calls == [] + + +def test_spotify_skipped_when_rate_limited(): + """Spotify globally rate-limited → falls through to iTunes.""" + states = {} + match = _FakeMatch() + _seed_state('lb5', states, tracks=[_track()]) + deps = _build_deps(states=states, rate_limited=True, + itunes_results=[match], + score_result=(match, 0.95, 0)) + + dl.run_listenbrainz_discovery_worker('lb5', deps) + + assert deps._itunes.search_calls + + +# --------------------------------------------------------------------------- +# Cancellation +# --------------------------------------------------------------------------- + +def test_phase_changed_cancels_loop(): + """Phase changed → worker exits early.""" + states = {} + _seed_state('lb6', states, tracks=[_track(), _track('T2')], phase='cancelled') + deps = _build_deps(states=states) + + dl.run_listenbrainz_discovery_worker('lb6', deps) + + assert states['lb6']['discovery_results'] == [] + + +# --------------------------------------------------------------------------- +# Completion +# --------------------------------------------------------------------------- + +def test_completion_marks_phase_discovered(): + """All tracks processed → phase='discovered', status='complete', progress=100.""" + states = {} + _seed_state('lb7', states, tracks=[_track()]) + deps = _build_deps(states=states) + + dl.run_listenbrainz_discovery_worker('lb7', deps) + + assert states['lb7']['phase'] == 'discovered' + assert states['lb7']['status'] == 'complete' + assert states['lb7']['discovery_progress'] == 100 + + +def test_activity_feed_logged(): + """Completion appends activity feed entry mentioning ListenBrainz Discovery Complete.""" + states = {} + _seed_state('lb8', states, tracks=[_track()]) + deps = _build_deps(states=states) + + dl.run_listenbrainz_discovery_worker('lb8', deps) + + args, _ = deps._activity_log[0] + title = args[1] + assert 'ListenBrainz Discovery Complete' in title + + +# --------------------------------------------------------------------------- +# Per-track error +# --------------------------------------------------------------------------- + +def test_per_track_error_appends_error_result(): + """Track-level exception (outside strategies' inner try) → 'Error' result, loop continues.""" + states = {} + _seed_state('lb9', states, tracks=[_track('A'), _track('B')]) + deps = _build_deps(states=states) + + call_count = [0] + + # Raising in get_discovery_cache_key bubbles past strategies' inner try/except. + def raising_cache_key(title, artist): + call_count[0] += 1 + if call_count[0] == 1: + raise RuntimeError("boom") + return (title.lower(), artist.lower()) + + deps.get_discovery_cache_key = raising_cache_key + + dl.run_listenbrainz_discovery_worker('lb9', deps) + + state = states['lb9'] + assert len(state['discovery_results']) == 2 + assert state['discovery_results'][0]['status'] == 'Error' + assert state['discovery_results'][1]['status'] == 'Wing It' + + +# --------------------------------------------------------------------------- +# Float duration_ms tolerance (regression for :02d format) +# --------------------------------------------------------------------------- + +def test_float_duration_does_not_crash(): + """yt_dlp/LB can pass float durations — int() cast prevents :02d crash.""" + states = {} + track = _track(duration_ms=212345.7) # float + _seed_state('lb10', states, tracks=[track]) + deps = _build_deps(states=states) + + dl.run_listenbrainz_discovery_worker('lb10', deps) + + result = states['lb10']['discovery_results'][0] + assert result['status'] != 'Error' + assert ':' in result['duration'] + + +# --------------------------------------------------------------------------- +# Resume enrichment workers always +# --------------------------------------------------------------------------- + +def test_resume_enrichment_called_on_completion(): + """Successful run → resume_enrichment_workers called via finally.""" + states = {} + _seed_state('lb11', states, tracks=[_track()]) + resumes = [] + deps = _build_deps(states=states) + deps.resume_enrichment_workers = lambda state, label: resumes.append((state, label)) + + dl.run_listenbrainz_discovery_worker('lb11', deps) + + assert resumes # finally fired diff --git a/web_server.py b/web_server.py index 4fbfbca8..a1892d90 100644 --- a/web_server.py +++ b/web_server.py @@ -28391,292 +28391,37 @@ def _run_youtube_discovery_worker(url_hash): return _discovery_youtube.run_youtube_discovery_worker(url_hash, _build_youtube_discovery_deps()) +# ListenBrainz discovery worker logic lives in core/discovery/listenbrainz.py. +from core.discovery import listenbrainz as _discovery_listenbrainz + + +def _build_listenbrainz_discovery_deps(): + """Build the ListenbrainzDiscoveryDeps bundle from web_server.py globals on each call.""" + return _discovery_listenbrainz.ListenbrainzDiscoveryDeps( + listenbrainz_playlist_states=listenbrainz_playlist_states, + spotify_client=spotify_client, + matching_engine=matching_engine, + pause_enrichment_workers=_pause_enrichment_workers, + resume_enrichment_workers=_resume_enrichment_workers, + get_active_discovery_source=_get_active_discovery_source, + get_metadata_fallback_client=_get_metadata_fallback_client, + get_discovery_cache_key=_get_discovery_cache_key, + get_database=get_database, + validate_discovery_cache_artist=_validate_discovery_cache_artist, + extract_artist_name=_extract_artist_name, + spotify_rate_limited=_spotify_rate_limited, + discovery_score_candidates=_discovery_score_candidates, + get_metadata_cache=get_metadata_cache, + build_discovery_wing_it_stub=_build_discovery_wing_it_stub, + add_activity_item=add_activity_item, + ) + + def _run_listenbrainz_discovery_worker(state_key): - """Background worker for ListenBrainz music discovery process (Spotify preferred, iTunes fallback)""" - playlist_mbid = state_key.split(':', 1)[1] if ':' in state_key else state_key - _ew_state = {} - try: - _ew_state = _pause_enrichment_workers('ListenBrainz discovery') - state = listenbrainz_playlist_states[state_key] - playlist = state['playlist'] - tracks = playlist['tracks'] + return _discovery_listenbrainz.run_listenbrainz_discovery_worker( + state_key, _build_listenbrainz_discovery_deps() + ) - # Determine which provider to use (Spotify preferred, iTunes fallback) - discovery_source = _get_active_discovery_source() - use_spotify = (discovery_source == 'spotify') and spotify_client and spotify_client.is_spotify_authenticated() - - # Get fallback client - itunes_client = _get_metadata_fallback_client() - - logger.info(f"Starting {discovery_source} discovery for {len(tracks)} ListenBrainz tracks...") - - # Store the discovery source in state - state['discovery_source'] = discovery_source - - # Process each track for discovery - for i, track in enumerate(tracks): - try: - # Check for cancellation - if state.get('phase') != 'discovering': - logger.warning(f"ListenBrainz discovery cancelled (phase changed to '{state.get('phase')}')") - return - - # Update progress - state['discovery_progress'] = int((i / len(tracks)) * 100) - - # Get cleaned track data from ListenBrainz - cleaned_title = track['track_name'] - cleaned_artist = track['artist_name'] - album_name = track.get('album_name', '') - duration_ms = track.get('duration_ms', 0) - - logger.info(f"Searching {discovery_source} for: '{cleaned_artist}' - '{cleaned_title}'") - - # Check discovery cache first - cache_key = _get_discovery_cache_key(cleaned_title, cleaned_artist) - try: - cache_db = get_database() - cached_match = cache_db.get_discovery_cache_match(cache_key[0], cache_key[1], discovery_source) - if cached_match and _validate_discovery_cache_artist(cleaned_artist, cached_match): - logger.debug(f"CACHE HIT [{i+1}/{len(tracks)}]: {cleaned_artist} - {cleaned_title}") - result = { - 'index': i, - 'lb_track': cleaned_title, - 'lb_artist': cleaned_artist, - 'status': 'Found', - 'status_class': 'found', - 'spotify_track': cached_match.get('name', ''), - 'spotify_artist': _extract_artist_name(cached_match.get('artists', [''])[0]) if cached_match.get('artists') else '', - 'spotify_album': cached_match.get('album', {}).get('name', '') if isinstance(cached_match.get('album'), dict) else cached_match.get('album', ''), - 'duration': f"{int(duration_ms) // 60000}:{(int(duration_ms) % 60000) // 1000:02d}" if duration_ms else '0:00', - 'discovery_source': discovery_source, - 'matched_data': cached_match, - 'spotify_data': cached_match - } - state['spotify_matches'] += 1 - state['discovery_results'].append(result) - continue - except Exception as cache_err: - logger.error(f"Cache lookup error: {cache_err}") - - # Try multiple search strategies using matching engine - matched_track = None - best_confidence = 0.0 - best_raw_track = None - min_confidence = 0.9 - source_duration = duration_ms or 0 - - # Strategy 1: Use matching_engine search queries - try: - temp_track = type('TempTrack', (), { - 'name': cleaned_title, - 'artists': [cleaned_artist], - 'album': album_name if album_name else None - })() - search_queries = matching_engine.generate_download_queries(temp_track) - logger.info(f"Generated {len(search_queries)} search queries for ListenBrainz track") - except Exception as e: - logger.error(f"Matching engine failed for ListenBrainz, falling back to basic query: {e}") - search_queries = [f"{cleaned_artist} {cleaned_title}", cleaned_title] - - for query_idx, search_query in enumerate(search_queries): - try: - logger.debug(f"ListenBrainz query {query_idx + 1}/{len(search_queries)}: {search_query}") - - search_results = None - - if use_spotify and not _spotify_rate_limited(): - search_results = spotify_client.search_tracks(search_query, limit=10) - else: - search_results = itunes_client.search_tracks(search_query, limit=10) - - if not search_results: - continue - - # Score all results using the matching engine - match, confidence, match_idx = _discovery_score_candidates( - cleaned_title, cleaned_artist, source_duration, search_results - ) - - if match and confidence > best_confidence and confidence >= min_confidence: - best_confidence = confidence - matched_track = match - if use_spotify and match.id: - _cache = get_metadata_cache() - best_raw_track = _cache.get_entity('spotify', 'track', match.id) - else: - best_raw_track = None - logger.info(f"New best ListenBrainz match: {match.artists[0]} - {match.name} (confidence: {confidence:.3f})") - - if best_confidence >= 0.9: - logger.info(f"High confidence ListenBrainz match found ({best_confidence:.3f}), stopping search") - break - - except Exception as e: - logger.debug(f"Error in ListenBrainz search for query '{search_query}': {e}") - continue - - if matched_track: - logger.info(f"Strategy 1 ListenBrainz match: {matched_track.artists[0]} - {matched_track.name} (confidence: {best_confidence:.3f})") - - # Strategy 2: Swapped search (if first failed) - score results properly - if not matched_track: - logger.info("ListenBrainz Strategy 2: Trying swapped search (artist/title reversed)") - if use_spotify: - query = f"artist:{cleaned_title} track:{cleaned_artist}" - fallback_results = spotify_client.search_tracks(query, limit=5) - else: - query = f"{cleaned_title} {cleaned_artist}" - fallback_results = itunes_client.search_tracks(query, limit=5) - if fallback_results: - match, confidence, _ = _discovery_score_candidates( - cleaned_title, cleaned_artist, source_duration, fallback_results - ) - if match and confidence >= min_confidence: - matched_track = match - best_confidence = confidence - logger.info(f"Strategy 2 ListenBrainz match (swapped): {match.artists[0]} - {match.name} (confidence: {confidence:.3f})") - - # Strategy 3: Album-based search (if still failed and we have album name) - score results properly - if not matched_track and album_name: - logger.info(f"ListenBrainz Strategy 3: Trying album-based search: '{cleaned_artist} {album_name} {cleaned_title}'") - if use_spotify: - query = f"artist:{cleaned_artist} album:{album_name} track:{cleaned_title}" - fallback_results = spotify_client.search_tracks(query, limit=5) - else: - query = f"{cleaned_artist} {album_name} {cleaned_title}" - fallback_results = itunes_client.search_tracks(query, limit=5) - if fallback_results: - match, confidence, _ = _discovery_score_candidates( - cleaned_title, cleaned_artist, source_duration, fallback_results - ) - if match and confidence >= min_confidence: - matched_track = match - best_confidence = confidence - logger.info(f"Strategy 3 ListenBrainz match (album): {match.artists[0]} - {match.name} (confidence: {confidence:.3f})") - - # Strategy 4: Extended search with higher limit (last resort) - if not matched_track: - logger.info("ListenBrainz Strategy 4: Extended search with limit=50") - query = f"{cleaned_artist} {cleaned_title}" - if use_spotify: - extended_results = spotify_client.search_tracks(query, limit=50) - else: - extended_results = itunes_client.search_tracks(query, limit=50) - if extended_results: - match, confidence, _ = _discovery_score_candidates( - cleaned_title, cleaned_artist, source_duration, extended_results - ) - if match and confidence >= min_confidence: - matched_track = match - best_confidence = confidence - logger.info(f"Strategy 4 ListenBrainz match (extended): {match.artists[0]} - {match.name} (confidence: {confidence:.3f})") - - # Create result entry - result = { - 'index': i, - 'lb_track': cleaned_title, - 'lb_artist': cleaned_artist, - 'status': 'Found' if matched_track else 'Not Found', - 'status_class': 'found' if matched_track else 'not-found', - 'spotify_track': matched_track.name if matched_track else '', - 'spotify_artist': _extract_artist_name(matched_track.artists[0]) if matched_track else '', - 'spotify_album': matched_track.album if matched_track else '', - 'duration': f"{int(duration_ms) // 60000}:{(int(duration_ms) % 60000) // 1000:02d}" if duration_ms else '0:00', - 'discovery_source': discovery_source, - 'confidence': best_confidence - } - - if matched_track: - state['spotify_matches'] += 1 - - # Build album data based on provider - if use_spotify and best_raw_track: - album_data = best_raw_track.get('album', {}) - else: - album_data = { - 'name': matched_track.album, - 'album_type': 'album', - 'release_date': getattr(matched_track, 'release_date', '') or '', - 'images': [{'url': matched_track.image_url}] if hasattr(matched_track, 'image_url') and matched_track.image_url else [] - } - - # Extract image URL for discovery pool display - _yt_album_images = album_data.get('images', []) - _yt_image_url = _yt_album_images[0].get('url', '') if _yt_album_images else (getattr(matched_track, 'image_url', '') or '') - - result['matched_data'] = { - 'id': matched_track.id, - 'name': matched_track.name, - 'artists': matched_track.artists, - 'album': album_data, - 'duration_ms': matched_track.duration_ms, - 'image_url': _yt_image_url, - 'source': discovery_source - } - result['spotify_data'] = result['matched_data'] - - # Save to discovery cache (only high-confidence matches) - if best_confidence >= 0.7: - try: - cache_db = get_database() - cache_db.save_discovery_cache_match( - cache_key[0], cache_key[1], discovery_source, best_confidence, - result['matched_data'], cleaned_title, cleaned_artist - ) - logger.info(f"CACHE SAVED: {cleaned_artist} - {cleaned_title} (confidence: {best_confidence:.3f})") - except Exception as cache_err: - logger.error(f"Cache save error: {cache_err}") - - else: - # Auto Wing It fallback — build stub from raw source data - stub = _build_discovery_wing_it_stub(cleaned_title, cleaned_artist, duration_ms) - result['status'] = 'Wing It' - result['status_class'] = 'wing-it' - result['spotify_track'] = cleaned_title - result['spotify_artist'] = cleaned_artist - result['spotify_album'] = '' - result['matched_data'] = stub - result['spotify_data'] = stub - result['wing_it_fallback'] = True - state['wing_it_count'] = state.get('wing_it_count', 0) + 1 - - state['discovery_results'].append(result) - - logger.info(f" {'' if matched_track else ''} Track {i+1}/{len(tracks)}: {result['status']}") - - except Exception as e: - logger.error(f"Error processing track {i}: {e}") - result = { - 'index': i, - 'lb_track': track['track_name'], - 'lb_artist': track['artist_name'], - 'status': 'Error', - 'status_class': 'error', - 'spotify_track': '', - 'spotify_artist': '', - 'spotify_album': '', - 'duration': '0:00' - } - state['discovery_results'].append(result) - - # Complete discovery - state['phase'] = 'discovered' - state['status'] = 'complete' - state['discovery_progress'] = 100 - - playlist_name = playlist.get('name') or playlist.get('title') or 'Unknown Playlist' - source_label = discovery_source.upper() - add_activity_item("", f"ListenBrainz Discovery Complete ({source_label})", f"'{playlist_name}' - {state['spotify_matches']}/{len(tracks)} tracks found", "Now") - - logger.info(f"ListenBrainz discovery complete ({discovery_source}): {state['spotify_matches']}/{len(tracks)} tracks matched") - - except Exception as e: - logger.error(f"Error in ListenBrainz discovery worker: {e}") - state['status'] = 'error' - state['phase'] = 'fresh' - finally: - _resume_enrichment_workers(_ew_state, 'ListenBrainz discovery') def _calculate_similarity(str1, str2): """Calculate string similarity using simple character overlap"""