diff --git a/core/discovery/tidal.py b/core/discovery/tidal.py new file mode 100644 index 00000000..5c6069b5 --- /dev/null +++ b/core/discovery/tidal.py @@ -0,0 +1,273 @@ +"""Background worker for Tidal playlist discovery. + +`run_tidal_discovery_worker(playlist_id, deps)` is the function the tidal +discovery start-endpoint submits to its executor to match each Tidal +playlist track against Spotify (preferred) or iTunes (fallback). Same +shape as the other source-specific discovery workers in this package. + +1. Pause enrichment workers (release shared resources). +2. For each Tidal track: + - Cancellation gate (state['cancelled']). + - Discovery cache lookup; cache hit short-circuits the search. + - `_search_spotify_for_tidal_track` (shared helper that the deezer + + spotify_public workers also use; returns tuple for Spotify or dict + for iTunes). + - On Spotify match: build `match_data` preserving track_number / + disc_number from raw API data; image extracted from album images + or track object fallback; release_date filled from + track.release_date when album dict is missing it. + - On iTunes match: dict result populated as `match_data` with source + set to discovery_source; image extracted from album images. + - Save matched result to discovery cache. + - 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 'tidal' tag. +4. On error: state['phase']='error' + status with error string. +5. Finally: resume enrichment workers. + +Lifted verbatim from web_server.py. Wide dependency surface (Spotify +and iTunes clients, multiple metadata helpers, state dict, mirrored +sync, shared tidal search helper) all injected via `TidalDiscoveryDeps`. +""" + +from __future__ import annotations + +import logging +import time +from dataclasses import dataclass +from typing import Any, Callable + +logger = logging.getLogger(__name__) + + +@dataclass +class TidalDiscoveryDeps: + """Bundle of cross-cutting deps the Tidal discovery worker needs.""" + tidal_discovery_states: dict + spotify_client: 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 + search_spotify_for_tidal_track: Callable + build_discovery_wing_it_stub: Callable + add_activity_item: Callable + sync_discovery_results_to_mirrored: Callable + + +def run_tidal_discovery_worker(playlist_id, deps: TidalDiscoveryDeps): + """Background worker for Tidal discovery process (Spotify preferred, iTunes fallback)""" + _ew_state = {} + try: + _ew_state = deps.pause_enrichment_workers('Tidal discovery') + state = deps.tidal_discovery_states[playlist_id] + playlist = state['playlist'] + + # Determine which provider to use — respect user's configured primary source + discovery_source = deps.get_active_discovery_source() + use_spotify = (discovery_source == 'spotify') and deps.spotify_client and deps.spotify_client.is_spotify_authenticated() + + # Initialize fallback client if needed + itunes_client_instance = None + if not use_spotify: + itunes_client_instance = deps.get_metadata_fallback_client() + + logger.info(f"Starting Tidal discovery for: {playlist.name} (using {discovery_source.upper()})") + + # Store discovery source in state for frontend + state['discovery_source'] = discovery_source + + successful_discoveries = 0 + + for i, tidal_track in enumerate(playlist.tracks): + if state.get('cancelled', False): + break + + try: + logger.info(f"[{i+1}/{len(playlist.tracks)}] Searching {discovery_source.upper()}: {tidal_track.name} by {', '.join(tidal_track.artists)}") + + # Check discovery cache first + cache_key = deps.get_discovery_cache_key(tidal_track.name, tidal_track.artists[0] if tidal_track.artists else '') + 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(tidal_track.artists[0] if tidal_track.artists else '', cached_match): + logger.debug(f"CACHE HIT [{i+1}/{len(playlist.tracks)}]: {tidal_track.name} by {', '.join(tidal_track.artists)}") + result = { + 'tidal_track': { + 'id': tidal_track.id, + 'name': tidal_track.name, + 'artists': tidal_track.artists or [], + 'album': getattr(tidal_track, 'album', 'Unknown Album'), + 'duration_ms': getattr(tidal_track, 'duration_ms', 0), + }, + 'spotify_data': cached_match, + 'match_data': cached_match, + 'status': 'found', + 'discovery_source': discovery_source + } + successful_discoveries += 1 + state['spotify_matches'] = successful_discoveries + state['discovery_results'].append(result) + state['discovery_progress'] = int(((i + 1) / len(playlist.tracks)) * 100) + continue + except Exception as cache_err: + logger.error(f"Cache lookup error: {cache_err}") + + # Use the search function with appropriate provider + track_result = deps.search_spotify_for_tidal_track( + tidal_track, + use_spotify=use_spotify, + itunes_client=itunes_client_instance + ) + + # Create result entry - use 'match_data' as generic key for both providers + result = { + 'tidal_track': { + 'id': tidal_track.id, + 'name': tidal_track.name, + 'artists': tidal_track.artists or [], + 'album': getattr(tidal_track, 'album', 'Unknown Album'), + 'duration_ms': getattr(tidal_track, 'duration_ms', 0), + }, + 'spotify_data': None, # Keep for backwards compatibility + 'match_data': None, # Generic field for any provider + 'status': 'not_found', + 'discovery_source': discovery_source + } + + match_confidence = 0.0 + + if use_spotify and isinstance(track_result, tuple): + # Spotify: Function returns (Track, raw_data, confidence) + track_obj, raw_track_data, match_confidence = track_result + album_obj = raw_track_data.get('album', {}) if raw_track_data else {} + # Ensure album has a name — fall back to track_obj.album if raw_data was missing + if isinstance(album_obj, dict) and not album_obj.get('name') and track_obj.album: + album_obj['name'] = track_obj.album + elif not album_obj and track_obj.album: + album_obj = {'name': track_obj.album} + # Ensure release_date is present (raw Spotify data has it, but fallback may not) + if isinstance(album_obj, dict) and not album_obj.get('release_date'): + album_obj['release_date'] = getattr(track_obj, 'release_date', '') or '' + # Extract image URL from album data or track object + _album_images = album_obj.get('images', []) if isinstance(album_obj, dict) else [] + _image_url = _album_images[0].get('url', '') if _album_images else (getattr(track_obj, 'image_url', '') or '') + + match_data = { + 'id': track_obj.id, + 'name': track_obj.name, + 'artists': track_obj.artists, + 'album': album_obj, + 'duration_ms': track_obj.duration_ms, + 'external_urls': track_obj.external_urls, + 'image_url': _image_url, + 'source': 'spotify' + } + # Preserve track_number/disc_number from raw Spotify API data + if raw_track_data and raw_track_data.get('track_number'): + match_data['track_number'] = raw_track_data['track_number'] + if raw_track_data and raw_track_data.get('disc_number'): + match_data['disc_number'] = raw_track_data['disc_number'] + result['spotify_data'] = match_data + result['match_data'] = match_data + result['status'] = 'found' + result['confidence'] = match_confidence + successful_discoveries += 1 + state['spotify_matches'] = successful_discoveries + + elif not use_spotify and track_result and isinstance(track_result, dict): + # Fallback: Function returns a dict with track data (includes 'confidence' key) + match_confidence = track_result.pop('confidence', 0.80) + match_data = track_result + match_data['source'] = discovery_source + # Extract image URL from album images + _fb_album = match_data.get('album', {}) + _fb_images = _fb_album.get('images', []) if isinstance(_fb_album, dict) else [] + if _fb_images and 'image_url' not in match_data: + match_data['image_url'] = _fb_images[0].get('url', '') + result['spotify_data'] = match_data + result['match_data'] = match_data + result['status'] = 'found' + result['confidence'] = match_confidence + successful_discoveries += 1 + state['spotify_matches'] = successful_discoveries + + # Save to discovery cache if match found + if result['status'] == 'found' and result.get('match_data'): + try: + cache_db = deps.get_database() + cache_db.save_discovery_cache_match( + cache_key[0], cache_key[1], discovery_source, match_confidence, + result['match_data'], tidal_track.name, + tidal_track.artists[0] if tidal_track.artists else '' + ) + logger.info(f"CACHE SAVED: {tidal_track.name} (confidence: {match_confidence:.3f})") + except Exception as cache_err: + logger.error(f"Cache save error: {cache_err}") + + # Auto Wing It fallback for unmatched tracks + if result['status'] != 'found': + tidal_t = result.get('tidal_track', {}) + stub = deps.build_discovery_wing_it_stub( + tidal_t.get('name', ''), + ', '.join(tidal_t.get('artists', [])), + tidal_t.get('duration_ms', 0) + ) + result['status'] = 'found' + result['status_class'] = 'wing-it' + result['spotify_data'] = stub + result['match_data'] = stub + result['wing_it_fallback'] = True + result['confidence'] = 0 + successful_discoveries += 1 + state['spotify_matches'] = successful_discoveries + state['wing_it_count'] = state.get('wing_it_count', 0) + 1 + + state['discovery_results'].append(result) + state['discovery_progress'] = int(((i + 1) / len(playlist.tracks)) * 100) + + # Add delay between requests + time.sleep(0.1) + + except Exception as e: + logger.error(f"Error processing track {i+1}: {e}") + # Add error result + result = { + 'tidal_track': { + 'name': tidal_track.name, + 'artists': tidal_track.artists or [], + }, + 'spotify_data': None, + 'match_data': None, + 'status': 'error', + 'error': str(e), + 'discovery_source': discovery_source + } + state['discovery_results'].append(result) + state['discovery_progress'] = int(((i + 1) / len(playlist.tracks)) * 100) + + # Mark as complete + state['phase'] = 'discovered' + state['status'] = 'discovered' + state['discovery_progress'] = 100 + + # Add activity for discovery completion + source_label = discovery_source.upper() + deps.add_activity_item("", f"Tidal Discovery Complete ({source_label})", f"'{playlist.name}' - {successful_discoveries}/{len(playlist.tracks)} tracks found", "Now") + + logger.info(f"Tidal discovery complete ({source_label}): {successful_discoveries}/{len(playlist.tracks)} tracks found") + + # Sync discovery results back to mirrored playlist + deps.sync_discovery_results_to_mirrored('tidal', playlist_id, state.get('discovery_results', []), discovery_source, profile_id=state.get('_profile_id', 1)) + + except Exception as e: + logger.error(f"Error in Tidal discovery worker: {e}") + state['phase'] = 'error' + state['status'] = f'error: {str(e)}' + finally: + deps.resume_enrichment_workers(_ew_state, 'Tidal discovery') diff --git a/tests/discovery/test_discovery_tidal.py b/tests/discovery/test_discovery_tidal.py new file mode 100644 index 00000000..f88ba636 --- /dev/null +++ b/tests/discovery/test_discovery_tidal.py @@ -0,0 +1,302 @@ +"""Tests for core/discovery/tidal.py — Tidal discovery worker.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pytest + +from core.discovery import tidal as dt + + +# --------------------------------------------------------------------------- +# Fakes +# --------------------------------------------------------------------------- + +@dataclass +class _TidalTrack: + id: str = 'tid-1' + name: str = 'Track' + artists: list = None + album: str = 'Album' + duration_ms: int = 180000 + + def __post_init__(self): + if self.artists is None: + self.artists = ['Artist'] + + +class _FakePlaylist: + def __init__(self, name='My Tidal Playlist', tracks=None): + self.name = name + self.tracks = tracks or [] + + +@dataclass +class _FakeTrackObj: + id: str = 'sp-1' + name: str = 'Found' + artists: list = None + album: str = 'Album X' + duration_ms: int = 200000 + image_url: str = '' + external_urls: dict = None + release_date: str = '2024-01-01' + + def __post_init__(self): + if self.artists is None: + self.artists = ['Found Artist'] + if self.external_urls is None: + self.external_urls = {} + + +class _FakeSpotifyClient: + def __init__(self, authenticated=True): + self._authenticated = authenticated + + def is_spotify_authenticated(self): + return self._authenticated + + +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)) + + +def _build_deps( + *, + states=None, + spotify_auth=True, + discovery_source='spotify', + cache_match=None, + search_result=None, + sync_calls=None, + activity_log=None, +): + sync_calls = sync_calls if sync_calls is not None else [] + activity_log = activity_log if activity_log is not None else [] + db = _FakeDB(cache_match=cache_match) + spotify = _FakeSpotifyClient(authenticated=spotify_auth) + itunes = object() + + deps = dt.TidalDiscoveryDeps( + tidal_discovery_states=states if states is not None else {}, + spotify_client=spotify, + 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, + search_spotify_for_tidal_track=lambda track, use_spotify, itunes_client: search_result, + 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)), + sync_discovery_results_to_mirrored=lambda *a, **kw: sync_calls.append((a, kw)), + ) + deps._db = db + deps._spotify = spotify + deps._sync_calls = sync_calls + deps._activity_log = activity_log + return deps + + +def _seed_state(playlist_id, states, *, tracks=None, cancelled=False): + states[playlist_id] = { + 'cancelled': cancelled, + 'playlist': _FakePlaylist(tracks=tracks or []), + 'spotify_matches': 0, + 'discovery_results': [], + 'discovery_progress': 0, + } + + +# --------------------------------------------------------------------------- +# Cache hit +# --------------------------------------------------------------------------- + +def test_cache_hit_short_circuits(): + """Cache hit appends Found result without live search.""" + states = {} + cached = {'name': 'Cached', 'artists': ['CA'], 'album': {'name': 'CAlb'}} + _seed_state('p1', states, tracks=[_TidalTrack()]) + deps = _build_deps(states=states, cache_match=cached) + + dt.run_tidal_discovery_worker('p1', deps) + + state = states['p1'] + assert state['spotify_matches'] == 1 + result = state['discovery_results'][0] + assert result['status'] == 'found' + assert result['spotify_data'] == cached + + +# --------------------------------------------------------------------------- +# Spotify path (tuple result) +# --------------------------------------------------------------------------- + +def test_spotify_match_preserves_track_disc_numbers(): + """Spotify result tuple → match_data preserves track_number & disc_number.""" + states = {} + raw = { + 'album': {'name': 'A', 'release_date': '2024-05-05', 'images': [{'url': 'http://i'}]}, + 'track_number': 4, + 'disc_number': 2, + } + track_obj = _FakeTrackObj() + _seed_state('p2', states, tracks=[_TidalTrack()]) + deps = _build_deps(states=states, search_result=(track_obj, raw, 0.93)) + + dt.run_tidal_discovery_worker('p2', deps) + + md = states['p2']['discovery_results'][0]['match_data'] + assert md['track_number'] == 4 + assert md['disc_number'] == 2 + assert md['album']['release_date'] == '2024-05-05' + + +# --------------------------------------------------------------------------- +# iTunes path +# --------------------------------------------------------------------------- + +def test_itunes_dict_result_path(): + """Non-spotify dict result → match_data with source set to discovery_source.""" + states = {} + track_data = { + 'id': 'it-1', 'name': 'iT', 'artists': ['iA'], + 'album': {'name': 'iAlb', 'images': [{'url': 'http://it'}]}, + 'duration_ms': 200000, 'confidence': 0.85, + } + _seed_state('p3', states, tracks=[_TidalTrack()]) + deps = _build_deps( + states=states, spotify_auth=False, discovery_source='itunes', + search_result=track_data, + ) + + dt.run_tidal_discovery_worker('p3', deps) + + result = states['p3']['discovery_results'][0] + assert result['status'] == 'found' + assert result['confidence'] == 0.85 + assert result['match_data']['source'] == 'itunes' + assert result['match_data']['image_url'] == 'http://it' + + +# --------------------------------------------------------------------------- +# Wing It fallback +# --------------------------------------------------------------------------- + +def test_no_match_wing_it_fallback(): + """No match → Wing It stub stored, status_class='wing-it'.""" + states = {} + _seed_state('p4', states, tracks=[_TidalTrack()]) + deps = _build_deps(states=states, search_result=None) + + dt.run_tidal_discovery_worker('p4', deps) + + state = states['p4'] + assert state.get('wing_it_count') == 1 + result = state['discovery_results'][0] + assert result['status'] == 'found' # Wing It is also "found" status + assert result['status_class'] == 'wing-it' + assert result['wing_it_fallback'] is True + + +# --------------------------------------------------------------------------- +# Cancellation +# --------------------------------------------------------------------------- + +def test_cancellation_breaks_loop(): + """state['cancelled']=True bails immediately.""" + states = {} + _seed_state('p5', states, tracks=[_TidalTrack(), _TidalTrack(id='t2')], cancelled=True) + deps = _build_deps(states=states) + + dt.run_tidal_discovery_worker('p5', deps) + + assert states['p5']['discovery_results'] == [] + + +# --------------------------------------------------------------------------- +# Completion +# --------------------------------------------------------------------------- + +def test_completion_marks_phase_discovered(): + """Completion → phase='discovered', status='discovered', progress=100.""" + states = {} + _seed_state('p6', states, tracks=[_TidalTrack()]) + deps = _build_deps(states=states, search_result=None) + + dt.run_tidal_discovery_worker('p6', deps) + + assert states['p6']['phase'] == 'discovered' + assert states['p6']['status'] == 'discovered' + assert states['p6']['discovery_progress'] == 100 + + +def test_activity_feed_logged(): + """Completion appends activity feed entry mentioning Tidal.""" + states = {} + _seed_state('p7', states, tracks=[_TidalTrack()]) + deps = _build_deps(states=states, search_result=None) + + dt.run_tidal_discovery_worker('p7', deps) + + args, _ = deps._activity_log[0] + title = args[1] + assert 'Tidal Discovery Complete' in title + + +def test_sync_to_mirrored_invoked(): + """Completion calls sync_discovery_results_to_mirrored with 'tidal' tag.""" + states = {} + _seed_state('p8', states, tracks=[_TidalTrack()]) + states['p8']['_profile_id'] = 5 + deps = _build_deps(states=states, search_result=None) + + dt.run_tidal_discovery_worker('p8', deps) + + assert len(deps._sync_calls) == 1 + args, kwargs = deps._sync_calls[0] + assert args[0] == 'tidal' + assert args[1] == 'p8' + assert kwargs.get('profile_id') == 5 + + +# --------------------------------------------------------------------------- +# Error path +# --------------------------------------------------------------------------- + +def test_per_track_error_appends_error_entry(): + """Per-track exception → 'error' result entry, loop continues.""" + states = {} + tracks = [_TidalTrack(id='a'), _TidalTrack(id='b')] + _seed_state('p9', states, tracks=tracks) + deps = _build_deps(states=states) + + call_count = [0] + + def search_side_effect(track, use_spotify, itunes_client): + call_count[0] += 1 + if call_count[0] == 1: + raise RuntimeError("track boom") + return None + + deps.search_spotify_for_tidal_track = search_side_effect + + dt.run_tidal_discovery_worker('p9', deps) + + state = states['p9'] + assert len(state['discovery_results']) == 2 + assert state['discovery_results'][0]['status'] == 'error' + # Second one falls through to Wing It (no match returned) + assert state['discovery_results'][1]['status_class'] == 'wing-it' diff --git a/web_server.py b/web_server.py index 5da9513a..e1ee4603 100644 --- a/web_server.py +++ b/web_server.py @@ -24247,218 +24247,32 @@ def _discovery_score_candidates(source_title, source_artist, source_duration_ms, return best_match, best_confidence, best_index -def _run_tidal_discovery_worker(playlist_id): - """Background worker for Tidal discovery process (Spotify preferred, iTunes fallback)""" - _ew_state = {} - try: - _ew_state = _pause_enrichment_workers('Tidal discovery') - state = tidal_discovery_states[playlist_id] - playlist = state['playlist'] - - # Determine which provider to use — respect user's configured primary source - discovery_source = _get_active_discovery_source() - use_spotify = (discovery_source == 'spotify') and spotify_client and spotify_client.is_spotify_authenticated() - - # Initialize fallback client if needed - itunes_client_instance = None - if not use_spotify: - itunes_client_instance = _get_metadata_fallback_client() - - logger.info(f"Starting Tidal discovery for: {playlist.name} (using {discovery_source.upper()})") +# Tidal discovery worker logic lives in core/discovery/tidal.py. +from core.discovery import tidal as _discovery_tidal - # Store discovery source in state for frontend - state['discovery_source'] = discovery_source - successful_discoveries = 0 - - for i, tidal_track in enumerate(playlist.tracks): - if state.get('cancelled', False): - break - - try: - logger.info(f"[{i+1}/{len(playlist.tracks)}] Searching {discovery_source.upper()}: {tidal_track.name} by {', '.join(tidal_track.artists)}") - - # Check discovery cache first - cache_key = _get_discovery_cache_key(tidal_track.name, tidal_track.artists[0] if tidal_track.artists else '') - 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(tidal_track.artists[0] if tidal_track.artists else '', cached_match): - logger.debug(f"CACHE HIT [{i+1}/{len(playlist.tracks)}]: {tidal_track.name} by {', '.join(tidal_track.artists)}") - result = { - 'tidal_track': { - 'id': tidal_track.id, - 'name': tidal_track.name, - 'artists': tidal_track.artists or [], - 'album': getattr(tidal_track, 'album', 'Unknown Album'), - 'duration_ms': getattr(tidal_track, 'duration_ms', 0), - }, - 'spotify_data': cached_match, - 'match_data': cached_match, - 'status': 'found', - 'discovery_source': discovery_source - } - successful_discoveries += 1 - state['spotify_matches'] = successful_discoveries - state['discovery_results'].append(result) - state['discovery_progress'] = int(((i + 1) / len(playlist.tracks)) * 100) - continue - except Exception as cache_err: - logger.error(f"Cache lookup error: {cache_err}") - - # Use the search function with appropriate provider - track_result = _search_spotify_for_tidal_track( - tidal_track, - use_spotify=use_spotify, - itunes_client=itunes_client_instance - ) - - # Create result entry - use 'match_data' as generic key for both providers - result = { - 'tidal_track': { - 'id': tidal_track.id, - 'name': tidal_track.name, - 'artists': tidal_track.artists or [], - 'album': getattr(tidal_track, 'album', 'Unknown Album'), - 'duration_ms': getattr(tidal_track, 'duration_ms', 0), - }, - 'spotify_data': None, # Keep for backwards compatibility - 'match_data': None, # Generic field for any provider - 'status': 'not_found', - 'discovery_source': discovery_source - } - - match_confidence = 0.0 - - if use_spotify and isinstance(track_result, tuple): - # Spotify: Function returns (Track, raw_data, confidence) - track_obj, raw_track_data, match_confidence = track_result - album_obj = raw_track_data.get('album', {}) if raw_track_data else {} - # Ensure album has a name — fall back to track_obj.album if raw_data was missing - if isinstance(album_obj, dict) and not album_obj.get('name') and track_obj.album: - album_obj['name'] = track_obj.album - elif not album_obj and track_obj.album: - album_obj = {'name': track_obj.album} - # Ensure release_date is present (raw Spotify data has it, but fallback may not) - if isinstance(album_obj, dict) and not album_obj.get('release_date'): - album_obj['release_date'] = getattr(track_obj, 'release_date', '') or '' - # Extract image URL from album data or track object - _album_images = album_obj.get('images', []) if isinstance(album_obj, dict) else [] - _image_url = _album_images[0].get('url', '') if _album_images else (getattr(track_obj, 'image_url', '') or '') - - match_data = { - 'id': track_obj.id, - 'name': track_obj.name, - 'artists': track_obj.artists, - 'album': album_obj, - 'duration_ms': track_obj.duration_ms, - 'external_urls': track_obj.external_urls, - 'image_url': _image_url, - 'source': 'spotify' - } - # Preserve track_number/disc_number from raw Spotify API data - if raw_track_data and raw_track_data.get('track_number'): - match_data['track_number'] = raw_track_data['track_number'] - if raw_track_data and raw_track_data.get('disc_number'): - match_data['disc_number'] = raw_track_data['disc_number'] - result['spotify_data'] = match_data - result['match_data'] = match_data - result['status'] = 'found' - result['confidence'] = match_confidence - successful_discoveries += 1 - state['spotify_matches'] = successful_discoveries - - elif not use_spotify and track_result and isinstance(track_result, dict): - # Fallback: Function returns a dict with track data (includes 'confidence' key) - match_confidence = track_result.pop('confidence', 0.80) - match_data = track_result - match_data['source'] = discovery_source - # Extract image URL from album images - _fb_album = match_data.get('album', {}) - _fb_images = _fb_album.get('images', []) if isinstance(_fb_album, dict) else [] - if _fb_images and 'image_url' not in match_data: - match_data['image_url'] = _fb_images[0].get('url', '') - result['spotify_data'] = match_data - result['match_data'] = match_data - result['status'] = 'found' - result['confidence'] = match_confidence - successful_discoveries += 1 - state['spotify_matches'] = successful_discoveries - - # Save to discovery cache if match found - if result['status'] == 'found' and result.get('match_data'): - try: - cache_db = get_database() - cache_db.save_discovery_cache_match( - cache_key[0], cache_key[1], discovery_source, match_confidence, - result['match_data'], tidal_track.name, - tidal_track.artists[0] if tidal_track.artists else '' - ) - logger.info(f"CACHE SAVED: {tidal_track.name} (confidence: {match_confidence:.3f})") - except Exception as cache_err: - logger.error(f"Cache save error: {cache_err}") - - # Auto Wing It fallback for unmatched tracks - if result['status'] != 'found': - tidal_t = result.get('tidal_track', {}) - stub = _build_discovery_wing_it_stub( - tidal_t.get('name', ''), - ', '.join(tidal_t.get('artists', [])), - tidal_t.get('duration_ms', 0) - ) - result['status'] = 'found' - result['status_class'] = 'wing-it' - result['spotify_data'] = stub - result['match_data'] = stub - result['wing_it_fallback'] = True - result['confidence'] = 0 - successful_discoveries += 1 - state['spotify_matches'] = successful_discoveries - state['wing_it_count'] = state.get('wing_it_count', 0) + 1 - - state['discovery_results'].append(result) - state['discovery_progress'] = int(((i + 1) / len(playlist.tracks)) * 100) - - # Add delay between requests - time.sleep(0.1) - - except Exception as e: - logger.error(f"Error processing track {i+1}: {e}") - # Add error result - result = { - 'tidal_track': { - 'name': tidal_track.name, - 'artists': tidal_track.artists or [], - }, - 'spotify_data': None, - 'match_data': None, - 'status': 'error', - 'error': str(e), - 'discovery_source': discovery_source - } - state['discovery_results'].append(result) - state['discovery_progress'] = int(((i + 1) / len(playlist.tracks)) * 100) - - # Mark as complete - state['phase'] = 'discovered' - state['status'] = 'discovered' - state['discovery_progress'] = 100 - - # Add activity for discovery completion - source_label = discovery_source.upper() - add_activity_item("", f"Tidal Discovery Complete ({source_label})", f"'{playlist.name}' - {successful_discoveries}/{len(playlist.tracks)} tracks found", "Now") +def _build_tidal_discovery_deps(): + """Build the TidalDiscoveryDeps bundle from web_server.py globals on each call.""" + return _discovery_tidal.TidalDiscoveryDeps( + tidal_discovery_states=tidal_discovery_states, + spotify_client=spotify_client, + 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, + search_spotify_for_tidal_track=_search_spotify_for_tidal_track, + build_discovery_wing_it_stub=_build_discovery_wing_it_stub, + add_activity_item=add_activity_item, + sync_discovery_results_to_mirrored=_sync_discovery_results_to_mirrored, + ) - logger.info(f"Tidal discovery complete ({source_label}): {successful_discoveries}/{len(playlist.tracks)} tracks found") - # Sync discovery results back to mirrored playlist - _sync_discovery_results_to_mirrored('tidal', playlist_id, state.get('discovery_results', []), discovery_source, profile_id=state.get('_profile_id', 1)) +def _run_tidal_discovery_worker(playlist_id): + return _discovery_tidal.run_tidal_discovery_worker(playlist_id, _build_tidal_discovery_deps()) - except Exception as e: - logger.error(f"Error in Tidal discovery worker: {e}") - state['phase'] = 'error' - state['status'] = f'error: {str(e)}' - finally: - _resume_enrichment_workers(_ew_state, 'Tidal discovery') def _search_spotify_for_tidal_track(tidal_track, use_spotify=True, itunes_client=None):