soulsync/core/discovery/hero.py
Broque Thomas 5bc5fbb662 Add MusicBrainz as a metadata source
Register MusicBrainz as a first-class metadata source alongside Deezer, iTunes, Spotify, Discogs, and Hydrabase. Expose the shared client through metadata services, add the settings option, and expand the MusicBrainz search adapter with source-compatible artist, album, track, and detail methods.

Carry MusicBrainz IDs through similar-artist discovery, recommended artists, artist map serialization, and personalized playlist selection. Update DB migrations and lookup filters so similar_artist_musicbrainz_id is preserved on older schemas and used for source requirements and library exclusion.

Normalize MusicBrainz album adapter output for import context and add regression coverage for registry mapping, typed album conversion, and similar-artist filtering. Verified by user with 120 focused tests passing.
2026-05-18 18:47:13 -07:00

258 lines
13 KiB
Python

"""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