"""Discover Hero endpoint — lifted from web_server.py. The function body is byte-identical to the original. The ``spotify_client`` proxy + helper shims let the body resolve its original names; the more complex ``_get_metadata_fallback_client`` is injected via init() because it composes multiple registry helpers that web_server.py wires together. """ import logging from flask import g, jsonify from database.music_database import get_database from core.metadata.registry import get_primary_source, get_spotify_client logger = logging.getLogger(__name__) def get_current_profile_id() -> int: """Mirror of web_server.get_current_profile_id — uses Flask g. Catches RuntimeError too because reading `g` outside a request context raises that (not AttributeError) — happens when this is called from background threads (sync, automation, scanners).""" try: return g.profile_id except (AttributeError, RuntimeError): return 1 def _get_active_discovery_source(): """Mirror of web_server._get_active_discovery_source — delegates to registry.""" return get_primary_source() class _SpotifyClientProxy: """Resolves the global Spotify client lazily through core.metadata.registry.""" def __getattr__(self, name): client = get_spotify_client() if client is None: raise AttributeError(name) return getattr(client, name) def __bool__(self): return get_spotify_client() is not None spotify_client = _SpotifyClientProxy() # Injected at runtime via init(). _get_metadata_fallback_client = None def init(get_metadata_fallback_client_fn): """Bind web_server's _get_metadata_fallback_client helper.""" global _get_metadata_fallback_client _get_metadata_fallback_client = get_metadata_fallback_client_fn def get_discover_hero(): """Get featured similar artists for hero slideshow""" try: database = get_database() # Determine active source active_source = _get_active_discovery_source() logger.info(f"Discover hero using source: {active_source}") # Import fallback client for non-Spotify lookups itunes_client = _get_metadata_fallback_client() # Get top similar artists (excluding watchlist, cycled by last_featured) # Fetch more than needed since strict source filtering may drop many pid = get_current_profile_id() logger.info(f"[Discover Hero] Profile ID: {pid}, Active source: {active_source}") similar_artists = database.get_top_similar_artists(limit=200, profile_id=pid, require_source=active_source) # FALLBACK: If no similar artists exist, use watchlist artists for Hero section if not similar_artists: logger.warning("[Discover Hero] No similar artists found, falling back to watchlist artists") watchlist_artists = database.get_watchlist_artists(profile_id=pid) if not watchlist_artists: return jsonify({"success": True, "artists": [], "source": active_source}) # Convert watchlist artists to hero format import random shuffled_watchlist = list(watchlist_artists) random.shuffle(shuffled_watchlist) hero_artists = [] for artist in shuffled_watchlist[:10]: if active_source == 'spotify': artist_id = artist.spotify_artist_id elif active_source == 'deezer': artist_id = getattr(artist, 'deezer_artist_id', None) or artist.itunes_artist_id elif active_source == 'musicbrainz': artist_id = getattr(artist, 'musicbrainz_artist_id', None) or artist.itunes_artist_id else: artist_id = artist.itunes_artist_id if not artist_id: continue artist_data = { "spotify_artist_id": artist.spotify_artist_id, "itunes_artist_id": artist.itunes_artist_id, "artist_id": artist_id, "artist_name": artist.artist_name, "occurrence_count": 1, "similarity_rank": 1, "source": active_source, "is_watchlist": True } # Use cached image from watchlist — no API call needed if hasattr(artist, 'image_url') and artist.image_url: artist_data['image_url'] = artist.image_url hero_artists.append(artist_data) logger.warning(f"[Discover Hero] Returning {len(hero_artists)} watchlist artists as fallback") return jsonify({"success": True, "artists": hero_artists, "source": active_source, "fallback": "watchlist"}) # Artists are already filtered by source in SQL — no post-filter needed valid_artists = list(similar_artists) # FALLBACK: If no valid artists for fallback source, try to resolve IDs on-the-fly if active_source in ('itunes', 'deezer', 'musicbrainz') and not valid_artists: logger.warning(f"[{active_source} Fallback] No artists with {active_source} IDs found, attempting on-the-fly resolution for {len(similar_artists)} artists") resolved_count = 0 for artist in similar_artists: existing_id = getattr(artist, f'similar_artist_{active_source}_id', None) or (artist.similar_artist_itunes_id if active_source == 'itunes' else None) if existing_id: valid_artists.append(artist) continue # Try to resolve ID by name try: resolve_client = itunes_client if active_source == 'musicbrainz': from core.metadata.registry import get_musicbrainz_client resolve_client = get_musicbrainz_client() search_results = resolve_client.search_artists(artist.similar_artist_name, limit=1) if search_results and len(search_results) > 0: resolved_id = search_results[0].id # Cache the resolved ID for future use if active_source == 'deezer': database.update_similar_artist_deezer_id(artist.id, resolved_id) artist.similar_artist_deezer_id = resolved_id elif active_source == 'musicbrainz': database.update_similar_artist_musicbrainz_id(artist.id, resolved_id) artist.similar_artist_musicbrainz_id = resolved_id else: database.update_similar_artist_itunes_id(artist.id, resolved_id) artist.similar_artist_itunes_id = resolved_id valid_artists.append(artist) resolved_count += 1 logger.info(f" [Resolved] {artist.similar_artist_name} -> {active_source} ID: {resolved_id}") except Exception as resolve_err: logger.error(f" [Failed] Could not resolve {active_source} ID for {artist.similar_artist_name}: {resolve_err}") # Stop after 10 successful resolutions to avoid rate limiting if len(valid_artists) >= 10: break logger.warning(f"[{active_source} Fallback] Resolved {resolved_count} artists with IDs") logger.info(f"[Discover Hero] Found {len(valid_artists)} valid artists for source: {active_source}") # Filter out blacklisted artists blacklisted = database.get_discovery_blacklist_names() if blacklisted: valid_artists = [a for a in valid_artists if a.similar_artist_name.lower() not in blacklisted] # Take top 10 (already ordered by least-recently-featured, then quality) similar_artists = valid_artists[:10] # Convert to JSON format — use cached metadata, only fetch from API if missing hero_artists = [] for artist in similar_artists: # Use the ID for the active source, falling back to the other if needed if active_source == 'spotify': artist_id = artist.similar_artist_spotify_id or artist.similar_artist_itunes_id elif active_source == 'deezer': artist_id = getattr(artist, 'similar_artist_deezer_id', None) or artist.similar_artist_itunes_id or artist.similar_artist_spotify_id elif active_source == 'musicbrainz': artist_id = getattr(artist, 'similar_artist_musicbrainz_id', None) or artist.similar_artist_itunes_id or artist.similar_artist_spotify_id else: artist_id = artist.similar_artist_itunes_id or artist.similar_artist_spotify_id artist_data = { "spotify_artist_id": artist.similar_artist_spotify_id, "itunes_artist_id": artist.similar_artist_itunes_id, "musicbrainz_artist_id": getattr(artist, 'similar_artist_musicbrainz_id', None), "artist_id": artist_id, "artist_name": artist.similar_artist_name, "occurrence_count": artist.occurrence_count, "similarity_rank": artist.similarity_rank, "source": active_source } # Use cached metadata if available if artist.image_url: artist_data['image_url'] = artist.image_url artist_data['genres'] = artist.genres or [] artist_data['popularity'] = artist.popularity or 0 else: # No cached metadata — fetch from API and cache for next time try: if active_source == 'spotify' and artist.similar_artist_spotify_id: if spotify_client and spotify_client.is_authenticated(): sp_artist = spotify_client.get_artist(artist.similar_artist_spotify_id) if sp_artist and sp_artist.get('images'): artist_data['artist_name'] = sp_artist.get('name', artist.similar_artist_name) artist_data['image_url'] = sp_artist['images'][0]['url'] if sp_artist['images'] else None artist_data['genres'] = sp_artist.get('genres', []) artist_data['popularity'] = sp_artist.get('popularity', 0) # Cache it database.update_similar_artist_metadata( artist.id, artist_data.get('image_url'), artist_data.get('genres'), artist_data.get('popularity') ) elif active_source in ('itunes', 'deezer', 'musicbrainz'): if active_source == 'deezer': fb_artist_id = getattr(artist, 'similar_artist_deezer_id', None) or artist.similar_artist_itunes_id fetch_client = itunes_client elif active_source == 'musicbrainz': fb_artist_id = getattr(artist, 'similar_artist_musicbrainz_id', None) from core.metadata.registry import get_musicbrainz_client fetch_client = get_musicbrainz_client() else: fb_artist_id = artist.similar_artist_itunes_id fetch_client = itunes_client if fb_artist_id: fb_artist_data = fetch_client.get_artist(fb_artist_id) if fb_artist_data: artist_data['artist_name'] = fb_artist_data.get('name', artist.similar_artist_name) artist_data['image_url'] = fb_artist_data.get('images', [{}])[0].get('url') if fb_artist_data.get('images') else None artist_data['genres'] = fb_artist_data.get('genres', []) artist_data['popularity'] = fb_artist_data.get('popularity', 0) # Cache it database.update_similar_artist_metadata( artist.id, artist_data.get('image_url'), artist_data.get('genres'), artist_data.get('popularity') ) except Exception as img_err: logger.error(f"Could not fetch artist image: {img_err}") hero_artists.append(artist_data) # Mark these artists as featured so they cycle to the back of the queue featured_names = [a["artist_name"] for a in hero_artists] database.mark_artists_featured(featured_names) return jsonify({"success": True, "artists": hero_artists, "source": active_source}) except Exception as e: logger.error(f"Error getting discover hero: {e}") return jsonify({"success": False, "error": str(e)}), 500