Multi-source genre explorer with Deezer genre support and cross-source routing

Genre explorer and deep dive modal now combine data from all available
metadata sources (iTunes + Deezer always, Spotify when authenticated).
Artists are deduplicated by name across sources, preferring entries
with images. Source dots (green/red/purple) indicate data origin.

Deezer genre support:
- Extract genre_id from Deezer album search responses via ID-to-name
  mapping table (26 Deezer genre categories)
- Extract full genre names from Deezer get_album responses
- One-time backfill updates existing cached albums from stored raw_json
- Propagate album genres to Deezer artist entities

Cross-source album routing:
- /api/discover/album endpoint uses source-specific client (iTunes or
  Deezer) based on the item's source, not just the active fallback
- Spotify path falls back to active fallback when album not found
- Track clicks use album_id directly instead of name-based resolution
- resolve-cache-album adds partial match and live search fallback

Other fixes:
- Genre explorer positioned at top of Discover page (below hero)
- Genre explorer results cached 24hr in-memory for fast reload
- Related genres computed from all albums by matched artists
- Artist clicks open Artists page with discography (not library detail)
- Discovery pool genre queries restored to source-filtered (Browse by
  Genre tabs stay source-isolated as designed)
This commit is contained in:
Broque Thomas 2026-03-24 19:36:11 -07:00
parent a3309d28a6
commit e08462a002
5 changed files with 520 additions and 105 deletions

View file

@ -18,13 +18,23 @@ _cache_instance = None
_cache_lock = threading.Lock()
_backfill_done = False
def get_metadata_cache():
"""Get or create the singleton MetadataCache instance."""
global _cache_instance
global _cache_instance, _backfill_done
if _cache_instance is None:
with _cache_lock:
if _cache_instance is None:
_cache_instance = MetadataCache()
# One-time backfill of Deezer album genres from stored raw_json
if not _backfill_done:
_backfill_done = True
try:
import threading
threading.Thread(target=_cache_instance.backfill_deezer_album_genres, daemon=True).start()
except Exception:
pass
return _cache_instance
@ -663,13 +673,28 @@ class MetadataCache:
return fields
# Deezer genre_id → name mapping (from https://api.deezer.com/genre)
_DEEZER_GENRE_MAP = {
132: 'Pop', 116: 'Rap/Hip Hop', 122: 'Reggaeton', 152: 'Rock', 113: 'Dance',
165: 'R&B', 85: 'Alternative', 186: 'Christian', 106: 'Electro', 466: 'Folk',
144: 'Reggae', 129: 'Jazz', 84: 'Country', 67: 'Salsa', 173: 'Films/Games',
98: 'Classical', 169: 'Soul & Funk', 2: 'African Music', 16: 'Asian Music',
153: 'Blues', 75: 'Brazilian Music', 81: 'Indian Music', 95: 'Kids',
197: 'Latin Music', 73: 'Metal', 464: 'Rap', 174: 'Musicals',
}
def _extract_deezer_fields(self, entity_type: str, data: dict) -> dict:
"""Extract fields from Deezer API response."""
fields = {}
if entity_type == 'artist':
fields['name'] = data.get('name', '')
fields['genres'] = '[]'
# Deezer artists don't have genres directly, but may have genre_id from search context
genre_id = data.get('genre_id')
if genre_id and genre_id in self._DEEZER_GENRE_MAP:
fields['genres'] = json.dumps([self._DEEZER_GENRE_MAP[genre_id]])
else:
fields['genres'] = '[]'
fields['popularity'] = 0
fields['followers'] = data.get('nb_fan', 0)
fields['image_url'] = data.get('picture_xl') or data.get('picture_big') or data.get('picture_medium')
@ -689,6 +714,17 @@ class MetadataCache:
fields['album_type'] = record_type if record_type in ('single', 'ep', 'album') else 'album'
fields['label'] = data.get('label', '')
fields['image_url'] = data.get('cover_xl') or data.get('cover_big') or data.get('cover_medium')
# Deezer full album response: genres in data.genres.data[].name
# Deezer search response: genre_id (numeric) — map to name
dz_genres = data.get('genres', {})
if isinstance(dz_genres, dict):
dz_genres = dz_genres.get('data', [])
if isinstance(dz_genres, list) and dz_genres:
fields['genres'] = json.dumps([g.get('name', '') for g in dz_genres if isinstance(g, dict) and g.get('name')])
else:
genre_id = data.get('genre_id')
if genre_id and genre_id in self._DEEZER_GENRE_MAP:
fields['genres'] = json.dumps([self._DEEZER_GENRE_MAP[genre_id]])
urls = {}
if data.get('link'):
urls['deezer'] = data['link']
@ -842,7 +878,7 @@ class MetadataCache:
logger.error(f"Undiscovered albums error: {e}")
return []
def get_genre_new_releases(self, user_genres, source=None, limit=20):
def get_genre_new_releases(self, user_genres, source=None, sources=None, limit=20):
"""Find recently released cached albums matching user's genres."""
if not user_genres:
return []
@ -854,7 +890,11 @@ class MetadataCache:
genre_clauses = ' OR '.join(['genres LIKE ?' for _ in user_genres])
params = [f'%{g}%' for g in user_genres]
source_filter = ""
if source:
if sources:
placeholders = ','.join(['?'] * len(sources))
source_filter = f"AND source IN ({placeholders})"
params.extend(sources)
elif source:
source_filter = "AND source = ?"
params.append(source)
cursor.execute(f"""
@ -941,7 +981,7 @@ class MetadataCache:
logger.error(f"Deep cuts error: {e}")
return []
def get_genre_deep_dive(self, genre, source=None, artist_limit=12, album_limit=20, track_limit=15):
def get_genre_deep_dive(self, genre, source=None, sources=None, artist_limit=12, album_limit=20, track_limit=15):
"""Get artists, albums, and tracks for a genre. Albums don't have genres in Spotify,
so we find artists with matching genres then fetch their cached albums and tracks."""
if not genre:
@ -952,11 +992,18 @@ class MetadataCache:
try:
cursor = conn.cursor()
# Step 1: Find artists with this genre
params = [f'%{genre}%']
source_filter = "AND source = ?" if source else ""
if source:
params.append(source)
# Build source filter for allowed sources
source_filter = ""
source_params = []
if sources:
placeholders = ','.join(['?'] * len(sources))
source_filter = f"AND source IN ({placeholders})"
source_params = list(sources)
elif source:
source_filter = "AND source = ?"
source_params = [source]
params = [f'%{genre}%'] + source_params
# Fetch extra to allow dedup across sources
cursor.execute(f"""
SELECT name, image_url, popularity, followers, entity_id, source, genres
FROM metadata_cache_entities
@ -965,61 +1012,129 @@ class MetadataCache:
{source_filter}
ORDER BY COALESCE(followers, 0) DESC, COALESCE(popularity, 0) DESC
LIMIT ?
""", params + [artist_limit])
artists = [dict(r) for r in cursor.fetchall()]
""", params + [artist_limit * 3])
# Deduplicate by name — prefer entry with image, then most followers
seen_artists = {}
for r in cursor.fetchall():
r = dict(r)
key = r['name'].lower().strip()
existing = seen_artists.get(key)
if not existing:
seen_artists[key] = r
elif not existing.get('image_url') and r.get('image_url'):
seen_artists[key] = r
elif (existing.get('followers') or 0) < (r.get('followers') or 0):
seen_artists[key] = r
artists = list(seen_artists.values())[:artist_limit]
# If not enough artists found (e.g. Deezer), find artists via album genres
# Two-step: get artist names from albums, then look up artist entities
if len(artists) < artist_limit:
existing_names = {a['name'].lower() for a in artists}
album_params = [f'%{genre}%'] + source_params
# Step 1b: Get distinct artist names from genre-matching albums
cursor.execute(f"""
SELECT DISTINCT artist_name FROM metadata_cache_entities
WHERE entity_type = 'album' AND genres LIKE ?
{source_filter}
LIMIT 50
""", album_params)
album_artist_names = [r['artist_name'] for r in cursor.fetchall()
if r['artist_name'] and r['artist_name'].lower() not in existing_names]
# Step 1c: Look up those artists by name (deduplicate across sources)
if album_artist_names:
name_ph = ','.join(['?'] * len(album_artist_names))
art_params = list(album_artist_names) + source_params
cursor.execute(f"""
SELECT name, image_url, popularity, followers, entity_id, source, genres
FROM metadata_cache_entities
WHERE entity_type = 'artist'
AND name COLLATE NOCASE IN ({name_ph})
{source_filter}
ORDER BY COALESCE(followers, 0) DESC
LIMIT ?
""", art_params + [(artist_limit - len(artists)) * 3])
for row in cursor.fetchall():
r = dict(row)
key = r['name'].lower()
if key not in existing_names:
existing_names.add(key)
artists.append(r)
if len(artists) >= artist_limit:
break
artist_names = [a['name'].lower() for a in artists if a.get('name')]
albums = []
tracks = []
original_names = [a['name'] for a in artists if a.get('name')]
if artist_names:
name_placeholders = ','.join(['?'] * len(artist_names))
src_filter = "AND source = ?" if source else ""
if original_names:
name_placeholders = ','.join(['?'] * len(original_names))
# Step 2: Find albums by those artists
album_params = list(artist_names)
if source:
album_params.append(source)
# Step 2: Find albums by those artists (COLLATE NOCASE on column leverages index)
album_params = list(original_names) + source_params
cursor.execute(f"""
SELECT name, artist_name, image_url, popularity, release_date, label,
source, entity_id, album_type, total_tracks
source, entity_id, album_type, total_tracks, genres
FROM metadata_cache_entities
WHERE entity_type = 'album'
AND LOWER(artist_name) IN ({name_placeholders})
{src_filter}
ORDER BY COALESCE(popularity, 0) DESC, access_count DESC
AND artist_name COLLATE NOCASE IN ({name_placeholders})
{source_filter}
ORDER BY COALESCE(popularity, 0) DESC, RANDOM()
LIMIT ?
""", album_params + [album_limit])
albums = [dict(r) for r in cursor.fetchall()]
# Step 3: Find tracks by those artists
track_params = list(artist_names)
if source:
track_params.append(source)
track_params = list(original_names) + source_params
cursor.execute(f"""
SELECT name, artist_name, image_url, popularity, album_name,
source, entity_id, duration_ms, album_id
FROM metadata_cache_entities
WHERE entity_type = 'track'
AND LOWER(artist_name) IN ({name_placeholders})
{src_filter}
ORDER BY COALESCE(popularity, 0) DESC, access_count DESC
AND artist_name COLLATE NOCASE IN ({name_placeholders})
{source_filter}
ORDER BY COALESCE(popularity, 0) DESC, RANDOM()
LIMIT ?
""", track_params + [track_limit])
tracks = [dict(r) for r in cursor.fetchall()]
# Step 4: Find related genres (co-occurring on same artists)
# Step 4: Find related genres from artist genres + ALL albums by these artists
related_genres = {}
genre_lower = genre.lower()
# From artist genre data (Spotify/iTunes — multiple genres per artist)
for artist in artists:
try:
artist_genres = json.loads(artist.get('genres', '[]'))
if isinstance(artist_genres, list):
for g in artist_genres:
g_lower = g.strip().lower()
if g_lower and g_lower != genre.lower():
if g_lower and g_lower != genre_lower:
related_genres[g_lower] = related_genres.get(g_lower, 0) + 1
except (json.JSONDecodeError, TypeError):
pass
# From ALL albums by these artists (not just the 20 we fetched)
# This finds cross-genre artists (e.g., artist has Pop AND R&B albums)
if original_names:
cursor.execute(f"""
SELECT DISTINCT genres FROM metadata_cache_entities
WHERE entity_type = 'album'
AND artist_name COLLATE NOCASE IN ({name_placeholders})
AND genres IS NOT NULL AND genres != '[]'
{source_filter}
""", list(original_names) + source_params)
for row in cursor.fetchall():
try:
parsed = json.loads(row['genres'])
if isinstance(parsed, list):
for g in parsed:
g_lower = g.strip().lower()
if g_lower and g_lower != genre_lower:
related_genres[g_lower] = related_genres.get(g_lower, 0) + 1
except (json.JSONDecodeError, TypeError):
pass
related = sorted(
[{'genre': g.title(), 'count': c} for g, c in related_genres.items()],
key=lambda x: x['count'], reverse=True
@ -1041,16 +1156,20 @@ class MetadataCache:
for album in albums:
album['in_library'] = (album['name'].lower().strip(), album['artist_name'].lower().strip()) in lib_set
# Step 6: Resolve library artist IDs for navigation
for artist in artists:
# Step 6: Resolve library artist IDs for navigation (batched)
if artists:
lib_name_placeholders = ','.join(['LOWER(?)'] * len(artists))
lib_name_params = [a['name'] for a in artists]
try:
cursor.execute("""
SELECT id FROM artists WHERE LOWER(name) = LOWER(?) LIMIT 1
""", (artist['name'],))
row = cursor.fetchone()
artist['library_id'] = row['id'] if row else None
cursor.execute(f"""
SELECT id, LOWER(name) as lname FROM artists
WHERE LOWER(name) IN ({lib_name_placeholders})
""", lib_name_params)
lib_id_map = {r['lname']: r['id'] for r in cursor.fetchall()}
except Exception:
artist['library_id'] = None
lib_id_map = {}
for artist in artists:
artist['library_id'] = lib_id_map.get(artist['name'].lower())
return {'artists': artists, 'albums': albums, 'tracks': tracks, 'related_genres': related}
finally:
@ -1059,8 +1178,21 @@ class MetadataCache:
logger.error(f"Genre deep dive error: {e}")
return {'artists': [], 'albums': [], 'tracks': []}
def get_genre_explorer(self, user_genres_set, source=None):
"""Aggregate genres from cached artists, highlight unexplored ones."""
_genre_explorer_cache = {} # {source: (timestamp, results)}
_GENRE_EXPLORER_TTL = 86400 # 24 hours
def get_genre_explorer(self, user_genres_set, source=None, sources=None):
"""Aggregate genres from cached artists and albums, highlight unexplored ones."""
import time
cache_key = ','.join(sorted(sources)) if sources else (source or '_all')
cached = self._genre_explorer_cache.get(cache_key)
if cached:
ts, raw_results = cached
if time.time() - ts < self._GENRE_EXPLORER_TTL:
user_lower = {g.lower() for g in user_genres_set} if user_genres_set else set()
for r in raw_results:
r['explored'] = r['genre'].lower() in user_lower
return raw_results
try:
db = self._get_db()
conn = db._get_connection()
@ -1068,38 +1200,166 @@ class MetadataCache:
cursor = conn.cursor()
params = []
source_filter = ""
if source:
if sources:
placeholders = ','.join(['?'] * len(sources))
source_filter = f"AND source IN ({placeholders})"
params.extend(sources)
elif source:
source_filter = "AND source = ?"
params.append(source)
# Count unique artists per genre from both artist and album entities
# Artists have genres directly; albums have genre_id-mapped genres + artist_name
genre_artists = {} # {genre_lower: set(artist_names)}
# From artist entities
cursor.execute(f"""
SELECT genres FROM metadata_cache_entities
SELECT name, genres FROM metadata_cache_entities
WHERE entity_type = 'artist'
AND genres IS NOT NULL AND genres != '' AND genres != '[]'
{source_filter}
""", params)
genre_counts = {}
for row in cursor.fetchall():
try:
parsed = json.loads(row['genres'])
if isinstance(parsed, list):
artist_key = (row['name'] or '').lower()
for g in parsed:
g_lower = g.strip().lower()
if g_lower:
genre_counts[g_lower] = genre_counts.get(g_lower, 0) + 1
genre_artists.setdefault(g_lower, set()).add(artist_key)
except (json.JSONDecodeError, TypeError):
pass
# From album entities (for Deezer where artists lack genres)
cursor.execute(f"""
SELECT artist_name, genres FROM metadata_cache_entities
WHERE entity_type = 'album'
AND genres IS NOT NULL AND genres != '' AND genres != '[]'
{source_filter}
""", params)
for row in cursor.fetchall():
try:
parsed = json.loads(row['genres'])
if isinstance(parsed, list):
artist_key = (row['artist_name'] or '').lower()
if artist_key:
for g in parsed:
g_lower = g.strip().lower()
if g_lower:
genre_artists.setdefault(g_lower, set()).add(artist_key)
except (json.JSONDecodeError, TypeError):
pass
user_lower = {g.lower() for g in user_genres_set} if user_genres_set else set()
results = []
for genre, count in sorted(genre_counts.items(), key=lambda x: x[1], reverse=True)[:50]:
for genre, artists_set in sorted(genre_artists.items(), key=lambda x: len(x[1]), reverse=True)[:50]:
if len(artists_set) < 2:
continue # Skip genres with only 1 artist
results.append({
'genre': genre.title(),
'artist_count': count,
'artist_count': len(artists_set),
'explored': genre in user_lower,
})
# Cache for subsequent requests
self._genre_explorer_cache[cache_key] = (time.time(), results)
return results
finally:
conn.close()
except Exception as e:
logger.error(f"Genre explorer error: {e}")
return []
def backfill_deezer_album_genres(self):
"""One-time backfill: extract genres from raw_json for Deezer albums that have genres: '[]'.
Deezer album API responses include genres in data.genres.data[].name but this wasn't
extracted in earlier versions. This parses the stored raw_json and updates the genres field."""
try:
db = self._get_db()
conn = db._get_connection()
try:
cursor = conn.cursor()
cursor.execute("""
SELECT id, raw_json FROM metadata_cache_entities
WHERE source = 'deezer' AND entity_type = 'album'
AND (genres IS NULL OR genres = '' OR genres = '[]')
AND raw_json IS NOT NULL
LIMIT 50000
""")
updated = 0
for row in cursor.fetchall():
try:
raw = json.loads(row['raw_json'])
genre_names = []
# Try full genres object (from get_album responses)
dz_genres = raw.get('genres', {})
if isinstance(dz_genres, dict):
dz_genres = dz_genres.get('data', [])
if isinstance(dz_genres, list) and dz_genres:
genre_names = [g.get('name', '') for g in dz_genres if isinstance(g, dict) and g.get('name')]
# Fallback: genre_id from search responses
if not genre_names:
genre_id = raw.get('genre_id')
if genre_id and genre_id in self._DEEZER_GENRE_MAP:
genre_names = [self._DEEZER_GENRE_MAP[genre_id]]
if genre_names:
cursor.execute(
"UPDATE metadata_cache_entities SET genres = ? WHERE id = ?",
(json.dumps(genre_names), row['id'])
)
updated += 1
except Exception:
continue
conn.commit()
logger.info(f"Deezer album genre backfill: updated {updated} albums")
# Phase 2: Propagate album genres to Deezer artist entities
# Match by artist_name or artist_id from albums that have genres
artist_updated = 0
cursor.execute("""
SELECT DISTINCT artist_name, artist_id, genres
FROM metadata_cache_entities
WHERE source = 'deezer' AND entity_type = 'album'
AND genres IS NOT NULL AND genres != '' AND genres != '[]'
AND (artist_name != '' OR artist_id != '')
""")
album_artists = {} # {artist_identifier: set(genres)}
for row in cursor.fetchall():
try:
names = json.loads(row['genres'])
if not isinstance(names, list):
continue
# Key by artist_name or artist_id
key_name = row['artist_name'] or ''
key_id = row['artist_id'] or ''
for key in [k for k in [key_name, key_id] if k]:
album_artists.setdefault(key, set()).update(names)
except Exception:
continue
# Update artist entities that have empty genres
cursor.execute("""
SELECT id, name, entity_id FROM metadata_cache_entities
WHERE source = 'deezer' AND entity_type = 'artist'
AND (genres IS NULL OR genres = '' OR genres = '[]')
""")
for row in cursor.fetchall():
genres = album_artists.get(row['name']) or album_artists.get(row['entity_id']) or set()
if genres:
cursor.execute(
"UPDATE metadata_cache_entities SET genres = ? WHERE id = ?",
(json.dumps(list(genres)), row['id'])
)
artist_updated += 1
conn.commit()
logger.info(f"Deezer artist genre backfill: updated {artist_updated} artists from album genres")
return updated + artist_updated
finally:
conn.close()
except Exception as e:
logger.error(f"Deezer genre backfill error: {e}")
return 0

View file

@ -322,17 +322,16 @@ class PersonalizedPlaylistsService:
with self.database._get_connection() as conn:
cursor = conn.cursor()
# Get all tracks with genres from discovery pool (source-agnostic —
# genres are artist metadata, not tied to a specific metadata source)
# Get all tracks with genres from discovery pool, filtered by source
cursor.execute("""
SELECT artist_genres
FROM discovery_pool
WHERE artist_genres IS NOT NULL
""")
WHERE artist_genres IS NOT NULL AND source = ?
""", (active_source,))
rows = cursor.fetchall()
if not rows:
logger.warning("No genres found in discovery pool - genres may not be populated yet")
logger.warning(f"No genres found in discovery pool for source {active_source}")
return []
# Count tracks per PARENT genre (consolidated)

View file

@ -26961,35 +26961,95 @@ def get_discover_album(source, album_id):
logger.warning(f"Hydrabase album_tracks failed for '{album_id}', falling back to {source}: {e}")
if source == 'spotify':
if not spotify_client or not spotify_client.is_authenticated():
return jsonify({"error": "Spotify not authenticated."}), 401
album_data = spotify_client.get_album(album_id) if spotify_client and spotify_client.is_authenticated() else None
album_data = spotify_client.get_album(album_id)
if not album_data:
return jsonify({"error": "Album not found"}), 404
if album_data:
tracks = album_data.get('tracks', {}).get('items', [])
if not tracks:
tracks_data = spotify_client.get_album_tracks(album_id)
if tracks_data and 'items' in tracks_data:
tracks = tracks_data['items']
tracks = album_data.get('tracks', {}).get('items', [])
if not tracks:
tracks_data = spotify_client.get_album_tracks(album_id)
if tracks_data and 'items' in tracks_data:
tracks = tracks_data['items']
return jsonify({
'id': album_data['id'],
'name': album_data['name'],
'artists': album_data.get('artists', []),
'release_date': album_data.get('release_date', ''),
'total_tracks': album_data.get('total_tracks', 0),
'album_type': album_data.get('album_type', 'album'),
'images': album_data.get('images', []),
'tracks': tracks,
'source': 'spotify'
})
return jsonify({
'id': album_data['id'],
'name': album_data['name'],
'artists': album_data.get('artists', []),
'release_date': album_data.get('release_date', ''),
'total_tracks': album_data.get('total_tracks', 0),
'album_type': album_data.get('album_type', 'album'),
'images': album_data.get('images', []),
'tracks': tracks,
'source': 'spotify'
})
# Spotify failed (not authenticated, album removed, rate limited) — try fallback
album_name = request.args.get('name', '')
album_artist = request.args.get('artist', '')
fallback = _get_metadata_fallback_client()
if fallback and (album_name or album_artist):
clean_name = album_name.replace(' - Single', '').replace(' - EP', '').replace(' (Single)', '').strip()
search_query = f"{album_artist} {clean_name}" if album_artist else clean_name
try:
results = fallback.search_albums(search_query, limit=3)
for r in (results or []):
tracks_data = fallback.get_album_tracks(str(r.id))
tracks = tracks_data.get('items', []) if tracks_data else []
if tracks:
return jsonify({
'id': str(r.id),
'name': r.name,
'artists': [{'name': getattr(r, 'artist', album_artist) or album_artist}],
'release_date': getattr(r, 'release_date', '') or '',
'total_tracks': getattr(r, 'total_tracks', len(tracks)),
'album_type': getattr(r, 'album_type', 'album') or 'album',
'images': [{'url': r.image_url}] if getattr(r, 'image_url', None) else [],
'tracks': tracks,
'source': _get_metadata_fallback_source(),
})
except Exception as e:
logger.debug(f"Fallback album resolve failed: {e}")
return jsonify({"error": "Album not found"}), 404
elif source in ('itunes', 'deezer'):
fallback_client = _get_metadata_fallback_client()
# Use the source-specific client, not just the active fallback
if source == 'deezer':
fallback_client = _get_deezer_client()
fallback_source = 'deezer'
else:
from core.itunes_client import iTunesClient
fallback_client = iTunesClient()
fallback_source = 'itunes'
album_data = fallback_client.get_album(album_id)
# If ID doesn't resolve (cross-source ID), search by name+artist
if not album_data:
album_name = request.args.get('name', '')
album_artist = request.args.get('artist', '')
if album_name or album_artist:
clean_name = album_name.replace(' - Single', '').replace(' - EP', '').replace(' (Single)', '').strip()
search_query = f"{album_artist} {clean_name}" if album_artist else clean_name
try:
results = fallback_client.search_albums(search_query, limit=3)
for r in (results or []):
tracks_data = fallback_client.get_album_tracks(str(r.id))
tracks = tracks_data.get('items', []) if tracks_data else []
if tracks:
return jsonify({
'id': str(r.id),
'name': r.name,
'artists': [{'name': getattr(r, 'artist', album_artist) or album_artist}],
'release_date': getattr(r, 'release_date', '') or '',
'total_tracks': getattr(r, 'total_tracks', len(tracks)),
'album_type': getattr(r, 'album_type', 'album') or 'album',
'images': [{'url': r.image_url}] if getattr(r, 'image_url', None) else [],
'tracks': tracks,
'source': fallback_source,
})
except Exception as e:
logger.debug(f"Fallback album name search failed: {e}")
if not album_data:
return jsonify({"error": "Album not found"}), 404
@ -27005,7 +27065,7 @@ def get_discover_album(source, album_id):
'album_type': album_data.get('album_type', 'album'),
'images': album_data.get('images', []),
'tracks': tracks,
'source': source
'source': fallback_source,
})
else:
@ -35801,12 +35861,12 @@ def get_discover_genre_new_releases():
try:
database = get_database()
cache = get_metadata_cache()
active_source = _get_active_discovery_source()
genres = database.get_genre_breakdown('all')
genre_names = [g['genre'] for g in (genres or [])[:10] if g.get('genre')]
if not genre_names:
return jsonify({'success': True, 'albums': []})
albums = cache.get_genre_new_releases(genre_names, source=active_source, limit=20)
allowed = _get_genre_allowed_sources()
albums = cache.get_genre_new_releases(genre_names, source=allowed, limit=20)
return jsonify({'success': True, 'albums': albums})
except Exception as e:
logger.error(f"Genre new releases endpoint error: {e}")
@ -35852,16 +35912,25 @@ def get_discover_deep_cuts():
logger.error(f"Deep cuts endpoint error: {e}")
return jsonify({'success': True, 'tracks': []})
def _get_genre_allowed_sources():
"""Get allowed metadata sources for genre features.
Spotify authed ['spotify', 'itunes', 'deezer']
Not authed ['itunes', 'deezer']"""
sources = ['itunes', 'deezer']
if spotify_client and spotify_client.is_spotify_authenticated():
sources.append('spotify')
return sources
@app.route('/api/discover/genre-explorer', methods=['GET'])
def get_discover_genre_explorer():
"""Genre landscape from cached artists — highlights unexplored genres."""
try:
database = get_database()
cache = get_metadata_cache()
active_source = _get_active_discovery_source()
genres = database.get_genre_breakdown('all')
user_genres = {g['genre'] for g in (genres or []) if g.get('genre')}
data = cache.get_genre_explorer(user_genres, source=active_source)
allowed = _get_genre_allowed_sources()
data = cache.get_genre_explorer(user_genres, sources=allowed)
return jsonify({'success': True, 'genres': data})
except Exception as e:
logger.error(f"Genre explorer endpoint error: {e}")
@ -35874,9 +35943,9 @@ def get_discover_genre_deep_dive():
genre = request.args.get('genre', '').strip()
if not genre:
return jsonify({'success': False, 'error': 'genre required'}), 400
active_source = _get_active_discovery_source()
cache = get_metadata_cache()
data = cache.get_genre_deep_dive(genre, source=active_source)
allowed = _get_genre_allowed_sources()
data = cache.get_genre_deep_dive(genre, sources=allowed)
return jsonify({'success': True, **data})
except Exception as e:
logger.error(f"Genre albums endpoint error: {e}")
@ -35895,18 +35964,43 @@ def resolve_cache_album():
database = get_database()
with database._get_connection() as conn:
cursor = conn.cursor()
# Prefer active source, fall back to any source
# Strategy 1: exact match, prefer active source
cursor.execute("""
SELECT entity_id, source FROM metadata_cache_entities
WHERE entity_type = 'album'
AND LOWER(name) = LOWER(?)
AND LOWER(artist_name) = LOWER(?)
AND name COLLATE NOCASE = ? COLLATE NOCASE
AND artist_name COLLATE NOCASE = ? COLLATE NOCASE
ORDER BY CASE WHEN source = ? THEN 0 ELSE 1 END
LIMIT 1
""", (name, artist, active_source))
row = cursor.fetchone()
if row:
return jsonify({'success': True, 'entity_id': row['entity_id'], 'source': row['source']})
# Strategy 2: partial match (handles "Album - Single" vs "Album" naming)
cursor.execute("""
SELECT entity_id, source FROM metadata_cache_entities
WHERE entity_type = 'album'
AND name COLLATE NOCASE LIKE ? COLLATE NOCASE
AND artist_name COLLATE NOCASE LIKE ? COLLATE NOCASE
ORDER BY CASE WHEN source = ? THEN 0 ELSE 1 END
LIMIT 1
""", (f'%{name}%', f'%{artist}%', active_source))
row = cursor.fetchone()
if row:
return jsonify({'success': True, 'entity_id': row['entity_id'], 'source': row['source']})
# Strategy 3: not in cache — try searching the fallback client directly
fallback = _get_metadata_fallback_client()
if fallback:
try:
results = fallback.search_albums(f"{artist} {name}", limit=3)
if results:
r = results[0]
return jsonify({'success': True, 'entity_id': str(r.id), 'source': _get_metadata_fallback_source()})
except Exception:
pass
return jsonify({'success': False, 'error': 'Album not found in cache'})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500

View file

@ -50768,26 +50768,43 @@ async function openCacheDiscoverAlbum(sectionKey, index) {
// Deep cuts / genre dive tracks — find the real album by searching the cache
if (sectionKey === 'deep_cuts' || sectionKey === 'genre_dive_tracks') {
document.getElementById('genre-deep-dive-modal')?.remove();
const albumName = item.album_name || '';
const albumName = item.album_name || item.name || '';
const artistName = item.artist_name || '';
if (!albumName || !artistName) {
showToast('No album data available for this track', 'error');
const trackAlbumId = item.album_id || '';
const trackSource = item.source || source;
if (!artistName) {
showToast('No artist data available for this track', 'error');
return;
}
// Search for the album by name+artist to get the real album entity_id
showLoadingOverlay(`Loading ${albumName}...`);
try {
const searchResp = await fetch(`/api/discover/resolve-cache-album?name=${encodeURIComponent(albumName)}&artist=${encodeURIComponent(artistName)}`);
if (!searchResp.ok) throw new Error('Album not found in cache');
const searchData = await searchResp.json();
if (!searchData.success || !searchData.entity_id) throw new Error('Album not found in cache');
let resolvedSource = trackSource;
let resolvedId = trackAlbumId;
let response;
const resolvedSource = searchData.source || source;
const resolvedId = searchData.entity_id;
// If we have an album_id, use it directly
if (trackAlbumId) {
const _params = new URLSearchParams({ name: albumName, artist: artistName });
response = await fetch(`/api/discover/album/${trackSource}/${trackAlbumId}?${_params}`);
}
const _params = new URLSearchParams({ name: albumName, artist: artistName });
const response = await fetch(`/api/discover/album/${resolvedSource}/${resolvedId}?${_params}`);
if (!response.ok) throw new Error('Failed to fetch album tracks');
// Fallback: resolve by name+artist if no album_id or direct fetch failed
if (!trackAlbumId || (response && !response.ok)) {
const searchResp = await fetch(`/api/discover/resolve-cache-album?name=${encodeURIComponent(albumName)}&artist=${encodeURIComponent(artistName)}`);
if (searchResp.ok) {
const searchData = await searchResp.json();
if (searchData.success && searchData.entity_id) {
resolvedSource = searchData.source || trackSource;
resolvedId = searchData.entity_id;
const _params = new URLSearchParams({ name: albumName, artist: artistName });
response = await fetch(`/api/discover/album/${resolvedSource}/${resolvedId}?${_params}`);
}
}
}
if (!response || !response.ok) throw new Error('Failed to fetch album tracks');
const albumData = await response.json();
if (!albumData.tracks || albumData.tracks.length === 0) throw new Error('No tracks found');
@ -50885,7 +50902,7 @@ async function openCacheDiscoverAlbum(sectionKey, index) {
}
}
function _insertCacheSection(id, title, subtitle, html) {
function _insertCacheSection(id, title, subtitle, html, position) {
const container = document.getElementById('discover-bylt-sections') || document.querySelector('.discover-container');
if (!container) return;
let section = document.getElementById(id);
@ -50893,7 +50910,17 @@ function _insertCacheSection(id, title, subtitle, html) {
section = document.createElement('div');
section.id = id;
section.className = 'discover-section';
container.appendChild(section);
if (position === 'top') {
// Insert after the hero section (first child), not before it
const hero = container.querySelector('.discover-hero');
if (hero && hero.nextSibling) {
container.insertBefore(section, hero.nextSibling);
} else {
container.prepend(section);
}
} else {
container.appendChild(section);
}
}
section.innerHTML = `
<div class="discover-section-header">
@ -50973,7 +51000,7 @@ async function loadCacheGenreExplorer() {
</div>
`).join('')}</div>`;
_insertCacheSection('cache-genre-explorer',
'Genre Explorer', 'Tap a genre to explore', html);
'Genre Explorer', 'Tap a genre to explore', html, 'top');
} catch (e) { console.debug('Cache genre explorer:', e); }
}
@ -51045,17 +51072,23 @@ async function openGenreDeepDive(genre) {
</div>`;
}
// Artists section — clickable, navigates to artist page directly
// Artists section — clickable, navigates to artist page
// Uses library_id for in-library artists (source-agnostic), falls back to search by name
if (data.artists && data.artists.length) {
html += `<div class="genre-dive-section">
<h3 class="genre-dive-section-title"><span class="genre-dive-icon">🎤</span> Artists in ${_esc(genre)}</h3>
<div class="genre-dive-artists">
${data.artists.map(a => {
const clickAction = `onclick="document.getElementById('genre-deep-dive-modal').remove();navigateToPage('artists');setTimeout(()=>selectArtistForDetail({id:'${_esc(a.entity_id)}',name:'${_esc(a.name)}',image_url:'${_esc(a.image_url||'')}'},{source:'${_esc(a.source||'spotify')}'}),300)"`;
// Always open on Artists page with discography — pass source for correct routing
const imgUrl = _esc(a.image_url || '');
const artSource = _esc(a.source || '');
const clickAction = `onclick="document.getElementById('genre-deep-dive-modal').remove();navigateToPage('artists');setTimeout(()=>selectArtistForDetail({id:'${_esc(a.entity_id)}',name:'${_esc(a.name)}',image_url:'${imgUrl}'},{source:'${artSource}'}),300)"`;
const srcClass = (a.source || '').toLowerCase();
return `<div class="genre-dive-artist" ${clickAction}>
<div class="genre-dive-artist-img" style="${a.image_url ? `background-image:url('${_esc(a.image_url)}')` : ''}">
${!a.image_url ? '<span>🎤</span>' : ''}
</div>
<span class="genre-dive-src-dot genre-dive-src-${srcClass}"></span>
<div class="genre-dive-artist-name">${_esc(a.name)}</div>
${a.followers ? `<div class="genre-dive-artist-meta">${_fmtNum(a.followers)} followers</div>` : ''}
${a.library_id ? '<div class="genre-dive-artist-badge">In Library</div>' : ''}
@ -51071,7 +51104,9 @@ async function openGenreDeepDive(genre) {
html += `<div class="genre-dive-section">
<h3 class="genre-dive-section-title"><span class="genre-dive-icon">🎵</span> Popular Tracks</h3>
<div class="genre-dive-tracks">
${data.tracks.map((t, i) => `
${data.tracks.map((t, i) => {
const tSrcClass = (t.source || '').toLowerCase();
return `
<div class="genre-dive-track" onclick="document.getElementById('genre-deep-dive-modal').remove();openCacheDiscoverAlbum('genre_dive_tracks',${i})">
<div class="genre-dive-track-num">${i + 1}</div>
<div class="genre-dive-track-img" style="${t.image_url ? `background-image:url('${_esc(t.image_url)}')` : ''}">
@ -51081,9 +51116,10 @@ async function openGenreDeepDive(genre) {
<div class="genre-dive-track-name">${_esc(t.name)}</div>
<div class="genre-dive-track-artist">${_esc(t.artist_name)}${t.album_name ? ' · ' + _esc(t.album_name) : ''}</div>
</div>
<span class="genre-dive-src-dot genre-dive-src-${tSrcClass}" style="flex-shrink:0"></span>
<div class="genre-dive-track-duration">${_fmtDur(t.duration_ms)}</div>
</div>
`).join('')}
`}).join('')}
</div>
</div>`;
}

View file

@ -26923,6 +26923,32 @@ body {
border-radius: 6px;
}
/* Source indicator dots */
.genre-dive-src-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
}
.genre-dive-src-spotify { background: #1db954; }
.genre-dive-src-itunes { background: #fc3c44; }
.genre-dive-src-deezer { background: #a238ff; }
.genre-dive-artist .genre-dive-src-dot {
position: absolute;
top: 4px;
right: 4px;
width: 8px;
height: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
}
.genre-dive-artist {
position: relative;
}
/* Tracks */
.genre-dive-tracks {
display: flex;