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:
parent
a3309d28a6
commit
e08462a002
5 changed files with 520 additions and 105 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
158
web_server.py
158
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
|
||||
|
|
|
|||
|
|
@ -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>`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue