From e08462a002edb4c5dfc454a40a736ed3af6c1354 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Tue, 24 Mar 2026 19:36:11 -0700 Subject: [PATCH] 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) --- core/metadata_cache.py | 356 ++++++++++++++++++++++++++++----- core/personalized_playlists.py | 9 +- web_server.py | 158 ++++++++++++--- webui/static/script.js | 76 +++++-- webui/static/style.css | 26 +++ 5 files changed, 520 insertions(+), 105 deletions(-) diff --git a/core/metadata_cache.py b/core/metadata_cache.py index 8c0e3e72..b6243893 100644 --- a/core/metadata_cache.py +++ b/core/metadata_cache.py @@ -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 diff --git a/core/personalized_playlists.py b/core/personalized_playlists.py index 42775730..7ee2e598 100644 --- a/core/personalized_playlists.py +++ b/core/personalized_playlists.py @@ -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) diff --git a/web_server.py b/web_server.py index c47892c0..1a783056 100644 --- a/web_server.py +++ b/web_server.py @@ -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 diff --git a/webui/static/script.js b/webui/static/script.js index 2326a14c..b8b81718 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -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 = `