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() _cache_lock = threading.Lock()
_backfill_done = False
def get_metadata_cache(): def get_metadata_cache():
"""Get or create the singleton MetadataCache instance.""" """Get or create the singleton MetadataCache instance."""
global _cache_instance global _cache_instance, _backfill_done
if _cache_instance is None: if _cache_instance is None:
with _cache_lock: with _cache_lock:
if _cache_instance is None: if _cache_instance is None:
_cache_instance = MetadataCache() _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 return _cache_instance
@ -663,13 +673,28 @@ class MetadataCache:
return fields 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: def _extract_deezer_fields(self, entity_type: str, data: dict) -> dict:
"""Extract fields from Deezer API response.""" """Extract fields from Deezer API response."""
fields = {} fields = {}
if entity_type == 'artist': if entity_type == 'artist':
fields['name'] = data.get('name', '') 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['popularity'] = 0
fields['followers'] = data.get('nb_fan', 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') 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['album_type'] = record_type if record_type in ('single', 'ep', 'album') else 'album'
fields['label'] = data.get('label', '') fields['label'] = data.get('label', '')
fields['image_url'] = data.get('cover_xl') or data.get('cover_big') or data.get('cover_medium') 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 = {} urls = {}
if data.get('link'): if data.get('link'):
urls['deezer'] = data['link'] urls['deezer'] = data['link']
@ -842,7 +878,7 @@ class MetadataCache:
logger.error(f"Undiscovered albums error: {e}") logger.error(f"Undiscovered albums error: {e}")
return [] 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.""" """Find recently released cached albums matching user's genres."""
if not user_genres: if not user_genres:
return [] return []
@ -854,7 +890,11 @@ class MetadataCache:
genre_clauses = ' OR '.join(['genres LIKE ?' for _ in user_genres]) genre_clauses = ' OR '.join(['genres LIKE ?' for _ in user_genres])
params = [f'%{g}%' for g in user_genres] params = [f'%{g}%' for g in user_genres]
source_filter = "" 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 = ?" source_filter = "AND source = ?"
params.append(source) params.append(source)
cursor.execute(f""" cursor.execute(f"""
@ -941,7 +981,7 @@ class MetadataCache:
logger.error(f"Deep cuts error: {e}") logger.error(f"Deep cuts error: {e}")
return [] 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, """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.""" so we find artists with matching genres then fetch their cached albums and tracks."""
if not genre: if not genre:
@ -952,11 +992,18 @@ class MetadataCache:
try: try:
cursor = conn.cursor() cursor = conn.cursor()
# Step 1: Find artists with this genre # Build source filter for allowed sources
params = [f'%{genre}%'] source_filter = ""
source_filter = "AND source = ?" if source else "" source_params = []
if source: if sources:
params.append(source) 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""" cursor.execute(f"""
SELECT name, image_url, popularity, followers, entity_id, source, genres SELECT name, image_url, popularity, followers, entity_id, source, genres
FROM metadata_cache_entities FROM metadata_cache_entities
@ -965,61 +1012,129 @@ class MetadataCache:
{source_filter} {source_filter}
ORDER BY COALESCE(followers, 0) DESC, COALESCE(popularity, 0) DESC ORDER BY COALESCE(followers, 0) DESC, COALESCE(popularity, 0) DESC
LIMIT ? LIMIT ?
""", params + [artist_limit]) """, params + [artist_limit * 3])
artists = [dict(r) for r in cursor.fetchall()] # 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 = [] albums = []
tracks = [] tracks = []
original_names = [a['name'] for a in artists if a.get('name')]
if artist_names: if original_names:
name_placeholders = ','.join(['?'] * len(artist_names)) name_placeholders = ','.join(['?'] * len(original_names))
src_filter = "AND source = ?" if source else ""
# Step 2: Find albums by those artists # Step 2: Find albums by those artists (COLLATE NOCASE on column leverages index)
album_params = list(artist_names) album_params = list(original_names) + source_params
if source:
album_params.append(source)
cursor.execute(f""" cursor.execute(f"""
SELECT name, artist_name, image_url, popularity, release_date, label, 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 FROM metadata_cache_entities
WHERE entity_type = 'album' WHERE entity_type = 'album'
AND LOWER(artist_name) IN ({name_placeholders}) AND artist_name COLLATE NOCASE IN ({name_placeholders})
{src_filter} {source_filter}
ORDER BY COALESCE(popularity, 0) DESC, access_count DESC ORDER BY COALESCE(popularity, 0) DESC, RANDOM()
LIMIT ? LIMIT ?
""", album_params + [album_limit]) """, album_params + [album_limit])
albums = [dict(r) for r in cursor.fetchall()] albums = [dict(r) for r in cursor.fetchall()]
# Step 3: Find tracks by those artists # Step 3: Find tracks by those artists
track_params = list(artist_names) track_params = list(original_names) + source_params
if source:
track_params.append(source)
cursor.execute(f""" cursor.execute(f"""
SELECT name, artist_name, image_url, popularity, album_name, SELECT name, artist_name, image_url, popularity, album_name,
source, entity_id, duration_ms, album_id source, entity_id, duration_ms, album_id
FROM metadata_cache_entities FROM metadata_cache_entities
WHERE entity_type = 'track' WHERE entity_type = 'track'
AND LOWER(artist_name) IN ({name_placeholders}) AND artist_name COLLATE NOCASE IN ({name_placeholders})
{src_filter} {source_filter}
ORDER BY COALESCE(popularity, 0) DESC, access_count DESC ORDER BY COALESCE(popularity, 0) DESC, RANDOM()
LIMIT ? LIMIT ?
""", track_params + [track_limit]) """, track_params + [track_limit])
tracks = [dict(r) for r in cursor.fetchall()] 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 = {} related_genres = {}
genre_lower = genre.lower()
# From artist genre data (Spotify/iTunes — multiple genres per artist)
for artist in artists: for artist in artists:
try: try:
artist_genres = json.loads(artist.get('genres', '[]')) artist_genres = json.loads(artist.get('genres', '[]'))
if isinstance(artist_genres, list): if isinstance(artist_genres, list):
for g in artist_genres: for g in artist_genres:
g_lower = g.strip().lower() 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 related_genres[g_lower] = related_genres.get(g_lower, 0) + 1
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
pass 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( related = sorted(
[{'genre': g.title(), 'count': c} for g, c in related_genres.items()], [{'genre': g.title(), 'count': c} for g, c in related_genres.items()],
key=lambda x: x['count'], reverse=True key=lambda x: x['count'], reverse=True
@ -1041,16 +1156,20 @@ class MetadataCache:
for album in albums: for album in albums:
album['in_library'] = (album['name'].lower().strip(), album['artist_name'].lower().strip()) in lib_set album['in_library'] = (album['name'].lower().strip(), album['artist_name'].lower().strip()) in lib_set
# Step 6: Resolve library artist IDs for navigation # Step 6: Resolve library artist IDs for navigation (batched)
for artist in artists: if artists:
lib_name_placeholders = ','.join(['LOWER(?)'] * len(artists))
lib_name_params = [a['name'] for a in artists]
try: try:
cursor.execute(""" cursor.execute(f"""
SELECT id FROM artists WHERE LOWER(name) = LOWER(?) LIMIT 1 SELECT id, LOWER(name) as lname FROM artists
""", (artist['name'],)) WHERE LOWER(name) IN ({lib_name_placeholders})
row = cursor.fetchone() """, lib_name_params)
artist['library_id'] = row['id'] if row else None lib_id_map = {r['lname']: r['id'] for r in cursor.fetchall()}
except Exception: 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} return {'artists': artists, 'albums': albums, 'tracks': tracks, 'related_genres': related}
finally: finally:
@ -1059,8 +1178,21 @@ class MetadataCache:
logger.error(f"Genre deep dive error: {e}") logger.error(f"Genre deep dive error: {e}")
return {'artists': [], 'albums': [], 'tracks': []} return {'artists': [], 'albums': [], 'tracks': []}
def get_genre_explorer(self, user_genres_set, source=None): _genre_explorer_cache = {} # {source: (timestamp, results)}
"""Aggregate genres from cached artists, highlight unexplored ones.""" _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: try:
db = self._get_db() db = self._get_db()
conn = db._get_connection() conn = db._get_connection()
@ -1068,38 +1200,166 @@ class MetadataCache:
cursor = conn.cursor() cursor = conn.cursor()
params = [] params = []
source_filter = "" 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 = ?" source_filter = "AND source = ?"
params.append(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""" cursor.execute(f"""
SELECT genres FROM metadata_cache_entities SELECT name, genres FROM metadata_cache_entities
WHERE entity_type = 'artist' WHERE entity_type = 'artist'
AND genres IS NOT NULL AND genres != '' AND genres != '[]' AND genres IS NOT NULL AND genres != '' AND genres != '[]'
{source_filter} {source_filter}
""", params) """, params)
genre_counts = {}
for row in cursor.fetchall(): for row in cursor.fetchall():
try: try:
parsed = json.loads(row['genres']) parsed = json.loads(row['genres'])
if isinstance(parsed, list): if isinstance(parsed, list):
artist_key = (row['name'] or '').lower()
for g in parsed: for g in parsed:
g_lower = g.strip().lower() g_lower = g.strip().lower()
if g_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): except (json.JSONDecodeError, TypeError):
pass pass
user_lower = {g.lower() for g in user_genres_set} if user_genres_set else set() user_lower = {g.lower() for g in user_genres_set} if user_genres_set else set()
results = [] 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({ results.append({
'genre': genre.title(), 'genre': genre.title(),
'artist_count': count, 'artist_count': len(artists_set),
'explored': genre in user_lower, 'explored': genre in user_lower,
}) })
# Cache for subsequent requests
self._genre_explorer_cache[cache_key] = (time.time(), results)
return results return results
finally: finally:
conn.close() conn.close()
except Exception as e: except Exception as e:
logger.error(f"Genre explorer error: {e}") logger.error(f"Genre explorer error: {e}")
return [] 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: with self.database._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
# Get all tracks with genres from discovery pool (source-agnostic — # Get all tracks with genres from discovery pool, filtered by source
# genres are artist metadata, not tied to a specific metadata source)
cursor.execute(""" cursor.execute("""
SELECT artist_genres SELECT artist_genres
FROM discovery_pool FROM discovery_pool
WHERE artist_genres IS NOT NULL WHERE artist_genres IS NOT NULL AND source = ?
""") """, (active_source,))
rows = cursor.fetchall() rows = cursor.fetchall()
if not rows: 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 [] return []
# Count tracks per PARENT genre (consolidated) # 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}") logger.warning(f"Hydrabase album_tracks failed for '{album_id}', falling back to {source}: {e}")
if source == 'spotify': if source == 'spotify':
if not spotify_client or not spotify_client.is_authenticated(): album_data = spotify_client.get_album(album_id) if spotify_client and spotify_client.is_authenticated() else None
return jsonify({"error": "Spotify not authenticated."}), 401
album_data = spotify_client.get_album(album_id) if album_data:
if not album_data: tracks = album_data.get('tracks', {}).get('items', [])
return jsonify({"error": "Album not found"}), 404 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', []) return jsonify({
if not tracks: 'id': album_data['id'],
tracks_data = spotify_client.get_album_tracks(album_id) 'name': album_data['name'],
if tracks_data and 'items' in tracks_data: 'artists': album_data.get('artists', []),
tracks = tracks_data['items'] '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({ # Spotify failed (not authenticated, album removed, rate limited) — try fallback
'id': album_data['id'], album_name = request.args.get('name', '')
'name': album_data['name'], album_artist = request.args.get('artist', '')
'artists': album_data.get('artists', []), fallback = _get_metadata_fallback_client()
'release_date': album_data.get('release_date', ''), if fallback and (album_name or album_artist):
'total_tracks': album_data.get('total_tracks', 0), clean_name = album_name.replace(' - Single', '').replace(' - EP', '').replace(' (Single)', '').strip()
'album_type': album_data.get('album_type', 'album'), search_query = f"{album_artist} {clean_name}" if album_artist else clean_name
'images': album_data.get('images', []), try:
'tracks': tracks, results = fallback.search_albums(search_query, limit=3)
'source': 'spotify' 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'): 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) 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: if not album_data:
return jsonify({"error": "Album not found"}), 404 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'), 'album_type': album_data.get('album_type', 'album'),
'images': album_data.get('images', []), 'images': album_data.get('images', []),
'tracks': tracks, 'tracks': tracks,
'source': source 'source': fallback_source,
}) })
else: else:
@ -35801,12 +35861,12 @@ def get_discover_genre_new_releases():
try: try:
database = get_database() database = get_database()
cache = get_metadata_cache() cache = get_metadata_cache()
active_source = _get_active_discovery_source()
genres = database.get_genre_breakdown('all') genres = database.get_genre_breakdown('all')
genre_names = [g['genre'] for g in (genres or [])[:10] if g.get('genre')] genre_names = [g['genre'] for g in (genres or [])[:10] if g.get('genre')]
if not genre_names: if not genre_names:
return jsonify({'success': True, 'albums': []}) 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}) return jsonify({'success': True, 'albums': albums})
except Exception as e: except Exception as e:
logger.error(f"Genre new releases endpoint error: {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}") logger.error(f"Deep cuts endpoint error: {e}")
return jsonify({'success': True, 'tracks': []}) 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']) @app.route('/api/discover/genre-explorer', methods=['GET'])
def get_discover_genre_explorer(): def get_discover_genre_explorer():
"""Genre landscape from cached artists — highlights unexplored genres.""" """Genre landscape from cached artists — highlights unexplored genres."""
try: try:
database = get_database() database = get_database()
cache = get_metadata_cache() cache = get_metadata_cache()
active_source = _get_active_discovery_source()
genres = database.get_genre_breakdown('all') genres = database.get_genre_breakdown('all')
user_genres = {g['genre'] for g in (genres or []) if g.get('genre')} 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}) return jsonify({'success': True, 'genres': data})
except Exception as e: except Exception as e:
logger.error(f"Genre explorer endpoint error: {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() genre = request.args.get('genre', '').strip()
if not genre: if not genre:
return jsonify({'success': False, 'error': 'genre required'}), 400 return jsonify({'success': False, 'error': 'genre required'}), 400
active_source = _get_active_discovery_source()
cache = get_metadata_cache() 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}) return jsonify({'success': True, **data})
except Exception as e: except Exception as e:
logger.error(f"Genre albums endpoint error: {e}") logger.error(f"Genre albums endpoint error: {e}")
@ -35895,18 +35964,43 @@ def resolve_cache_album():
database = get_database() database = get_database()
with database._get_connection() as conn: with database._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
# Prefer active source, fall back to any source # Strategy 1: exact match, prefer active source
cursor.execute(""" cursor.execute("""
SELECT entity_id, source FROM metadata_cache_entities SELECT entity_id, source FROM metadata_cache_entities
WHERE entity_type = 'album' WHERE entity_type = 'album'
AND LOWER(name) = LOWER(?) AND name COLLATE NOCASE = ? COLLATE NOCASE
AND LOWER(artist_name) = LOWER(?) AND artist_name COLLATE NOCASE = ? COLLATE NOCASE
ORDER BY CASE WHEN source = ? THEN 0 ELSE 1 END ORDER BY CASE WHEN source = ? THEN 0 ELSE 1 END
LIMIT 1 LIMIT 1
""", (name, artist, active_source)) """, (name, artist, active_source))
row = cursor.fetchone() row = cursor.fetchone()
if row: if row:
return jsonify({'success': True, 'entity_id': row['entity_id'], 'source': row['source']}) 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'}) return jsonify({'success': False, 'error': 'Album not found in cache'})
except Exception as e: except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500 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 // Deep cuts / genre dive tracks — find the real album by searching the cache
if (sectionKey === 'deep_cuts' || sectionKey === 'genre_dive_tracks') { if (sectionKey === 'deep_cuts' || sectionKey === 'genre_dive_tracks') {
document.getElementById('genre-deep-dive-modal')?.remove(); document.getElementById('genre-deep-dive-modal')?.remove();
const albumName = item.album_name || ''; const albumName = item.album_name || item.name || '';
const artistName = item.artist_name || ''; const artistName = item.artist_name || '';
if (!albumName || !artistName) { const trackAlbumId = item.album_id || '';
showToast('No album data available for this track', 'error'); const trackSource = item.source || source;
if (!artistName) {
showToast('No artist data available for this track', 'error');
return; return;
} }
// Search for the album by name+artist to get the real album entity_id
showLoadingOverlay(`Loading ${albumName}...`); showLoadingOverlay(`Loading ${albumName}...`);
try { try {
const searchResp = await fetch(`/api/discover/resolve-cache-album?name=${encodeURIComponent(albumName)}&artist=${encodeURIComponent(artistName)}`); let resolvedSource = trackSource;
if (!searchResp.ok) throw new Error('Album not found in cache'); let resolvedId = trackAlbumId;
const searchData = await searchResp.json(); let response;
if (!searchData.success || !searchData.entity_id) throw new Error('Album not found in cache');
const resolvedSource = searchData.source || source; // If we have an album_id, use it directly
const resolvedId = searchData.entity_id; 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 }); // Fallback: resolve by name+artist if no album_id or direct fetch failed
const response = await fetch(`/api/discover/album/${resolvedSource}/${resolvedId}?${_params}`); if (!trackAlbumId || (response && !response.ok)) {
if (!response.ok) throw new Error('Failed to fetch album tracks'); 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(); const albumData = await response.json();
if (!albumData.tracks || albumData.tracks.length === 0) throw new Error('No tracks found'); 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'); const container = document.getElementById('discover-bylt-sections') || document.querySelector('.discover-container');
if (!container) return; if (!container) return;
let section = document.getElementById(id); let section = document.getElementById(id);
@ -50893,7 +50910,17 @@ function _insertCacheSection(id, title, subtitle, html) {
section = document.createElement('div'); section = document.createElement('div');
section.id = id; section.id = id;
section.className = 'discover-section'; 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 = ` section.innerHTML = `
<div class="discover-section-header"> <div class="discover-section-header">
@ -50973,7 +51000,7 @@ async function loadCacheGenreExplorer() {
</div> </div>
`).join('')}</div>`; `).join('')}</div>`;
_insertCacheSection('cache-genre-explorer', _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); } } catch (e) { console.debug('Cache genre explorer:', e); }
} }
@ -51045,17 +51072,23 @@ async function openGenreDeepDive(genre) {
</div>`; </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) { if (data.artists && data.artists.length) {
html += `<div class="genre-dive-section"> html += `<div class="genre-dive-section">
<h3 class="genre-dive-section-title"><span class="genre-dive-icon">🎤</span> Artists in ${_esc(genre)}</h3> <h3 class="genre-dive-section-title"><span class="genre-dive-icon">🎤</span> Artists in ${_esc(genre)}</h3>
<div class="genre-dive-artists"> <div class="genre-dive-artists">
${data.artists.map(a => { ${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}> return `<div class="genre-dive-artist" ${clickAction}>
<div class="genre-dive-artist-img" style="${a.image_url ? `background-image:url('${_esc(a.image_url)}')` : ''}"> <div class="genre-dive-artist-img" style="${a.image_url ? `background-image:url('${_esc(a.image_url)}')` : ''}">
${!a.image_url ? '<span>🎤</span>' : ''} ${!a.image_url ? '<span>🎤</span>' : ''}
</div> </div>
<span class="genre-dive-src-dot genre-dive-src-${srcClass}"></span>
<div class="genre-dive-artist-name">${_esc(a.name)}</div> <div class="genre-dive-artist-name">${_esc(a.name)}</div>
${a.followers ? `<div class="genre-dive-artist-meta">${_fmtNum(a.followers)} followers</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>' : ''} ${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"> html += `<div class="genre-dive-section">
<h3 class="genre-dive-section-title"><span class="genre-dive-icon">🎵</span> Popular Tracks</h3> <h3 class="genre-dive-section-title"><span class="genre-dive-icon">🎵</span> Popular Tracks</h3>
<div class="genre-dive-tracks"> <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" 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-num">${i + 1}</div>
<div class="genre-dive-track-img" style="${t.image_url ? `background-image:url('${_esc(t.image_url)}')` : ''}"> <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-name">${_esc(t.name)}</div>
<div class="genre-dive-track-artist">${_esc(t.artist_name)}${t.album_name ? ' · ' + _esc(t.album_name) : ''}</div> <div class="genre-dive-track-artist">${_esc(t.artist_name)}${t.album_name ? ' · ' + _esc(t.album_name) : ''}</div>
</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 class="genre-dive-track-duration">${_fmtDur(t.duration_ms)}</div>
</div> </div>
`).join('')} `}).join('')}
</div> </div>
</div>`; </div>`;
} }

View file

@ -26923,6 +26923,32 @@ body {
border-radius: 6px; 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 */ /* Tracks */
.genre-dive-tracks { .genre-dive-tracks {
display: flex; display: flex;