Add DB storage visualization + cache-powered discovery sections + Genre Deep Dive

- 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
This commit is contained in:
Broque Thomas 2026-03-22 21:35:18 -07:00
parent c937045192
commit e3d70da55a
7 changed files with 1610 additions and 1 deletions

View file

@ -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 []

View file

@ -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."""

View file

@ -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."""

View file

@ -5140,6 +5140,18 @@
<div class="stats-enrichment" id="stats-enrichment-coverage"></div>
</div>
<!-- Database Storage -->
<div class="stats-section-card stats-full-width">
<div class="stats-section-title">Database Storage</div>
<div class="stats-db-storage-wrap">
<div class="stats-db-chart-container">
<canvas id="stats-db-storage-chart"></canvas>
<div class="stats-db-total" id="stats-db-total"></div>
</div>
<div class="stats-db-legend" id="stats-db-legend"></div>
</div>
</div>
<!-- Empty State -->
<div class="stats-empty hidden" id="stats-empty">
<div class="stats-empty-icon">📊</div>

View file

@ -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;

View file

@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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 ? '<div class="discover-card-lib-badge">In Library</div>' : '';
return `<div class="discover-card" onclick="${onclick}" style="cursor:pointer">
<div class="discover-card-image">
<img src="${_esc(coverUrl)}" alt="${_esc(title)}" loading="lazy" onerror="this.src='/static/placeholder-album.png'">
${libBadge}
</div>
<div class="discover-card-info">
<h4 class="discover-card-title">${_esc(title)}</h4>
<p class="discover-card-subtitle">${_esc(subtitle)}</p>
${meta ? `<p class="discover-card-meta">${_esc(meta)}</p>` : ''}
</div>
</div>`;
}
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 = `
<div class="discover-section-header">
<div>
<div class="discover-section-subtitle">${subtitle}</div>
<h3 class="discover-section-title">${title}</h3>
</div>
</div>
<div class="discover-carousel">${html}</div>
`;
}
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/'/g, '&#39;');
const html = `<div class="genre-explorer-grid">${data.genres.map(g => `
<div class="genre-explorer-pill ${g.explored ? 'explored' : 'unexplored'}" onclick="openGenreDeepDive('${_esc(g.genre)}')" style="cursor:pointer">
<span class="genre-pill-name">${_esc(g.genre)}</span>
<span class="genre-pill-count">${g.artist_count} artist${g.artist_count !== 1 ? 's' : ''}</span>
${!g.explored ? '<span class="genre-pill-badge">New</span>' : ''}
</div>
`).join('')}</div>`;
_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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
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 = `
<div class="genre-dive-modal">
<div class="genre-dive-header">
<div>
<div class="genre-dive-subtitle">Genre Deep Dive</div>
<h2 class="genre-dive-title">${_esc(genre)}</h2>
</div>
<button class="genre-dive-close" onclick="document.getElementById('genre-deep-dive-modal').remove()">&times;</button>
</div>
<div class="genre-dive-body" id="genre-dive-body">
<div class="genre-dive-loading"><div class="genre-dive-spinner"></div>Exploring ${_esc(genre)}...</div>
</div>
</div>
`;
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 += `<div class="genre-dive-related">
<div class="genre-dive-related-label">Related Genres</div>
${data.related_genres.map(rg => `
<button class="genre-dive-related-pill" onclick="document.getElementById('genre-deep-dive-modal').remove();openGenreDeepDive('${_esc(rg.genre)}')">${_esc(rg.genre)}</button>
`).join('')}
</div>`;
}
// Artists section — clickable, navigates to artist page directly
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)"`;
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>
<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>' : ''}
</div>`;
}).join('')}
</div>
</div>`;
}
// Tracks section — clickable, opens album download
if (data.tracks && data.tracks.length) {
_cacheDiscoverData['genre_dive_tracks'] = data.tracks;
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) => `
<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)}')` : ''}">
${!t.image_url ? '🎵' : ''}
</div>
<div class="genre-dive-track-info">
<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>
<div class="genre-dive-track-duration">${_fmtDur(t.duration_ms)}</div>
</div>
`).join('')}
</div>
</div>`;
}
// Albums section
if (data.albums && data.albums.length) {
_cacheDiscoverData['genre_dive_albums'] = data.albums;
html += `<div class="genre-dive-section">
<h3 class="genre-dive-section-title"><span class="genre-dive-icon">💿</span> Albums</h3>
<div class="discover-carousel">${data.albums.map((a, i) => _cacheDiscoverCard(a, 'album', 'genre_dive_albums', i)).join('')}</div>
</div>`;
}
if (!html) {
html = '<div class="genre-dive-empty"><div class="genre-dive-empty-icon">🔍</div><p>No cached data found for this genre yet</p><p class="genre-dive-empty-hint">Search for artists in this genre to build up the cache</p></div>';
}
body.innerHTML = html;
} catch (e) {
const body = document.getElementById('genre-dive-body');
if (body) body.innerHTML = '<div style="color:rgba(255,100,100,0.6);text-align:center;padding:40px;">Failed to load genre data</div>';
}
}
// ===============================
// 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 = `<div class="stats-db-total-value">${sizeStr}</div><div class="stats-db-total-label">Total Size</div>`;
}
// 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 `<div class="stats-db-legend-item">
<span class="stats-db-legend-dot" style="background:${colors[i]}"></span>
<span class="stats-db-legend-name">${t.name}</span>
<span class="stats-db-legend-size">${sizeLabel}</span>
</div>`;
}).join('');
}
}
async function playStatsTrack(title, artist, album) {
try {
const resp = await fetch('/api/stats/resolve-track', {

View file

@ -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;