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.
258 lines
13 KiB
Python
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
|