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)
|
fields['external_urls'] = json.dumps(urls)
|
||||||
|
|
||||||
return fields
|
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:
|
if conn:
|
||||||
conn.close()
|
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
|
@staticmethod
|
||||||
def _listening_time_filter(time_range, alias=''):
|
def _listening_time_filter(time_range, alias=''):
|
||||||
"""Build a WHERE clause for time-range filtering."""
|
"""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}")
|
logger.error(f"Error getting BYLT: {e}")
|
||||||
return jsonify({'success': True, 'sections': []})
|
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'])
|
@app.route('/api/discover/weekly', methods=['GET'])
|
||||||
def get_discover_weekly():
|
def get_discover_weekly():
|
||||||
"""Get discovery weekly playlist - curated selection that stays consistent until next update"""
|
"""Get discovery weekly playlist - curated selection that stays consistent until next update"""
|
||||||
|
|
@ -41969,6 +42114,16 @@ def stats_library_health():
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
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'])
|
@app.route('/api/stats/recent', methods=['GET'])
|
||||||
def stats_recent():
|
def stats_recent():
|
||||||
"""Get recently played tracks."""
|
"""Get recently played tracks."""
|
||||||
|
|
|
||||||
|
|
@ -5140,6 +5140,18 @@
|
||||||
<div class="stats-enrichment" id="stats-enrichment-coverage"></div>
|
<div class="stats-enrichment" id="stats-enrichment-coverage"></div>
|
||||||
</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 -->
|
<!-- Empty State -->
|
||||||
<div class="stats-empty hidden" id="stats-empty">
|
<div class="stats-empty hidden" id="stats-empty">
|
||||||
<div class="stats-empty-icon">📊</div>
|
<div class="stats-empty-icon">📊</div>
|
||||||
|
|
|
||||||
|
|
@ -2865,6 +2865,31 @@
|
||||||
min-width: 28px;
|
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 */
|
/* Empty state */
|
||||||
.stats-empty-icon {
|
.stats-empty-icon {
|
||||||
font-size: 40px;
|
font-size: 40px;
|
||||||
|
|
|
||||||
|
|
@ -11174,7 +11174,7 @@ async function openDownloadMissingModalForYouTube(virtualPlaylistId, playlistNam
|
||||||
}
|
}
|
||||||
|
|
||||||
// CRITICAL FIX: Use album context for discover_album playlists
|
// 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 ? {
|
const heroContext = isDiscoverAlbum && album && artist ? {
|
||||||
type: 'album',
|
type: 'album',
|
||||||
artist: {
|
artist: {
|
||||||
|
|
@ -46843,6 +46843,11 @@ async function loadDiscoverPage() {
|
||||||
loadDiscoveryShuffle(), // NEW: Discovery Shuffle
|
loadDiscoveryShuffle(), // NEW: Discovery Shuffle
|
||||||
loadFamiliarFavorites(), // NEW: Familiar Favorites
|
loadFamiliarFavorites(), // NEW: Familiar Favorites
|
||||||
loadBecauseYouListenTo(), // Personalized by listening stats
|
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)
|
initializeListenBrainzTabs(), // ListenBrainz playlists (tabbed)
|
||||||
loadDecadeBrowserTabs(), // Time Machine (tabbed by decade)
|
loadDecadeBrowserTabs(), // Time Machine (tabbed by decade)
|
||||||
loadGenreBrowserTabs(), // Browse by Genre (tabbed by genre)
|
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
|
// BUILD A PLAYLIST FEATURE
|
||||||
// ===============================
|
// ===============================
|
||||||
|
|
@ -54827,6 +55210,7 @@ const importPageState = {
|
||||||
let _statsRange = '7d';
|
let _statsRange = '7d';
|
||||||
let _statsTimelineChart = null;
|
let _statsTimelineChart = null;
|
||||||
let _statsGenreChart = null;
|
let _statsGenreChart = null;
|
||||||
|
let _statsDbStorageChart = null;
|
||||||
let _statsInitialized = false;
|
let _statsInitialized = false;
|
||||||
|
|
||||||
function initializeStatsPage() {
|
function initializeStatsPage() {
|
||||||
|
|
@ -55001,6 +55385,9 @@ async function loadStatsData() {
|
||||||
// Library health
|
// Library health
|
||||||
_renderLibraryHealth(data.health || {});
|
_renderLibraryHealth(data.health || {});
|
||||||
|
|
||||||
|
// DB storage chart (separate fetch — not part of cached stats)
|
||||||
|
_loadDbStorageChart();
|
||||||
|
|
||||||
// Recent plays
|
// Recent plays
|
||||||
_renderRecentPlays(data.recent || []);
|
_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) {
|
async function playStatsTrack(title, artist, album) {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/stats/resolve-track', {
|
const resp = await fetch('/api/stats/resolve-track', {
|
||||||
|
|
|
||||||
|
|
@ -26264,6 +26264,7 @@ body {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 200px;
|
height: 200px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.discover-card-image img {
|
.discover-card-image img {
|
||||||
|
|
@ -26301,6 +26302,513 @@ body {
|
||||||
margin: 0;
|
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 */
|
/* Discover Empty State */
|
||||||
/* ── Spotify Library Section ────────────────────────── */
|
/* ── Spotify Library Section ────────────────────────── */
|
||||||
|
|
||||||
|
|
@ -33444,6 +33952,86 @@ body {
|
||||||
text-align: right;
|
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 state */
|
||||||
.stats-empty {
|
.stats-empty {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue