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:
parent
c937045192
commit
e3d70da55a
7 changed files with 1610 additions and 1 deletions
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
155
web_server.py
155
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."""
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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, '>').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 ? '<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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/'/g, ''');
|
||||
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, '&').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 = `
|
||||
<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()">×</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', {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue