Merge pull request #411 from Nezreka/refactor/lift-discovery-listenbrainz
PR5g: lift _run_listenbrainz_discovery_worker to core/discovery/liste…
This commit is contained in:
commit
1c86d70386
3 changed files with 717 additions and 284 deletions
342
core/discovery/listenbrainz.py
Normal file
342
core/discovery/listenbrainz.py
Normal file
|
|
@ -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')
|
||||||
346
tests/discovery/test_discovery_listenbrainz.py
Normal file
346
tests/discovery/test_discovery_listenbrainz.py
Normal file
|
|
@ -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
|
||||||
313
web_server.py
313
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())
|
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):
|
def _run_listenbrainz_discovery_worker(state_key):
|
||||||
"""Background worker for ListenBrainz music discovery process (Spotify preferred, iTunes fallback)"""
|
return _discovery_listenbrainz.run_listenbrainz_discovery_worker(
|
||||||
playlist_mbid = state_key.split(':', 1)[1] if ':' in state_key else state_key
|
state_key, _build_listenbrainz_discovery_deps()
|
||||||
_ew_state = {}
|
)
|
||||||
try:
|
|
||||||
_ew_state = _pause_enrichment_workers('ListenBrainz discovery')
|
|
||||||
state = listenbrainz_playlist_states[state_key]
|
|
||||||
playlist = state['playlist']
|
|
||||||
tracks = playlist['tracks']
|
|
||||||
|
|
||||||
# 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):
|
def _calculate_similarity(str1, str2):
|
||||||
"""Calculate string similarity using simple character overlap"""
|
"""Calculate string similarity using simple character overlap"""
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue