From e3d70da55a11f5418e927a1af973f5535578e3c8 Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Sun, 22 Mar 2026 21:35:18 -0700
Subject: [PATCH] Add DB storage visualization + cache-powered discovery
sections + Genre Deep Dive
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Stats page: database storage donut chart with per-table breakdown and total size
- Discover page: 5 new sections mined from metadata cache (zero API calls):
Undiscovered Albums, New In Your Genres, From Your Labels, Deep Cuts, Genre Explorer
- Genre Deep Dive modal: artists (clickable → artist page), popular tracks,
albums with download flow, related genre pills, in-library badges
- All cache queries filtered by active metadata source (Spotify/iTunes/Deezer)
- Stale cache entries (404) gracefully fall back to name+artist resolution
- Album cards show "In Library" badge, artist avatars scaled by prominence
---
core/metadata_cache.py | 302 +++++++++++++++++++
database/music_database.py | 53 ++++
web_server.py | 155 ++++++++++
webui/index.html | 12 +
webui/static/mobile.css | 25 ++
webui/static/script.js | 476 +++++++++++++++++++++++++++++-
webui/static/style.css | 588 +++++++++++++++++++++++++++++++++++++
7 files changed, 1610 insertions(+), 1 deletion(-)
diff --git a/core/metadata_cache.py b/core/metadata_cache.py
index 049673d4..dcb317a4 100644
--- a/core/metadata_cache.py
+++ b/core/metadata_cache.py
@@ -806,3 +806,305 @@ class MetadataCache:
fields['external_urls'] = json.dumps(urls)
return fields
+
+ # ─── Discovery Methods (mine cache for recommendations) ──────
+
+ def get_undiscovered_albums(self, top_artist_names, library_album_keys, source=None, limit=20):
+ """Find popular cached albums by user's top artists that aren't in their library."""
+ if not top_artist_names:
+ return []
+ try:
+ db = self._get_db()
+ conn = db._get_connection()
+ try:
+ cursor = conn.cursor()
+ placeholders = ','.join(['?'] * len(top_artist_names))
+ params = [a.lower() for a in top_artist_names]
+ source_filter = "AND source = ?" if source else ""
+ if source:
+ params.append(source)
+ cursor.execute(f"""
+ SELECT name, artist_name, image_url, popularity, release_date, label,
+ source, entity_id, album_type, total_tracks
+ FROM metadata_cache_entities
+ WHERE entity_type = 'album'
+ AND LOWER(artist_name) IN ({placeholders})
+ {source_filter}
+ ORDER BY COALESCE(popularity, 0) DESC, access_count DESC
+ LIMIT 200
+ """, params)
+ results = []
+ for row in cursor.fetchall():
+ key = (row['name'].lower().strip(), row['artist_name'].lower().strip())
+ if key not in library_album_keys:
+ results.append(dict(row))
+ if len(results) >= limit:
+ break
+ return results
+ finally:
+ conn.close()
+ except Exception as e:
+ logger.error(f"Undiscovered albums error: {e}")
+ return []
+
+ def get_genre_new_releases(self, user_genres, source=None, limit=20):
+ """Find recently released cached albums matching user's genres."""
+ if not user_genres:
+ return []
+ try:
+ db = self._get_db()
+ conn = db._get_connection()
+ try:
+ cursor = conn.cursor()
+ genre_clauses = ' OR '.join(['genres LIKE ?' for _ in user_genres])
+ params = [f'%{g}%' for g in user_genres]
+ source_filter = ""
+ if source:
+ source_filter = "AND source = ?"
+ params.append(source)
+ cursor.execute(f"""
+ SELECT name, artist_name, image_url, popularity, release_date, genres,
+ source, entity_id, album_type, total_tracks
+ FROM metadata_cache_entities
+ WHERE entity_type = 'album'
+ AND release_date != '' AND release_date IS NOT NULL
+ AND release_date >= date('now', '-180 days')
+ AND ({genre_clauses})
+ {source_filter}
+ ORDER BY release_date DESC, COALESCE(popularity, 0) DESC
+ LIMIT ?
+ """, params + [limit])
+ return [dict(r) for r in cursor.fetchall()]
+ finally:
+ conn.close()
+ except Exception as e:
+ logger.error(f"Genre new releases error: {e}")
+ return []
+
+ def get_label_explorer(self, user_labels, source=None, limit=20):
+ """Find popular cached albums from labels the user already has."""
+ if not user_labels:
+ return []
+ try:
+ db = self._get_db()
+ conn = db._get_connection()
+ try:
+ cursor = conn.cursor()
+ placeholders = ','.join(['?'] * len(user_labels))
+ params = list(user_labels)
+ source_filter = ""
+ if source:
+ source_filter = "AND source = ?"
+ params.append(source)
+ cursor.execute(f"""
+ SELECT name, artist_name, image_url, popularity, release_date, label,
+ source, entity_id, album_type, total_tracks
+ FROM metadata_cache_entities
+ WHERE entity_type = 'album'
+ AND label IN ({placeholders})
+ {source_filter}
+ ORDER BY COALESCE(popularity, 0) DESC, access_count DESC
+ LIMIT ?
+ """, params + [limit])
+ return [dict(r) for r in cursor.fetchall()]
+ finally:
+ conn.close()
+ except Exception as e:
+ logger.error(f"Label explorer error: {e}")
+ return []
+
+ def get_deep_cuts(self, artist_names, source=None, popularity_cap=30, limit=20):
+ """Find low-popularity tracks from artists the user listens to."""
+ if not artist_names:
+ return []
+ try:
+ db = self._get_db()
+ conn = db._get_connection()
+ try:
+ cursor = conn.cursor()
+ placeholders = ','.join(['?'] * len(artist_names))
+ params = [a.lower() for a in artist_names]
+ source_filter = ""
+ if source:
+ source_filter = "AND source = ?"
+ params.append(source)
+ 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 ({placeholders})
+ AND (popularity IS NULL OR popularity <= ?)
+ {source_filter}
+ ORDER BY COALESCE(popularity, 50) ASC, access_count DESC
+ LIMIT ?
+ """, params + [popularity_cap, limit])
+ return [dict(r) for r in cursor.fetchall()]
+ finally:
+ conn.close()
+ except Exception as e:
+ 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):
+ """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:
+ return {'artists': [], 'albums': [], 'tracks': []}
+ try:
+ db = self._get_db()
+ conn = db._get_connection()
+ 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)
+ cursor.execute(f"""
+ SELECT name, image_url, popularity, followers, entity_id, source, genres
+ FROM metadata_cache_entities
+ WHERE entity_type = 'artist'
+ AND genres LIKE ?
+ {source_filter}
+ ORDER BY COALESCE(followers, 0) DESC, COALESCE(popularity, 0) DESC
+ LIMIT ?
+ """, params + [artist_limit])
+ artists = [dict(r) for r in cursor.fetchall()]
+
+ artist_names = [a['name'].lower() for a in artists if a.get('name')]
+ albums = []
+ tracks = []
+
+ if artist_names:
+ name_placeholders = ','.join(['?'] * len(artist_names))
+ src_filter = "AND source = ?" if source else ""
+
+ # Step 2: Find albums by those artists
+ album_params = list(artist_names)
+ if source:
+ album_params.append(source)
+ cursor.execute(f"""
+ SELECT name, artist_name, image_url, popularity, release_date, label,
+ source, entity_id, album_type, total_tracks
+ 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
+ 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)
+ 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
+ LIMIT ?
+ """, track_params + [track_limit])
+ tracks = [dict(r) for r in cursor.fetchall()]
+
+ # Step 4: Find related genres (co-occurring on same artists)
+ related_genres = {}
+ 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():
+ 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
+ )[:12]
+
+ # Step 5: Check which albums are in the library
+ if albums:
+ album_keys = [(a['name'].lower().strip(), a['artist_name'].lower().strip()) for a in albums]
+ or_clauses = ' OR '.join(['(LOWER(al.title) = ? AND LOWER(ar.name) = ?)' for _ in album_keys])
+ lib_params = []
+ for k in album_keys:
+ lib_params.extend(k)
+ cursor.execute(f"""
+ SELECT LOWER(al.title), LOWER(ar.name) FROM albums al
+ JOIN artists ar ON ar.id = al.artist_id
+ WHERE {or_clauses}
+ """, lib_params)
+ lib_set = {(r[0].strip(), r[1].strip()) for r in cursor.fetchall()}
+ 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:
+ 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
+ except Exception:
+ artist['library_id'] = None
+
+ return {'artists': artists, 'albums': albums, 'tracks': tracks, 'related_genres': related}
+ finally:
+ conn.close()
+ except Exception as e:
+ 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."""
+ try:
+ db = self._get_db()
+ conn = db._get_connection()
+ try:
+ cursor = conn.cursor()
+ params = []
+ source_filter = ""
+ if source:
+ source_filter = "AND source = ?"
+ params.append(source)
+ cursor.execute(f"""
+ SELECT 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):
+ for g in parsed:
+ g_lower = g.strip().lower()
+ if g_lower:
+ genre_counts[g_lower] = genre_counts.get(g_lower, 0) + 1
+ 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]:
+ results.append({
+ 'genre': genre.title(),
+ 'artist_count': count,
+ 'explored': genre in user_lower,
+ })
+ return results
+ finally:
+ conn.close()
+ except Exception as e:
+ logger.error(f"Genre explorer error: {e}")
+ return []
diff --git a/database/music_database.py b/database/music_database.py
index ab953c3f..c79d0380 100644
--- a/database/music_database.py
+++ b/database/music_database.py
@@ -3035,6 +3035,59 @@ class MusicDatabase:
if conn:
conn.close()
+ def get_db_storage_stats(self):
+ """Get database storage breakdown by table."""
+ import os
+ conn = None
+ try:
+ # Total file size
+ total_size = 0
+ try:
+ total_size = os.path.getsize(str(self.database_path))
+ except Exception:
+ pass
+
+ conn = self._get_connection()
+ cursor = conn.cursor()
+
+ # Try dbstat first (real byte sizes)
+ tables = []
+ method = 'row_count'
+ try:
+ cursor.execute("""
+ SELECT name, SUM(pgsize) as size
+ FROM dbstat
+ WHERE name IN (SELECT name FROM sqlite_master WHERE type='table')
+ GROUP BY name
+ ORDER BY size DESC
+ """)
+ tables = [{'name': r[0], 'size': r[1]} for r in cursor.fetchall()]
+ method = 'dbstat'
+ except Exception:
+ # Fallback: row counts per table
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
+ for row in cursor.fetchall():
+ tbl = row[0]
+ try:
+ cursor.execute(f"SELECT COUNT(*) FROM [{tbl}]")
+ count = cursor.fetchone()[0]
+ tables.append({'name': tbl, 'size': count})
+ except Exception:
+ pass
+ tables.sort(key=lambda x: x['size'], reverse=True)
+
+ return {
+ 'tables': tables,
+ 'total_file_size': total_size,
+ 'method': method,
+ }
+ except Exception as e:
+ logger.error(f"Error getting db storage stats: {e}")
+ return {'tables': [], 'total_file_size': 0, 'method': 'error'}
+ finally:
+ if conn:
+ conn.close()
+
@staticmethod
def _listening_time_filter(time_range, alias=''):
"""Build a WHERE clause for time-range filtering."""
diff --git a/web_server.py b/web_server.py
index c325c6dd..83932213 100644
--- a/web_server.py
+++ b/web_server.py
@@ -35286,6 +35286,151 @@ def get_discover_because_you_listen_to():
logger.error(f"Error getting BYLT: {e}")
return jsonify({'success': True, 'sections': []})
+@app.route('/api/discover/undiscovered-albums', methods=['GET'])
+def get_discover_undiscovered_albums():
+ """Albums by artists you listen to that aren't in your library — from cache."""
+ try:
+ database = get_database()
+ cache = get_metadata_cache()
+ active_source = _get_active_discovery_source()
+
+ # Get top played artists
+ top = database.get_top_artists('all', 25)
+ artist_names = [a['name'] for a in top if a.get('name')]
+ if not artist_names:
+ return jsonify({'success': True, 'albums': []})
+
+ # Build library album keys for exclusion
+ with database._get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute("""
+ SELECT LOWER(al.title), LOWER(ar.name)
+ FROM albums al JOIN artists ar ON ar.id = al.artist_id
+ """)
+ library_keys = {(r[0].strip(), r[1].strip()) for r in cursor.fetchall()}
+
+ albums = cache.get_undiscovered_albums(artist_names, library_keys, source=active_source, limit=20)
+ return jsonify({'success': True, 'albums': albums})
+ except Exception as e:
+ logger.error(f"Undiscovered albums endpoint error: {e}")
+ return jsonify({'success': True, 'albums': []})
+
+@app.route('/api/discover/genre-new-releases', methods=['GET'])
+def get_discover_genre_new_releases():
+ """Recent releases matching your top genres — from cache."""
+ 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)
+ return jsonify({'success': True, 'albums': albums})
+ except Exception as e:
+ logger.error(f"Genre new releases endpoint error: {e}")
+ return jsonify({'success': True, 'albums': []})
+
+@app.route('/api/discover/label-explorer', methods=['GET'])
+def get_discover_label_explorer():
+ """Popular albums from labels in your library — from cache."""
+ try:
+ database = get_database()
+ cache = get_metadata_cache()
+ with database._get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute("""
+ SELECT DISTINCT label FROM albums
+ WHERE label IS NOT NULL AND label != ''
+ LIMIT 30
+ """)
+ labels = {r[0] for r in cursor.fetchall()}
+ active_source = _get_active_discovery_source()
+ if not labels:
+ return jsonify({'success': True, 'albums': [], 'labels': []})
+ albums = cache.get_label_explorer(labels, source=active_source, limit=20)
+ return jsonify({'success': True, 'albums': albums, 'labels': sorted(labels)})
+ except Exception as e:
+ logger.error(f"Label explorer endpoint error: {e}")
+ return jsonify({'success': True, 'albums': [], 'labels': []})
+
+@app.route('/api/discover/deep-cuts', methods=['GET'])
+def get_discover_deep_cuts():
+ """Low-popularity tracks from artists you listen to — from cache."""
+ try:
+ database = get_database()
+ cache = get_metadata_cache()
+ top = database.get_top_artists('all', 15)
+ artist_names = [a['name'] for a in top if a.get('name')]
+ active_source = _get_active_discovery_source()
+ if not artist_names:
+ return jsonify({'success': True, 'tracks': []})
+ tracks = cache.get_deep_cuts(artist_names, source=active_source, popularity_cap=30, limit=20)
+ return jsonify({'success': True, 'tracks': tracks})
+ except Exception as e:
+ logger.error(f"Deep cuts endpoint error: {e}")
+ return jsonify({'success': True, 'tracks': []})
+
+@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)
+ return jsonify({'success': True, 'genres': data})
+ except Exception as e:
+ logger.error(f"Genre explorer endpoint error: {e}")
+ return jsonify({'success': True, 'genres': []})
+
+@app.route('/api/discover/genre-deep-dive', methods=['GET'])
+def get_discover_genre_deep_dive():
+ """Get artists + albums for a genre — from cache."""
+ try:
+ 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)
+ return jsonify({'success': True, **data})
+ except Exception as e:
+ logger.error(f"Genre albums endpoint error: {e}")
+ return jsonify({'success': True, 'albums': []})
+
+@app.route('/api/discover/resolve-cache-album', methods=['GET'])
+def resolve_cache_album():
+ """Look up a real album entity in the cache by name+artist (avoids playlist ID confusion)."""
+ try:
+ name = request.args.get('name', '').strip()
+ artist = request.args.get('artist', '').strip()
+ if not name or not artist:
+ return jsonify({'success': False, 'error': 'name and artist required'}), 400
+
+ active_source = _get_active_discovery_source()
+ database = get_database()
+ with database._get_connection() as conn:
+ cursor = conn.cursor()
+ # Prefer active source, fall back to any source
+ cursor.execute("""
+ SELECT entity_id, source FROM metadata_cache_entities
+ WHERE entity_type = 'album'
+ AND LOWER(name) = LOWER(?)
+ AND LOWER(artist_name) = LOWER(?)
+ 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']})
+ return jsonify({'success': False, 'error': 'Album not found in cache'})
+ except Exception as e:
+ return jsonify({'success': False, 'error': str(e)}), 500
+
@app.route('/api/discover/weekly', methods=['GET'])
def get_discover_weekly():
"""Get discovery weekly playlist - curated selection that stays consistent until next update"""
@@ -41969,6 +42114,16 @@ def stats_library_health():
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
+@app.route('/api/stats/db-storage', methods=['GET'])
+def stats_db_storage():
+ """Get database storage breakdown by table."""
+ try:
+ database = get_database()
+ data = database.get_db_storage_stats()
+ return jsonify({'success': True, **data})
+ except Exception as e:
+ return jsonify({'success': False, 'error': str(e)}), 500
+
@app.route('/api/stats/recent', methods=['GET'])
def stats_recent():
"""Get recently played tracks."""
diff --git a/webui/index.html b/webui/index.html
index e2aa91aa..d4b65ff7 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -5140,6 +5140,18 @@
+
+
+
📊
diff --git a/webui/static/mobile.css b/webui/static/mobile.css
index 4a1f74fd..5d69b312 100644
--- a/webui/static/mobile.css
+++ b/webui/static/mobile.css
@@ -2865,6 +2865,31 @@
min-width: 28px;
}
+ /* DB storage chart */
+ .stats-db-storage-wrap {
+ flex-direction: column;
+ align-items: center;
+ gap: 12px;
+ }
+
+ .stats-db-chart-container {
+ width: 140px;
+ height: 140px;
+ }
+
+ .stats-db-chart-container canvas {
+ width: 140px !important;
+ height: 140px !important;
+ }
+
+ .stats-db-total-value {
+ font-size: 16px;
+ }
+
+ .stats-db-legend {
+ width: 100%;
+ }
+
/* Empty state */
.stats-empty-icon {
font-size: 40px;
diff --git a/webui/static/script.js b/webui/static/script.js
index 53c6db85..31c79afc 100644
--- a/webui/static/script.js
+++ b/webui/static/script.js
@@ -11174,7 +11174,7 @@ async function openDownloadMissingModalForYouTube(virtualPlaylistId, playlistNam
}
// CRITICAL FIX: Use album context for discover_album playlists
- const isDiscoverAlbum = virtualPlaylistId.startsWith('discover_album_') || virtualPlaylistId.startsWith('seasonal_album_') || virtualPlaylistId.startsWith('spotify_library_');
+ const isDiscoverAlbum = virtualPlaylistId.startsWith('discover_album_') || virtualPlaylistId.startsWith('discover_cache_') || virtualPlaylistId.startsWith('seasonal_album_') || virtualPlaylistId.startsWith('spotify_library_');
const heroContext = isDiscoverAlbum && album && artist ? {
type: 'album',
artist: {
@@ -46843,6 +46843,11 @@ async function loadDiscoverPage() {
loadDiscoveryShuffle(), // NEW: Discovery Shuffle
loadFamiliarFavorites(), // NEW: Familiar Favorites
loadBecauseYouListenTo(), // Personalized by listening stats
+ loadCacheUndiscoveredAlbums(), // From metadata cache
+ loadCacheGenreNewReleases(), // From metadata cache
+ loadCacheLabelExplorer(), // From metadata cache
+ loadCacheDeepCuts(), // From metadata cache
+ loadCacheGenreExplorer(), // From metadata cache
initializeListenBrainzTabs(), // ListenBrainz playlists (tabbed)
loadDecadeBrowserTabs(), // Time Machine (tabbed by decade)
loadGenreBrowserTabs(), // Browse by Genre (tabbed by genre)
@@ -50642,6 +50647,384 @@ async function loadBecauseYouListenTo() {
}
}
+// ===============================
+// CACHE DISCOVERY SECTIONS
+// ===============================
+
+// Global arrays for cache discovery click handlers
+let _cacheDiscoverData = {};
+
+function _cacheDiscoverCard(item, type, sectionKey, index) {
+ const _esc = (s) => (s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
+ const coverUrl = item.image_url || '/static/placeholder-album.png';
+ const title = item.name || '';
+ const subtitle = item.artist_name || '';
+ const meta = item.release_date ? item.release_date.substring(0, 10) : (item.label || '');
+ const onclick = `openCacheDiscoverAlbum('${sectionKey}',${index})`;
+ const libBadge = item.in_library ? '
In Library
' : '';
+ return `
+
+
})
+ ${libBadge}
+
+
+
${_esc(title)}
+
${_esc(subtitle)}
+ ${meta ? `
${_esc(meta)}
` : ''}
+
+
`;
+}
+
+async function openCacheDiscoverAlbum(sectionKey, index) {
+ const items = _cacheDiscoverData[sectionKey];
+ if (!items || !items[index]) return;
+ const item = items[index];
+ const source = item.source || 'spotify';
+ const albumId = item.entity_id;
+
+ // 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 artistName = item.artist_name || '';
+ if (!albumName || !artistName) {
+ showToast('No album 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');
+
+ const resolvedSource = searchData.source || source;
+ const resolvedId = searchData.entity_id;
+
+ 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');
+ const albumData = await response.json();
+ if (!albumData.tracks || albumData.tracks.length === 0) throw new Error('No tracks found');
+
+ const spotifyTracks = albumData.tracks.map(track => {
+ let artists = track.artists || albumData.artists || [{ name: artistName }];
+ if (Array.isArray(artists)) artists = artists.map(a => a.name || a);
+ return {
+ id: track.id, name: track.name, artists,
+ album: { id: albumData.id, name: albumData.name, album_type: albumData.album_type || 'album', total_tracks: albumData.total_tracks || 0, release_date: albumData.release_date || '', images: albumData.images || [] },
+ duration_ms: track.duration_ms || 0, track_number: track.track_number || 0,
+ };
+ });
+ const artistContext = { id: albumData.artists?.[0]?.id || '', name: artistName, source: resolvedSource };
+ const albumContext = { id: albumData.id, name: albumData.name, album_type: albumData.album_type || 'album', total_tracks: albumData.total_tracks || 0, release_date: albumData.release_date || '', images: albumData.images || [] };
+ await openDownloadMissingModalForYouTube(`discover_cache_${resolvedId}`, albumData.name, spotifyTracks, artistContext, albumContext);
+ hideLoadingOverlay();
+ } catch (error) {
+ console.error('Error opening deep cut album:', error);
+ showToast(`Failed to load album: ${error.message}`, 'error');
+ hideLoadingOverlay();
+ }
+ return;
+ }
+
+ if (!albumId) {
+ showToast('No album ID available', 'error');
+ return;
+ }
+
+ // Close genre deep dive modal if open
+ document.getElementById('genre-deep-dive-modal')?.remove();
+
+ showLoadingOverlay(`Loading ${item.name || 'album'}...`);
+ try {
+ const _params = new URLSearchParams({ name: item.name || '', artist: item.artist_name || '' });
+ let response = await fetch(`/api/discover/album/${source}/${albumId}?${_params}`);
+
+ // If 404 (stale cache entry), try resolving via name+artist
+ if (response.status === 404) {
+ const resolveResp = await fetch(`/api/discover/resolve-cache-album?name=${encodeURIComponent(item.name || '')}&artist=${encodeURIComponent(item.artist_name || '')}`);
+ if (resolveResp.ok) {
+ const resolved = await resolveResp.json();
+ if (resolved.success && resolved.entity_id && resolved.entity_id !== albumId) {
+ response = await fetch(`/api/discover/album/${resolved.source || source}/${resolved.entity_id}?${_params}`);
+ }
+ }
+ }
+
+ if (!response.ok) throw new Error('Album not available — it may have been removed from the source');
+ const albumData = await response.json();
+ if (!albumData.tracks || albumData.tracks.length === 0) throw new Error('No tracks found');
+
+ const spotifyTracks = albumData.tracks.map(track => {
+ let artists = track.artists || albumData.artists || [{ name: item.artist_name }];
+ if (Array.isArray(artists)) artists = artists.map(a => a.name || a);
+ return {
+ id: track.id,
+ name: track.name,
+ artists: artists,
+ album: {
+ id: albumData.id,
+ name: albumData.name,
+ album_type: albumData.album_type || 'album',
+ total_tracks: albumData.total_tracks || 0,
+ release_date: albumData.release_date || '',
+ images: albumData.images || [],
+ },
+ duration_ms: track.duration_ms || 0,
+ track_number: track.track_number || 0,
+ };
+ });
+
+ const artistContext = {
+ id: albumData.artists?.[0]?.id || '',
+ name: item.artist_name || albumData.artists?.[0]?.name || '',
+ source: source,
+ };
+ const albumContext = {
+ id: albumData.id,
+ name: albumData.name,
+ album_type: albumData.album_type || 'album',
+ total_tracks: albumData.total_tracks || 0,
+ release_date: albumData.release_date || '',
+ images: albumData.images || [],
+ };
+
+ await openDownloadMissingModalForYouTube(
+ `discover_cache_${albumId}`, albumData.name, spotifyTracks, artistContext, albumContext
+ );
+ hideLoadingOverlay();
+ } catch (error) {
+ console.error('Error opening cache discover album:', error);
+ showToast(`Failed to load album: ${error.message}`, 'error');
+ hideLoadingOverlay();
+ }
+}
+
+function _insertCacheSection(id, title, subtitle, html) {
+ const container = document.getElementById('discover-bylt-sections') || document.querySelector('.discover-container');
+ if (!container) return;
+ let section = document.getElementById(id);
+ if (!section) {
+ section = document.createElement('div');
+ section.id = id;
+ section.className = 'discover-section';
+ container.appendChild(section);
+ }
+ section.innerHTML = `
+
+
${html}
+ `;
+}
+
+async function loadCacheUndiscoveredAlbums() {
+ try {
+ const resp = await fetch('/api/discover/undiscovered-albums');
+ if (!resp.ok) return;
+ const data = await resp.json();
+ if (!data.success || !data.albums || !data.albums.length) return;
+ _cacheDiscoverData['undiscovered'] = data.albums;
+ _insertCacheSection('cache-undiscovered',
+ 'Undiscovered Albums', 'From artists you love',
+ data.albums.map((a, i) => _cacheDiscoverCard(a, 'album', 'undiscovered', i)).join(''));
+ } catch (e) { console.debug('Cache undiscovered albums:', e); }
+}
+
+async function loadCacheGenreNewReleases() {
+ try {
+ const resp = await fetch('/api/discover/genre-new-releases');
+ if (!resp.ok) return;
+ const data = await resp.json();
+ if (!data.success || !data.albums || !data.albums.length) return;
+ _cacheDiscoverData['genre_releases'] = data.albums;
+ _insertCacheSection('cache-genre-releases',
+ 'New In Your Genres', 'Released in the last 90 days',
+ data.albums.map((a, i) => _cacheDiscoverCard(a, 'album', 'genre_releases', i)).join(''));
+ } catch (e) { console.debug('Cache genre new releases:', e); }
+}
+
+async function loadCacheLabelExplorer() {
+ try {
+ const resp = await fetch('/api/discover/label-explorer');
+ if (!resp.ok) return;
+ const data = await resp.json();
+ if (!data.success || !data.albums || !data.albums.length) return;
+ _cacheDiscoverData['label_explorer'] = data.albums;
+ _insertCacheSection('cache-label-explorer',
+ 'From Your Labels', 'Popular on labels in your library',
+ data.albums.map((a, i) => _cacheDiscoverCard(a, 'album', 'label_explorer', i)).join(''));
+ } catch (e) { console.debug('Cache label explorer:', e); }
+}
+
+async function loadCacheDeepCuts() {
+ try {
+ const resp = await fetch('/api/discover/deep-cuts');
+ if (!resp.ok) return;
+ const data = await resp.json();
+ if (!data.success || !data.tracks || !data.tracks.length) return;
+ _cacheDiscoverData['deep_cuts'] = data.tracks;
+ _insertCacheSection('cache-deep-cuts',
+ 'Deep Cuts', 'Hidden tracks from artists you know',
+ data.tracks.map((t, i) => _cacheDiscoverCard(t, 'track', 'deep_cuts', i)).join(''));
+ } catch (e) { console.debug('Cache deep cuts:', e); }
+}
+
+async function loadCacheGenreExplorer() {
+ try {
+ const resp = await fetch('/api/discover/genre-explorer');
+ if (!resp.ok) return;
+ const data = await resp.json();
+ if (!data.success || !data.genres || !data.genres.length) return;
+ const _esc = (s) => (s || '').replace(/&/g, '&').replace(//g, '>').replace(/'/g, ''');
+ const html = `
${data.genres.map(g => `
+
+ ${_esc(g.genre)}
+ ${g.artist_count} artist${g.artist_count !== 1 ? 's' : ''}
+ ${!g.explored ? 'New' : ''}
+
+ `).join('')}
`;
+ _insertCacheSection('cache-genre-explorer',
+ 'Genre Explorer', 'Tap a genre to explore', html);
+ } catch (e) { console.debug('Cache genre explorer:', e); }
+}
+
+async function openGenreDeepDive(genre) {
+ document.getElementById('genre-deep-dive-modal')?.remove();
+
+ const _esc = (s) => (s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''');
+ const _fmtNum = (n) => {
+ if (!n) return '';
+ if (n >= 1000000) return (n/1000000).toFixed(1) + 'M';
+ if (n >= 1000) return (n/1000).toFixed(0) + 'K';
+ return n.toString();
+ };
+ const _fmtDur = (ms) => {
+ if (!ms) return '';
+ const m = Math.floor(ms / 60000);
+ const s = Math.floor((ms % 60000) / 1000);
+ return `${m}:${s.toString().padStart(2, '0')}`;
+ };
+
+ const overlay = document.createElement('div');
+ overlay.id = 'genre-deep-dive-modal';
+ overlay.className = 'genre-dive-overlay';
+ overlay.innerHTML = `
+
+
+
+
Exploring ${_esc(genre)}...
+
+
+ `;
+ overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
+ document.body.appendChild(overlay);
+
+ try {
+ const resp = await fetch(`/api/discover/genre-deep-dive?genre=${encodeURIComponent(genre)}`);
+ if (!resp.ok) throw new Error('Failed to load');
+ const data = await resp.json();
+ if (!data.success) throw new Error('Failed');
+
+ const body = document.getElementById('genre-dive-body');
+ if (!body) return;
+
+ // Update header with counts
+ const subtitle = document.querySelector('.genre-dive-subtitle');
+ if (subtitle) {
+ const parts = [];
+ if (data.artists?.length) parts.push(`${data.artists.length} artist${data.artists.length !== 1 ? 's' : ''}`);
+ if (data.tracks?.length) parts.push(`${data.tracks.length} track${data.tracks.length !== 1 ? 's' : ''}`);
+ if (data.albums?.length) parts.push(`${data.albums.length} album${data.albums.length !== 1 ? 's' : ''}`);
+ subtitle.textContent = parts.length ? parts.join(' · ') : 'Genre Deep Dive';
+ }
+
+ let html = '';
+
+ // Related genres — clickable pills that reload the modal
+ if (data.related_genres && data.related_genres.length) {
+ html += `
`;
+ }
+
+ // Artists section — clickable, navigates to artist page directly
+ if (data.artists && data.artists.length) {
+ html += `
+
🎤 Artists in ${_esc(genre)}
+
+ ${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)"`;
+ return `
+
+ ${!a.image_url ? '🎤' : ''}
+
+
${_esc(a.name)}
+ ${a.followers ? `
${_fmtNum(a.followers)} followers
` : ''}
+ ${a.library_id ? '
In Library
' : ''}
+
`;
+ }).join('')}
+
+
`;
+ }
+
+ // Tracks section — clickable, opens album download
+ if (data.tracks && data.tracks.length) {
+ _cacheDiscoverData['genre_dive_tracks'] = data.tracks;
+ html += `
+
🎵 Popular Tracks
+
+ ${data.tracks.map((t, i) => `
+
+
${i + 1}
+
+ ${!t.image_url ? '🎵' : ''}
+
+
+
${_esc(t.name)}
+
${_esc(t.artist_name)}${t.album_name ? ' · ' + _esc(t.album_name) : ''}
+
+
${_fmtDur(t.duration_ms)}
+
+ `).join('')}
+
+
`;
+ }
+
+ // Albums section
+ if (data.albums && data.albums.length) {
+ _cacheDiscoverData['genre_dive_albums'] = data.albums;
+ html += `
+
💿 Albums
+
${data.albums.map((a, i) => _cacheDiscoverCard(a, 'album', 'genre_dive_albums', i)).join('')}
+
`;
+ }
+
+ if (!html) {
+ html = '
🔍
No cached data found for this genre yet
Search for artists in this genre to build up the cache
';
+ }
+
+ body.innerHTML = html;
+ } catch (e) {
+ const body = document.getElementById('genre-dive-body');
+ if (body) body.innerHTML = '
Failed to load genre data
';
+ }
+}
+
// ===============================
// BUILD A PLAYLIST FEATURE
// ===============================
@@ -54827,6 +55210,7 @@ const importPageState = {
let _statsRange = '7d';
let _statsTimelineChart = null;
let _statsGenreChart = null;
+let _statsDbStorageChart = null;
let _statsInitialized = false;
function initializeStatsPage() {
@@ -55001,6 +55385,9 @@ async function loadStatsData() {
// Library health
_renderLibraryHealth(data.health || {});
+ // DB storage chart (separate fetch — not part of cached stats)
+ _loadDbStorageChart();
+
// Recent plays
_renderRecentPlays(data.recent || []);
}
@@ -55176,6 +55563,93 @@ function _renderLibraryHealth(data) {
}
}
+async function _loadDbStorageChart() {
+ try {
+ const resp = await fetch('/api/stats/db-storage');
+ const data = await resp.json();
+ if (!data.success || !data.tables || !data.tables.length) return;
+ _renderDbStorageChart(data.tables, data.total_file_size, data.method);
+ } catch (e) {
+ console.debug('DB storage chart load failed:', e);
+ }
+}
+
+function _renderDbStorageChart(tables, totalFileSize, method) {
+ const canvas = document.getElementById('stats-db-storage-chart');
+ if (!canvas || typeof Chart === 'undefined') return;
+
+ if (_statsDbStorageChart) _statsDbStorageChart.destroy();
+
+ // Top 8 tables, group rest as "Other"
+ const top = tables.slice(0, 8);
+ const rest = tables.slice(8);
+ const restSize = rest.reduce((s, t) => s + t.size, 0);
+ if (restSize > 0) top.push({ name: 'Other', size: restSize });
+
+ const colors = ['#3b82f6', '#f97316', '#a855f7', '#14b8a6', '#eab308', '#ec4899', '#6366f1', '#22c55e', '#555'];
+
+ _statsDbStorageChart = new Chart(canvas, {
+ type: 'doughnut',
+ data: {
+ labels: top.map(t => t.name),
+ datasets: [{
+ data: top.map(t => t.size),
+ backgroundColor: colors.slice(0, top.length),
+ borderWidth: 0,
+ hoverOffset: 4,
+ }],
+ },
+ options: {
+ responsive: false,
+ cutout: '65%',
+ plugins: {
+ legend: { display: false },
+ tooltip: {
+ callbacks: {
+ label: (ctx) => {
+ const val = ctx.parsed;
+ if (method === 'dbstat') {
+ if (val > 1048576) return ` ${(val / 1048576).toFixed(1)} MB`;
+ return ` ${(val / 1024).toFixed(0)} KB`;
+ }
+ return ` ${val.toLocaleString()} rows`;
+ }
+ }
+ }
+ },
+ },
+ });
+
+ // Center label — total file size
+ const totalEl = document.getElementById('stats-db-total');
+ if (totalEl) {
+ let sizeStr;
+ if (totalFileSize > 1073741824) sizeStr = (totalFileSize / 1073741824).toFixed(2) + ' GB';
+ else if (totalFileSize > 1048576) sizeStr = (totalFileSize / 1048576).toFixed(1) + ' MB';
+ else sizeStr = (totalFileSize / 1024).toFixed(0) + ' KB';
+ totalEl.innerHTML = `
${sizeStr}
Total Size
`;
+ }
+
+ // Legend
+ const legendEl = document.getElementById('stats-db-legend');
+ if (legendEl) {
+ legendEl.innerHTML = top.map((t, i) => {
+ let sizeLabel;
+ if (method === 'dbstat') {
+ if (t.size > 1048576) sizeLabel = (t.size / 1048576).toFixed(1) + ' MB';
+ else sizeLabel = (t.size / 1024).toFixed(0) + ' KB';
+ } else {
+ sizeLabel = t.size.toLocaleString() + ' rows';
+ }
+ return `
+
+ ${t.name}
+ ${sizeLabel}
+
`;
+ }).join('');
+ }
+}
+
async function playStatsTrack(title, artist, album) {
try {
const resp = await fetch('/api/stats/resolve-track', {
diff --git a/webui/static/style.css b/webui/static/style.css
index 547c9a77..19a1b105 100644
--- a/webui/static/style.css
+++ b/webui/static/style.css
@@ -26264,6 +26264,7 @@ body {
width: 100%;
height: 200px;
overflow: hidden;
+ position: relative;
}
.discover-card-image img {
@@ -26301,6 +26302,513 @@ body {
margin: 0;
}
+/* Genre Explorer Grid */
+.genre-explorer-grid {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ padding: 4px 0;
+}
+
+.genre-explorer-pill {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 14px;
+ border-radius: 20px;
+ font-size: 13px;
+ font-weight: 500;
+ cursor: default;
+ transition: all 0.2s;
+}
+
+.genre-explorer-pill.explored {
+ background: rgba(255, 255, 255, 0.06);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ color: rgba(255, 255, 255, 0.6);
+}
+
+.genre-explorer-pill.unexplored {
+ background: rgba(var(--accent-rgb), 0.08);
+ border: 1px solid rgba(var(--accent-rgb), 0.2);
+ color: rgb(var(--accent-light-rgb));
+}
+
+.genre-explorer-pill:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
+}
+
+.genre-pill-count {
+ font-size: 11px;
+ opacity: 0.5;
+ font-variant-numeric: tabular-nums;
+}
+
+.genre-pill-badge {
+ font-size: 9px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ background: rgba(var(--accent-rgb), 0.25);
+ color: rgb(var(--accent-light-rgb));
+ padding: 2px 6px;
+ border-radius: 8px;
+}
+
+/* Genre Deep Dive Modal */
+.genre-dive-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.7);
+ backdrop-filter: blur(8px);
+ z-index: 10000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ animation: fadeIn 0.2s ease;
+}
+
+.genre-dive-modal {
+ background: linear-gradient(135deg, rgba(24, 24, 28, 0.98) 0%, rgba(16, 16, 20, 0.99) 100%);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 20px;
+ width: 90vw;
+ max-width: 800px;
+ max-height: 85vh;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
+}
+
+.genre-dive-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 24px 28px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
+ flex-shrink: 0;
+ position: relative;
+ overflow: hidden;
+ background: linear-gradient(180deg,
+ rgba(var(--accent-rgb), 0.08) 0%,
+ rgba(var(--accent-rgb), 0.02) 60%,
+ transparent 100%);
+}
+
+.genre-dive-header::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: -100%;
+ width: 50%;
+ height: 100%;
+ background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.03), transparent);
+ animation: stats-header-sweep 10s ease-in-out infinite;
+}
+
+.genre-dive-subtitle {
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: rgba(var(--accent-rgb), 0.7);
+ font-weight: 600;
+ margin-bottom: 4px;
+ position: relative;
+ z-index: 1;
+}
+
+.genre-dive-title {
+ font-size: 24px;
+ font-weight: 700;
+ color: #fff;
+ margin: 0;
+ text-transform: capitalize;
+ position: relative;
+ z-index: 1;
+}
+
+.genre-dive-close {
+ background: none;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ color: rgba(255, 255, 255, 0.5);
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+ font-size: 20px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s;
+ flex-shrink: 0;
+ position: relative;
+ z-index: 1;
+}
+
+.genre-dive-close:hover {
+ background: rgba(255, 255, 255, 0.08);
+ color: #fff;
+ transform: scale(1.1);
+}
+
+.genre-dive-body {
+ flex: 1;
+ overflow-y: auto;
+ padding: 24px 28px;
+}
+
+.genre-dive-loading {
+ text-align: center;
+ color: rgba(255, 255, 255, 0.4);
+ padding: 60px 40px;
+ font-size: 14px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 16px;
+}
+
+.genre-dive-spinner {
+ width: 32px;
+ height: 32px;
+ border: 3px solid rgba(255, 255, 255, 0.08);
+ border-top-color: rgb(var(--accent-rgb));
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin { to { transform: rotate(360deg); } }
+
+.genre-dive-section {
+ margin-bottom: 28px;
+}
+
+.genre-dive-section:last-child {
+ margin-bottom: 0;
+}
+
+.genre-dive-section-title {
+ font-size: 14px;
+ font-weight: 600;
+ color: rgba(255, 255, 255, 0.6);
+ margin: 0 0 14px 0;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.genre-dive-icon {
+ font-size: 16px;
+}
+
+/* Artists */
+.genre-dive-artists {
+ display: flex;
+ gap: 14px;
+ overflow-x: auto;
+ padding-bottom: 8px;
+ scroll-behavior: smooth;
+}
+
+.genre-dive-artists::-webkit-scrollbar { height: 4px; }
+.genre-dive-artists::-webkit-scrollbar-track { background: transparent; }
+.genre-dive-artists::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; }
+
+.genre-dive-artist {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 6px;
+ min-width: 88px;
+ max-width: 88px;
+ cursor: pointer;
+ padding: 8px 4px;
+ border-radius: 12px;
+ transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+.genre-dive-artist:first-child {
+ min-width: 100px;
+ max-width: 100px;
+}
+
+.genre-dive-artist:nth-child(2),
+.genre-dive-artist:nth-child(3) {
+ min-width: 94px;
+ max-width: 94px;
+}
+
+.genre-dive-artist:hover {
+ background: rgba(255, 255, 255, 0.04);
+ transform: translateY(-3px);
+}
+
+.genre-dive-artist-img {
+ width: 64px;
+ height: 64px;
+ border-radius: 50%;
+ background: rgba(255, 255, 255, 0.06);
+ background-size: cover;
+ background-position: center;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 22px;
+ border: 2px solid rgba(255, 255, 255, 0.06);
+ transition: all 0.25s;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+}
+
+.genre-dive-artist:first-child .genre-dive-artist-img {
+ width: 80px;
+ height: 80px;
+ border-width: 3px;
+ border-color: rgba(var(--accent-rgb), 0.25);
+ box-shadow: 0 6px 20px rgba(var(--accent-rgb), 0.1);
+}
+
+.genre-dive-artist:nth-child(2) .genre-dive-artist-img,
+.genre-dive-artist:nth-child(3) .genre-dive-artist-img {
+ width: 72px;
+ height: 72px;
+}
+
+.genre-dive-artist:hover .genre-dive-artist-img {
+ border-color: rgba(var(--accent-rgb), 0.5);
+ box-shadow: 0 4px 16px rgba(var(--accent-rgb), 0.15);
+ transform: scale(1.08);
+}
+
+.genre-dive-artist-name {
+ font-size: 11px;
+ font-weight: 600;
+ color: rgba(255, 255, 255, 0.8);
+ text-align: center;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ width: 100%;
+ transition: color 0.2s;
+}
+
+.genre-dive-artist:hover .genre-dive-artist-name {
+ color: #fff;
+}
+
+.genre-dive-artist-meta {
+ font-size: 9px;
+ color: rgba(255, 255, 255, 0.3);
+}
+
+.genre-dive-artist-badge {
+ font-size: 8px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ background: rgba(var(--accent-rgb), 0.15);
+ color: rgb(var(--accent-rgb));
+ padding: 2px 6px;
+ border-radius: 6px;
+}
+
+/* Tracks */
+.genre-dive-tracks {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ border-radius: 12px;
+ overflow: hidden;
+}
+
+.genre-dive-track {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 14px;
+ background: rgba(255, 255, 255, 0.02);
+ cursor: pointer;
+ transition: all 0.2s;
+ border-left: 2px solid transparent;
+}
+
+.genre-dive-track:nth-child(odd) {
+ background: rgba(255, 255, 255, 0.025);
+}
+
+.genre-dive-track:hover {
+ background: rgba(var(--accent-rgb), 0.06);
+ border-left-color: rgba(var(--accent-rgb), 0.5);
+}
+
+.genre-dive-track:hover .genre-dive-track-num {
+ color: rgb(var(--accent-rgb));
+}
+
+.genre-dive-track:active {
+ background: rgba(var(--accent-rgb), 0.1);
+}
+
+.genre-dive-track-num {
+ font-size: 12px;
+ color: rgba(255, 255, 255, 0.25);
+ min-width: 20px;
+ text-align: center;
+ font-variant-numeric: tabular-nums;
+}
+
+.genre-dive-track-img {
+ width: 40px;
+ height: 40px;
+ border-radius: 6px;
+ background: rgba(255, 255, 255, 0.06);
+ background-size: cover;
+ background-position: center;
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 16px;
+}
+
+.genre-dive-track-info {
+ flex: 1;
+ min-width: 0;
+}
+
+.genre-dive-track-name {
+ font-size: 13px;
+ font-weight: 500;
+ color: rgba(255, 255, 255, 0.85);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.genre-dive-track:hover .genre-dive-track-name {
+ color: #fff;
+}
+
+.genre-dive-track-artist {
+ font-size: 11px;
+ color: rgba(255, 255, 255, 0.35);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.genre-dive-track-duration {
+ font-size: 12px;
+ color: rgba(255, 255, 255, 0.25);
+ font-variant-numeric: tabular-nums;
+ flex-shrink: 0;
+}
+
+/* Empty state */
+.genre-dive-empty {
+ text-align: center;
+ padding: 50px 20px;
+ color: rgba(255, 255, 255, 0.4);
+}
+
+.genre-dive-empty-icon {
+ font-size: 36px;
+ margin-bottom: 12px;
+}
+
+.genre-dive-empty p {
+ margin: 4px 0;
+ font-size: 14px;
+}
+
+.genre-dive-empty-hint {
+ font-size: 12px !important;
+ color: rgba(255, 255, 255, 0.25) !important;
+}
+
+/* Related genres */
+.genre-dive-related {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ align-items: center;
+ margin-bottom: 24px;
+ padding-bottom: 20px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
+}
+
+.genre-dive-related-label {
+ font-size: 11px;
+ color: rgba(255, 255, 255, 0.3);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ font-weight: 600;
+ margin-right: 4px;
+ width: 100%;
+ margin-bottom: 2px;
+}
+
+.genre-dive-related-pill {
+ padding: 5px 12px;
+ border-radius: 14px;
+ font-size: 12px;
+ font-weight: 500;
+ background: rgba(255, 255, 255, 0.04);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ color: rgba(255, 255, 255, 0.55);
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.genre-dive-related-pill:hover {
+ background: rgba(var(--accent-rgb), 0.12);
+ border-color: rgba(var(--accent-rgb), 0.3);
+ color: rgb(var(--accent-light-rgb));
+ transform: translateY(-1px);
+}
+
+/* Album card hover inside genre dive modal */
+.genre-dive-modal .discover-card {
+ transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1),
+ box-shadow 0.25s ease,
+ border-color 0.25s ease;
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ border-radius: 12px;
+}
+
+.genre-dive-modal .discover-card:hover {
+ transform: translateY(-4px) scale(1.02);
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
+ border-color: rgba(var(--accent-rgb), 0.25);
+}
+
+.genre-dive-modal .discover-card:hover .discover-card-title {
+ color: #fff;
+}
+
+/* In Library badge on discover cards */
+.discover-card-lib-badge {
+ position: absolute;
+ top: 6px;
+ left: 6px;
+ font-size: 8px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ background: rgba(var(--accent-rgb), 0.85);
+ color: #fff;
+ padding: 3px 7px;
+ border-radius: 6px;
+ backdrop-filter: blur(4px);
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
/* Discover Empty State */
/* ── Spotify Library Section ────────────────────────── */
@@ -33444,6 +33952,86 @@ body {
text-align: right;
}
+/* Database Storage Chart */
+.stats-db-storage-wrap {
+ display: flex;
+ align-items: center;
+ gap: 24px;
+ margin-top: 8px;
+}
+
+.stats-db-chart-container {
+ position: relative;
+ width: 180px;
+ height: 180px;
+ flex-shrink: 0;
+}
+
+.stats-db-chart-container canvas {
+ width: 180px !important;
+ height: 180px !important;
+}
+
+.stats-db-total {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ pointer-events: none;
+}
+
+.stats-db-total-value {
+ font-size: 20px;
+ font-weight: 700;
+ color: rgba(255, 255, 255, 0.85);
+}
+
+.stats-db-total-label {
+ font-size: 10px;
+ color: rgba(255, 255, 255, 0.4);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.stats-db-legend {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ min-width: 0;
+}
+
+.stats-db-legend-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 12px;
+ color: rgba(255, 255, 255, 0.6);
+}
+
+.stats-db-legend-dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 3px;
+ flex-shrink: 0;
+}
+
+.stats-db-legend-name {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.stats-db-legend-size {
+ font-variant-numeric: tabular-nums;
+ color: rgba(255, 255, 255, 0.4);
+ font-size: 11px;
+}
+
/* Stats empty state */
.stats-empty {
text-align: center;