Add Listening Stats page with media server play data integration

Full stats dashboard that polls Plex/Jellyfin/Navidrome for play
history and presents it with Chart.js visualizations:

Backend:
- ListeningStatsWorker polls active server every 30 min
- listening_history DB table with dedup, play_count/last_played on tracks
- get_play_history() and get_track_play_counts() for all 3 servers
- Pre-computed cache for all time ranges (7d/30d/12m/all) rebuilt each sync
- Single cached endpoint serves all stats data instantly
- Stats query methods: top artists/albums/tracks, timeline, genres, health

Frontend:
- New Stats nav page with glassmorphic container matching dashboard style
- Overview cards (plays, time, artists, albums, tracks) with accent hover
- Listening timeline bar chart (Chart.js)
- Genre breakdown doughnut chart with legend
- Top artists visual bubbles with profile pictures + ranked list
- Top albums and tracks ranked lists with album art
- Library health: format breakdown bar, unplayed count, enrichment coverage
- Recently played timeline with relative timestamps
- Time range pills with instant switching via cache
- Sync Now button with spinner, last synced timestamp
- Clickable artist names navigate to library artist detail
- Last.fm global listeners shown alongside personal play counts
- SoulID badges on matched artists
- Empty state when no data synced yet
- Mobile responsive layout

DB migrations: listening_history table, play_count/last_played columns,
all with idempotent CREATE IF NOT EXISTS / PRAGMA checks.
This commit is contained in:
Broque Thomas 2026-03-22 13:18:14 -07:00
parent aca8a8e996
commit cfb0e85564
10 changed files with 2497 additions and 3 deletions

View file

@ -433,6 +433,10 @@ class ConfigManager:
"delete_original": False,
"downsample_hires": False
},
"listening_stats": {
"enabled": True,
"poll_interval": 30
},
"import": {
"staging_path": "./Staging"
},

View file

@ -1085,6 +1085,90 @@ class JellyfinClient:
logger.error(f"Error getting library stats: {e}")
return {}
def get_play_history(self, limit=500):
"""Fetch recently played tracks for the active user.
Returns list of dicts with: track_title, artist, album, played_at,
duration_ms, track_id.
"""
if not self.ensure_connection() or not self.user_id:
return []
try:
params = {
'UserId': self.user_id,
'IncludeItemTypes': 'Audio',
'SortBy': 'DatePlayed',
'SortOrder': 'Descending',
'Fields': 'UserData,MediaSources',
'Recursive': True,
'IsPlayed': True,
'Limit': limit,
}
if self.music_library_id:
params['ParentId'] = self.music_library_id
response = self._make_request(f'/Users/{self.user_id}/Items', params)
if not response or 'Items' not in response:
return []
results = []
for item in response['Items']:
user_data = item.get('UserData', {})
played_at = user_data.get('LastPlayedDate')
if not played_at:
continue
duration_ticks = item.get('RunTimeTicks', 0)
results.append({
'track_title': item.get('Name', ''),
'artist': item.get('AlbumArtist', '') or (item.get('Artists', [''])[0] if item.get('Artists') else ''),
'album': item.get('Album', ''),
'played_at': played_at,
'duration_ms': duration_ticks // 10000 if duration_ticks else 0,
'track_id': item.get('Id', ''),
})
logger.info(f"Retrieved {len(results)} play history entries from Jellyfin")
return results
except Exception as e:
logger.error(f"Error getting Jellyfin play history: {e}")
return []
def get_track_play_counts(self):
"""Get PlayCount for all played audio items.
Returns dict of {item_id: play_count}.
"""
if not self.ensure_connection() or not self.user_id:
return {}
try:
params = {
'UserId': self.user_id,
'IncludeItemTypes': 'Audio',
'Fields': 'UserData',
'Recursive': True,
'IsPlayed': True,
'Limit': 10000,
}
if self.music_library_id:
params['ParentId'] = self.music_library_id
response = self._make_request(f'/Users/{self.user_id}/Items', params)
if not response or 'Items' not in response:
return {}
counts = {}
for item in response['Items']:
user_data = item.get('UserData', {})
play_count = user_data.get('PlayCount', 0)
if play_count > 0:
counts[item.get('Id', '')] = play_count
logger.info(f"Retrieved play counts for {len(counts)} tracks from Jellyfin")
return counts
except Exception as e:
logger.error(f"Error getting Jellyfin track play counts: {e}")
return {}
def clear_cache(self):
"""Clear all caches to force fresh data on next request"""
self._album_cache.clear()

View file

@ -0,0 +1,366 @@
"""
Listening Stats Worker polls the active media server for play history
and stores it in the local database for the Stats page.
Runs every 30 minutes (configurable). Detects the active server type
(Plex/Jellyfin/Navidrome) and calls the appropriate client methods.
"""
import threading
import time
from typing import Dict, Any
from utils.logging_config import get_logger
logger = get_logger("listening_stats_worker")
class ListeningStatsWorker:
"""Background worker that polls media servers for play data."""
def __init__(self, database, config_manager, plex_client=None,
jellyfin_client=None, navidrome_client=None):
self.db = database
self.config_manager = config_manager
self.plex_client = plex_client
self.jellyfin_client = jellyfin_client
self.navidrome_client = navidrome_client
# Worker state
self.running = False
self.paused = False
self.should_stop = False
self.thread = None
self.current_item = None
# Stats
self.stats = {
'polls_completed': 0,
'events_added': 0,
'tracks_updated': 0,
'errors': 0,
'last_poll': None,
}
# Config
self.poll_interval = 30 * 60 # 30 minutes default
logger.info("Listening stats worker initialized")
def start(self):
if self.running:
return
self.running = True
self.should_stop = False
self.thread = threading.Thread(target=self._run, daemon=True)
self.thread.start()
logger.info("Listening stats worker started")
def stop(self):
if not self.running:
return
self.should_stop = True
self.running = False
if self.thread:
self.thread.join(timeout=5)
logger.info("Listening stats worker stopped")
def pause(self):
self.paused = True
def resume(self):
self.paused = False
def get_stats(self) -> Dict[str, Any]:
is_running = self.running and self.thread is not None and self.thread.is_alive()
return {
'enabled': True,
'running': is_running and not self.paused,
'paused': self.paused,
'idle': is_running and not self.paused and self.current_item is None,
'current_item': self.current_item,
'stats': self.stats.copy(),
}
def _run(self):
logger.info("Listening stats worker thread started")
# Build cache from existing data immediately (before first poll)
time.sleep(5)
try:
self._build_stats_cache()
logger.info("Initial stats cache built from existing data")
except Exception as e:
logger.debug(f"Initial cache build skipped: {e}")
# Wait before first poll
time.sleep(10)
while not self.should_stop:
try:
if self.paused:
time.sleep(5)
continue
# Check if enabled
if not self.config_manager.get('listening_stats.enabled', True):
time.sleep(30)
continue
# Update poll interval from config
self.poll_interval = self.config_manager.get('listening_stats.poll_interval', 30) * 60
self._poll()
self.stats['polls_completed'] += 1
self.stats['last_poll'] = time.strftime('%Y-%m-%d %H:%M:%S')
self.current_item = None
# Sleep until next poll
for _ in range(int(self.poll_interval)):
if self.should_stop:
break
time.sleep(1)
except Exception as e:
logger.error(f"Error in listening stats worker: {e}", exc_info=True)
self.stats['errors'] += 1
time.sleep(60)
self.current_item = None
logger.info("Listening stats worker thread finished")
def _poll(self):
"""Poll the active media server for play data."""
active_server = self.config_manager.get_active_media_server()
logger.info(f"Polling {active_server} for listening data...")
self.current_item = f"Polling {active_server}..."
client = None
if active_server == 'plex' and self.plex_client:
client = self.plex_client
elif active_server == 'jellyfin' and self.jellyfin_client:
client = self.jellyfin_client
elif active_server == 'navidrome' and self.navidrome_client:
client = self.navidrome_client
if not client:
logger.warning(f"No client available for active server: {active_server}")
return
# Step 1: Fetch play history
self.current_item = f"Fetching play history from {active_server}..."
try:
history = client.get_play_history(limit=500)
except Exception as e:
logger.error(f"Failed to fetch play history from {active_server}: {e}")
self.stats['errors'] += 1
return
if history:
# Convert to DB format
events = []
for entry in history:
if not entry.get('played_at'):
continue
events.append({
'track_id': entry.get('track_id', ''),
'title': entry.get('track_title', ''),
'artist': entry.get('artist', ''),
'album': entry.get('album', ''),
'played_at': entry.get('played_at'),
'duration_ms': entry.get('duration_ms', 0),
'server_source': active_server,
'db_track_id': self._resolve_db_track_id(
entry.get('track_title', ''),
entry.get('artist', '')
),
})
inserted = self.db.insert_listening_events(events)
self.stats['events_added'] += inserted
logger.info(f"Inserted {inserted} new listening events (of {len(events)} total)")
# Step 2: Fetch play counts and update tracks table
self.current_item = f"Updating play counts from {active_server}..."
try:
server_counts = client.get_track_play_counts()
except Exception as e:
logger.error(f"Failed to fetch play counts from {active_server}: {e}")
self.stats['errors'] += 1
return
if server_counts:
# Map server track IDs to DB track IDs and update
updates = self._map_play_counts_to_db(server_counts, active_server)
if updates:
self.db.update_track_play_counts(updates)
self.stats['tracks_updated'] += len(updates)
logger.info(f"Updated play counts for {len(updates)} tracks")
# Step 3: Pre-compute stats cache for all time ranges
self.current_item = "Building stats cache..."
self._build_stats_cache()
def _build_stats_cache(self):
"""Pre-compute stats for all time ranges, enrich with images/IDs, and store."""
import json
try:
for time_range in ('7d', '30d', '12m', 'all'):
granularity = 'month' if time_range in ('12m', 'all') else 'day'
cache = {
'overview': self.db.get_listening_stats(time_range),
'top_artists': self.db.get_top_artists(time_range, 10),
'top_albums': self.db.get_top_albums(time_range, 10),
'top_tracks': self.db.get_top_tracks(time_range, 10),
'timeline': self.db.get_listening_timeline(time_range, granularity),
'genres': self.db.get_genre_breakdown(time_range),
}
# Enrich with images/IDs so the endpoint doesn't have to
self._enrich_stats_items(cache)
conn = self.db._get_connection()
cursor = conn.cursor()
cursor.execute(
"INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)",
(f'stats_cache_{time_range}', json.dumps(cache))
)
conn.commit()
conn.close()
# Cache recent plays and library health separately
conn = self.db._get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT title, artist, album, played_at, duration_ms
FROM listening_history ORDER BY played_at DESC LIMIT 20
""")
recent = [{'title': r[0], 'artist': r[1], 'album': r[2], 'played_at': r[3], 'duration_ms': r[4]}
for r in cursor.fetchall()]
cursor.execute(
"INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)",
('stats_cache_recent', json.dumps(recent))
)
health = self.db.get_library_health()
cursor.execute(
"INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)",
('stats_cache_health', json.dumps(health))
)
conn.commit()
conn.close()
logger.info("Stats cache rebuilt for all time ranges")
except Exception as e:
logger.error(f"Failed to build stats cache: {e}")
def _enrich_stats_items(self, cache):
"""Add image URLs, IDs, and Last.fm data to cached stats items."""
conn = None
try:
conn = self.db._get_connection()
cursor = conn.cursor()
for artist in (cache.get('top_artists') or []):
try:
cursor.execute("""
SELECT thumb_url, id, lastfm_listeners, lastfm_playcount, soul_id
FROM artists WHERE LOWER(name) = LOWER(?) LIMIT 1
""", (artist['name'],))
r = cursor.fetchone()
if r:
artist['image_url'] = r[0] or None
artist['id'] = r[1]
artist['global_listeners'] = r[2]
artist['global_playcount'] = r[3]
artist['soul_id'] = r[4]
except Exception:
pass
for album in (cache.get('top_albums') or []):
try:
cursor.execute("""
SELECT al.thumb_url, al.id, al.artist_id FROM albums al
WHERE LOWER(al.title) = LOWER(?) LIMIT 1
""", (album['name'],))
r = cursor.fetchone()
if r:
album['image_url'] = r[0] or None
album['id'] = r[1]
album['artist_id'] = r[2]
except Exception:
pass
for track in (cache.get('top_tracks') or []):
try:
cursor.execute("""
SELECT al.thumb_url, t.id, t.artist_id FROM tracks t
JOIN albums al ON al.id = t.album_id
JOIN artists ar ON ar.id = t.artist_id
WHERE LOWER(t.title) = LOWER(?) AND LOWER(ar.name) = LOWER(?) LIMIT 1
""", (track['name'], track.get('artist', '')))
r = cursor.fetchone()
if r:
track['image_url'] = r[0] or None
track['id'] = r[1]
track['artist_id'] = r[2]
except Exception:
pass
except Exception:
pass
finally:
if conn:
conn.close()
def _resolve_db_track_id(self, title, artist):
"""Try to match a server track to a local DB track by title+artist."""
if not title:
return None
conn = None
try:
conn = self.db._get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT t.id FROM tracks t
JOIN artists ar ON ar.id = t.artist_id
WHERE LOWER(t.title) = LOWER(?) AND LOWER(ar.name) = LOWER(?)
LIMIT 1
""", (title.strip(), (artist or '').strip()))
row = cursor.fetchone()
return row[0] if row else None
except Exception:
return None
finally:
if conn:
conn.close()
def _map_play_counts_to_db(self, server_counts, server_source):
"""Map server track IDs to DB track IDs for play count updates.
Looks up tracks by matching the server's track ID stored in
the tracks table (from library sync).
"""
conn = None
try:
conn = self.db._get_connection()
cursor = conn.cursor()
# Build a lookup of server_id → db_track_id
# The tracks table stores server IDs as the primary 'id' column
updates = []
for server_id, play_count in server_counts.items():
cursor.execute("SELECT id FROM tracks WHERE id = ?", (server_id,))
row = cursor.fetchone()
if row:
updates.append({
'db_track_id': row[0],
'play_count': play_count,
'last_played': None, # Could be fetched separately
})
return updates
except Exception as e:
logger.error(f"Error mapping play counts: {e}")
return []
finally:
if conn:
conn.close()

View file

@ -644,6 +644,106 @@ class NavidromeClient:
logger.error(f"Error getting library stats: {e}")
return {}
def get_play_history(self, limit=500):
"""Fetch recently played tracks via Subsonic API.
Uses getAlbumList2 with type=recent to get recently played albums,
then fetches tracks for each with their play dates.
Returns list of dicts with: track_title, artist, album, played_at,
duration_ms, track_id.
"""
if not self.ensure_connection():
return []
try:
response = self._make_request('getAlbumList2', {
'type': 'recent',
'size': min(limit, 500),
})
if not response:
return []
album_list = response.get('albumList2', {}).get('album', [])
if not isinstance(album_list, list):
album_list = [album_list] if album_list else []
results = []
for album in album_list[:50]: # Limit album fetches to avoid API spam
album_id = album.get('id')
if not album_id:
continue
album_resp = self._make_request('getAlbum', {'id': album_id})
if not album_resp:
continue
songs = album_resp.get('album', {}).get('song', [])
if not isinstance(songs, list):
songs = [songs] if songs else []
for song in songs:
played = song.get('played')
if not played:
continue
results.append({
'track_title': song.get('title', ''),
'artist': song.get('artist', ''),
'album': song.get('album', ''),
'played_at': played,
'duration_ms': int(song.get('duration', 0)) * 1000,
'track_id': song.get('id', ''),
})
logger.info(f"Retrieved {len(results)} play history entries from Navidrome")
return results
except Exception as e:
logger.error(f"Error getting Navidrome play history: {e}")
return []
def get_track_play_counts(self):
"""Get play counts for tracks via Subsonic API.
Uses getAlbumList2 type=frequent to find most-played albums,
then reads playCount from each track.
Returns dict of {track_id: play_count}.
"""
if not self.ensure_connection():
return {}
try:
response = self._make_request('getAlbumList2', {
'type': 'frequent',
'size': 500,
})
if not response:
return {}
album_list = response.get('albumList2', {}).get('album', [])
if not isinstance(album_list, list):
album_list = [album_list] if album_list else []
counts = {}
for album in album_list[:100]:
album_id = album.get('id')
if not album_id:
continue
album_resp = self._make_request('getAlbum', {'id': album_id})
if not album_resp:
continue
songs = album_resp.get('album', {}).get('song', [])
if not isinstance(songs, list):
songs = [songs] if songs else []
for song in songs:
pc = song.get('playCount', 0)
if pc and pc > 0:
counts[song.get('id', '')] = pc
logger.info(f"Retrieved play counts for {len(counts)} tracks from Navidrome")
return counts
except Exception as e:
logger.error(f"Error getting Navidrome track play counts: {e}")
return {}
return {}
def get_all_playlists(self) -> List[NavidromePlaylistInfo]:
"""Get all playlists from Navidrome server"""
if not self.ensure_connection():

View file

@ -612,6 +612,60 @@ class PlexClient:
logger.error(f"Error getting library stats: {e}")
return {}
def get_play_history(self, limit=500):
"""Fetch recently played tracks from Plex.
Returns list of dicts with: track_title, artist, album, played_at,
duration_ms, track_id (ratingKey).
"""
if not self.ensure_connection() or not self.server:
return []
try:
history = self.server.history(librarySectionID=self.music_library.key if self.music_library else None,
maxresults=limit)
results = []
for item in history:
if item.type != 'track':
continue
try:
results.append({
'track_title': item.title or '',
'artist': item.grandparentTitle or '',
'album': item.parentTitle or '',
'played_at': item.viewedAt.isoformat() if hasattr(item, 'viewedAt') and item.viewedAt else None,
'duration_ms': (item.duration or 0),
'track_id': str(item.ratingKey),
})
except Exception:
continue
logger.info(f"Retrieved {len(results)} play history entries from Plex")
return results
except Exception as e:
logger.error(f"Error getting Plex play history: {e}")
return []
def get_track_play_counts(self):
"""Get viewCount for all tracks in the music library.
Returns dict of {ratingKey: play_count}.
"""
if not self.ensure_connection() or not self.music_library:
return {}
try:
tracks = self.music_library.searchTracks()
counts = {}
for track in tracks:
view_count = getattr(track, 'viewCount', 0) or 0
if view_count > 0:
counts[str(track.ratingKey)] = view_count
logger.info(f"Retrieved play counts for {len(counts)} tracks from Plex")
return counts
except Exception as e:
logger.error(f"Error getting Plex track play counts: {e}")
return {}
def get_all_artists(self) -> List[PlexArtist]:
"""Get all artists from the music library"""
if not self.ensure_connection() or not self.music_library:

View file

@ -357,6 +357,7 @@ class MusicDatabase:
self._add_profile_listenbrainz_support(cursor)
self._add_profile_service_credentials(cursor)
self._add_soul_id_columns(cursor)
self._add_listening_history_table(cursor)
# Spotify library cache
self._add_spotify_library_cache_table(cursor)
@ -2662,6 +2663,378 @@ class MusicDatabase:
except Exception as e:
logger.error(f"Error adding soul_id columns: {e}")
def _add_listening_history_table(self, cursor):
"""Create listening_history table and add play_count/last_played to tracks."""
try:
cursor.execute("""
CREATE TABLE IF NOT EXISTS listening_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
track_id TEXT,
title TEXT NOT NULL,
artist TEXT,
album TEXT,
played_at TIMESTAMP NOT NULL,
duration_ms INTEGER DEFAULT 0,
server_source TEXT,
db_track_id INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_listening_played_at ON listening_history (played_at)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_listening_artist ON listening_history (artist)")
cursor.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_listening_dedup ON listening_history (track_id, played_at, server_source)")
# Add play_count and last_played to tracks table
cursor.execute("PRAGMA table_info(tracks)")
track_cols = [c[1] for c in cursor.fetchall()]
if 'play_count' not in track_cols:
cursor.execute("ALTER TABLE tracks ADD COLUMN play_count INTEGER DEFAULT 0")
logger.info("Added play_count column to tracks table")
if 'last_played' not in track_cols:
cursor.execute("ALTER TABLE tracks ADD COLUMN last_played TIMESTAMP")
logger.info("Added last_played column to tracks table")
except Exception as e:
logger.error(f"Error creating listening_history table: {e}")
def insert_listening_events(self, events):
"""Bulk insert listening events, skipping duplicates."""
if not events:
return 0
conn = None
inserted = 0
try:
conn = self._get_connection()
cursor = conn.cursor()
for event in events:
try:
cursor.execute("""
INSERT OR IGNORE INTO listening_history
(track_id, title, artist, album, played_at, duration_ms, server_source, db_track_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (
event.get('track_id'),
event.get('title', ''),
event.get('artist', ''),
event.get('album', ''),
event.get('played_at'),
event.get('duration_ms', 0),
event.get('server_source', ''),
event.get('db_track_id'),
))
if cursor.rowcount > 0:
inserted += 1
except Exception:
pass
conn.commit()
return inserted
except Exception as e:
logger.error(f"Error inserting listening events: {e}")
return 0
finally:
if conn:
conn.close()
def update_track_play_counts(self, counts):
"""Update play_count and last_played on the tracks table.
Args:
counts: list of dicts with {db_track_id, play_count, last_played}
"""
if not counts:
return
conn = None
try:
conn = self._get_connection()
cursor = conn.cursor()
for item in counts:
cursor.execute("""
UPDATE tracks SET play_count = ?, last_played = ?
WHERE id = ?
""", (item.get('play_count', 0), item.get('last_played'), item.get('db_track_id')))
conn.commit()
except Exception as e:
logger.error(f"Error updating track play counts: {e}")
finally:
if conn:
conn.close()
def get_listening_stats(self, time_range='all'):
"""Get aggregate listening stats for a time range.
Args:
time_range: '7d', '30d', '12m', or 'all'
Returns:
Dict with total_plays, total_time_ms, unique_artists, unique_albums, unique_tracks
"""
conn = None
try:
conn = self._get_connection()
cursor = conn.cursor()
where = self._listening_time_filter(time_range)
cursor.execute(f"""
SELECT
COUNT(*) as total_plays,
COALESCE(SUM(duration_ms), 0) as total_time_ms,
COUNT(DISTINCT artist) as unique_artists,
COUNT(DISTINCT album) as unique_albums,
COUNT(DISTINCT title || '|||' || COALESCE(artist, '')) as unique_tracks
FROM listening_history
{where}
""")
row = cursor.fetchone()
return {
'total_plays': row[0] or 0,
'total_time_ms': row[1] or 0,
'unique_artists': row[2] or 0,
'unique_albums': row[3] or 0,
'unique_tracks': row[4] or 0,
}
except Exception as e:
logger.error(f"Error getting listening stats: {e}")
return {'total_plays': 0, 'total_time_ms': 0, 'unique_artists': 0, 'unique_albums': 0, 'unique_tracks': 0}
finally:
if conn:
conn.close()
def get_top_artists(self, time_range='all', limit=10):
"""Get top artists by play count."""
conn = None
try:
conn = self._get_connection()
cursor = conn.cursor()
where = self._listening_time_filter(time_range)
cursor.execute(f"""
SELECT artist, COUNT(*) as play_count
FROM listening_history
{where}
AND artist IS NOT NULL AND artist != ''
GROUP BY LOWER(artist)
ORDER BY play_count DESC
LIMIT ?
""", (limit,))
return [{'name': row[0], 'play_count': row[1]} for row in cursor.fetchall()]
except Exception as e:
logger.error(f"Error getting top artists: {e}")
return []
finally:
if conn:
conn.close()
def get_top_albums(self, time_range='all', limit=10):
"""Get top albums by play count."""
conn = None
try:
conn = self._get_connection()
cursor = conn.cursor()
where = self._listening_time_filter(time_range)
cursor.execute(f"""
SELECT album, artist, COUNT(*) as play_count
FROM listening_history
{where}
AND album IS NOT NULL AND album != ''
GROUP BY LOWER(album), LOWER(artist)
ORDER BY play_count DESC
LIMIT ?
""", (limit,))
return [{'name': row[0], 'artist': row[1], 'play_count': row[2]} for row in cursor.fetchall()]
except Exception as e:
logger.error(f"Error getting top albums: {e}")
return []
finally:
if conn:
conn.close()
def get_top_tracks(self, time_range='all', limit=10):
"""Get top tracks by play count."""
conn = None
try:
conn = self._get_connection()
cursor = conn.cursor()
where = self._listening_time_filter(time_range)
cursor.execute(f"""
SELECT title, artist, album, COUNT(*) as play_count
FROM listening_history
{where}
AND title IS NOT NULL AND title != ''
GROUP BY LOWER(title), LOWER(artist)
ORDER BY play_count DESC
LIMIT ?
""", (limit,))
return [{'name': row[0], 'artist': row[1], 'album': row[2], 'play_count': row[3]} for row in cursor.fetchall()]
except Exception as e:
logger.error(f"Error getting top tracks: {e}")
return []
finally:
if conn:
conn.close()
def get_listening_timeline(self, time_range='30d', granularity='day'):
"""Get play count per time period for chart rendering."""
conn = None
try:
conn = self._get_connection()
cursor = conn.cursor()
where = self._listening_time_filter(time_range)
if granularity == 'month':
date_fmt = '%Y-%m'
elif granularity == 'week':
date_fmt = '%Y-W%W'
else:
date_fmt = '%Y-%m-%d'
cursor.execute(f"""
SELECT strftime('{date_fmt}', played_at) as period, COUNT(*) as plays
FROM listening_history
{where}
GROUP BY period
ORDER BY period ASC
""")
return [{'date': row[0], 'plays': row[1]} for row in cursor.fetchall()]
except Exception as e:
logger.error(f"Error getting listening timeline: {e}")
return []
finally:
if conn:
conn.close()
def get_genre_breakdown(self, time_range='all'):
"""Get genre distribution by play count (joins listening_history to tracks/artists)."""
conn = None
try:
conn = self._get_connection()
cursor = conn.cursor()
where = self._listening_time_filter(time_range, alias='lh')
cursor.execute(f"""
SELECT a.genres, COUNT(*) as play_count
FROM listening_history lh
JOIN tracks t ON t.id = lh.db_track_id
JOIN artists a ON a.id = t.artist_id
{where}
AND a.genres IS NOT NULL AND a.genres != ''
GROUP BY a.genres
ORDER BY play_count DESC
LIMIT 50
""")
# Parse genre JSON and aggregate
genre_counts = {}
for row in cursor.fetchall():
genres_str = row[0]
count = row[1]
try:
import json
genres = json.loads(genres_str)
if isinstance(genres, list):
for g in genres:
genre_counts[g] = genre_counts.get(g, 0) + count
else:
genre_counts[str(genres)] = genre_counts.get(str(genres), 0) + count
except (ValueError, TypeError):
for g in genres_str.split(','):
g = g.strip()
if g:
genre_counts[g] = genre_counts.get(g, 0) + count
total = sum(genre_counts.values()) or 1
result = sorted(
[{'genre': g, 'play_count': c, 'percentage': round(c / total * 100, 1)} for g, c in genre_counts.items()],
key=lambda x: x['play_count'], reverse=True
)[:15]
return result
except Exception as e:
logger.error(f"Error getting genre breakdown: {e}")
return []
finally:
if conn:
conn.close()
def get_library_health(self):
"""Get library health metrics."""
conn = None
try:
conn = self._get_connection()
cursor = conn.cursor()
# Total tracks
cursor.execute("SELECT COUNT(*) FROM tracks WHERE id IS NOT NULL")
total_tracks = (cursor.fetchone() or [0])[0]
# Unplayed
cursor.execute("SELECT COUNT(*) FROM tracks WHERE (play_count IS NULL OR play_count = 0) AND id IS NOT NULL")
unplayed = (cursor.fetchone() or [0])[0]
# Format breakdown
cursor.execute("""
SELECT
CASE
WHEN LOWER(file_path) LIKE '%.flac' THEN 'FLAC'
WHEN LOWER(file_path) LIKE '%.mp3' THEN 'MP3'
WHEN LOWER(file_path) LIKE '%.opus' THEN 'Opus'
WHEN LOWER(file_path) LIKE '%.m4a' THEN 'AAC'
WHEN LOWER(file_path) LIKE '%.ogg' THEN 'OGG'
WHEN LOWER(file_path) LIKE '%.wav' THEN 'WAV'
ELSE 'Other'
END as format,
COUNT(*) as count
FROM tracks
WHERE file_path IS NOT NULL AND file_path != ''
GROUP BY format
ORDER BY count DESC
""")
format_breakdown = {row[0]: row[1] for row in cursor.fetchall()}
# Total duration
cursor.execute("SELECT COALESCE(SUM(duration), 0) FROM tracks WHERE id IS NOT NULL")
total_duration_ms = (cursor.fetchone() or [0])[0]
# Enrichment coverage
enrichment = {}
for service, col in [('spotify', 'spotify_id'), ('musicbrainz', 'musicbrainz_id'),
('deezer', 'deezer_id'), ('lastfm', 'lastfm_url')]:
try:
cursor.execute(f"SELECT COUNT(*) FROM artists WHERE {col} IS NOT NULL AND {col} != ''")
matched = (cursor.fetchone() or [0])[0]
cursor.execute("SELECT COUNT(*) FROM artists WHERE id IS NOT NULL")
total_artists = (cursor.fetchone() or [0])[0]
enrichment[service] = round(matched / total_artists * 100, 1) if total_artists else 0
except Exception:
enrichment[service] = 0
return {
'total_tracks': total_tracks,
'unplayed_count': unplayed,
'unplayed_percentage': round(unplayed / total_tracks * 100, 1) if total_tracks else 0,
'format_breakdown': format_breakdown,
'total_duration_ms': total_duration_ms,
'enrichment_coverage': enrichment,
}
except Exception as e:
logger.error(f"Error getting library health: {e}")
return {}
finally:
if conn:
conn.close()
@staticmethod
def _listening_time_filter(time_range, alias=''):
"""Build a WHERE clause for time-range filtering."""
prefix = f"{alias}." if alias else ""
if time_range == '7d':
return f"WHERE {prefix}played_at >= datetime('now', '-7 days')"
elif time_range == '30d':
return f"WHERE {prefix}played_at >= datetime('now', '-30 days')"
elif time_range == '12m':
return f"WHERE {prefix}played_at >= datetime('now', '-12 months')"
else:
return "WHERE 1=1"
def set_profile_spotify(self, profile_id: int, client_id: str, client_secret: str,
redirect_uri: str = '') -> bool:
"""Save Spotify API credentials for a profile (encrypted)."""

View file

@ -4481,7 +4481,7 @@ def handle_settings():
if 'active_media_server' in new_settings:
config_manager.set_active_media_server(new_settings['active_media_server'])
for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'tidal_download', 'qobuz', 'hifi_download', 'listenbrainz', 'acoustid', 'lastfm', 'genius', 'import', 'lossy_copy', 'ui_appearance', 'youtube', 'content_filter', 'itunes', 'm3u_export', 'musicbrainz', 'deezer', 'audiodb', 'metadata', 'hydrabase']:
for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'tidal_download', 'qobuz', 'hifi_download', 'listenbrainz', 'acoustid', 'lastfm', 'genius', 'import', 'lossy_copy', 'listening_stats', 'ui_appearance', 'youtube', 'content_filter', 'itunes', 'm3u_export', 'musicbrainz', 'deezer', 'audiodb', 'metadata', 'hydrabase']:
if service in new_settings:
for key, value in new_settings[service].items():
config_manager.set(f'{service}.{key}', value)
@ -41454,6 +41454,284 @@ def soulid_status():
except Exception as e:
return jsonify({'error': str(e)}), 500
# ===================================================================
# Listening Stats Worker — polls media servers for play data
# ===================================================================
listening_stats_worker = None
try:
from core.listening_stats_worker import ListeningStatsWorker
from database.music_database import MusicDatabase
listening_stats_db = MusicDatabase()
listening_stats_worker = ListeningStatsWorker(
database=listening_stats_db,
config_manager=config_manager,
plex_client=plex_client,
jellyfin_client=jellyfin_client,
navidrome_client=navidrome_client,
)
listening_stats_worker.start()
print("✅ Listening stats worker initialized and started")
except Exception as e:
print(f"⚠️ Listening stats worker initialization failed: {e}")
listening_stats_worker = None
# --- Stats API Endpoints ---
@app.route('/api/stats/cached', methods=['GET'])
def stats_cached():
"""Get all pre-computed stats for a time range from cache. Instant response."""
try:
import json as _json
time_range = request.args.get('range', '7d')
database = get_database()
conn = database._get_connection()
cursor = conn.cursor()
# Get time-range-specific cache
cursor.execute("SELECT value FROM metadata WHERE key = ?", (f'stats_cache_{time_range}',))
row = cursor.fetchone()
data = _json.loads(row[0]) if row and row[0] else {}
# Get recent plays cache
cursor.execute("SELECT value FROM metadata WHERE key = 'stats_cache_recent'")
row = cursor.fetchone()
recent = _json.loads(row[0]) if row and row[0] else []
# Get library health cache
cursor.execute("SELECT value FROM metadata WHERE key = 'stats_cache_health'")
row = cursor.fetchone()
health = _json.loads(row[0]) if row and row[0] else {}
conn.close()
# Fix image URLs (stored as relative paths, need server prefix)
for item in (data.get('top_artists') or []) + (data.get('top_albums') or []) + (data.get('top_tracks') or []):
if item.get('image_url'):
item['image_url'] = fix_artist_image_url(item['image_url'])
return jsonify({
'success': True,
'cached': True,
**data,
'recent': recent,
'health': health,
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/stats/overview', methods=['GET'])
def stats_overview():
"""Get aggregate listening stats for a time range."""
try:
time_range = request.args.get('range', 'all')
database = get_database()
data = database.get_listening_stats(time_range)
return jsonify({'success': True, **data})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/stats/top-artists', methods=['GET'])
def stats_top_artists():
"""Get top artists by play count."""
try:
time_range = request.args.get('range', 'all')
limit = int(request.args.get('limit', 10))
database = get_database()
artists = database.get_top_artists(time_range, limit)
# Enrich with images, Last.fm stats, and soul_id from the library
for artist in artists:
try:
conn = database._get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT thumb_url, id, lastfm_listeners, lastfm_playcount, soul_id
FROM artists
WHERE LOWER(name) = LOWER(?)
LIMIT 1
""", (artist['name'],))
row = cursor.fetchone()
if row:
artist['image_url'] = fix_artist_image_url(row[0]) if row[0] else None
artist['id'] = row[1]
artist['global_listeners'] = row[2]
artist['global_playcount'] = row[3]
artist['soul_id'] = row[4]
conn.close()
except Exception:
pass
return jsonify({'success': True, 'artists': artists})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/stats/top-albums', methods=['GET'])
def stats_top_albums():
"""Get top albums by play count."""
try:
time_range = request.args.get('range', 'all')
limit = int(request.args.get('limit', 10))
database = get_database()
albums = database.get_top_albums(time_range, limit)
# Enrich with images
for album in albums:
try:
conn = database._get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT al.thumb_url, al.id, al.artist_id FROM albums al
WHERE LOWER(al.title) = LOWER(?) AND al.thumb_url IS NOT NULL AND al.thumb_url != ''
LIMIT 1
""", (album['name'],))
row = cursor.fetchone()
if row:
album['image_url'] = fix_artist_image_url(row[0]) if row[0] else None
album['id'] = row[1]
album['artist_id'] = row[2]
conn.close()
except Exception:
pass
return jsonify({'success': True, 'albums': albums})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/stats/top-tracks', methods=['GET'])
def stats_top_tracks():
"""Get top tracks by play count."""
try:
time_range = request.args.get('range', 'all')
limit = int(request.args.get('limit', 10))
database = get_database()
tracks = database.get_top_tracks(time_range, limit)
# Enrich with album images
for track in tracks:
try:
conn = database._get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT al.thumb_url, t.id, t.artist_id FROM tracks t
JOIN albums al ON al.id = t.album_id
JOIN artists ar ON ar.id = t.artist_id
WHERE LOWER(t.title) = LOWER(?) AND LOWER(ar.name) = LOWER(?)
LIMIT 1
""", (track['name'], track['artist']))
row = cursor.fetchone()
if row:
track['image_url'] = fix_artist_image_url(row[0]) if row[0] else None
track['id'] = row[1]
track['artist_id'] = row[2]
conn.close()
except Exception:
pass
return jsonify({'success': True, 'tracks': tracks})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/stats/timeline', methods=['GET'])
def stats_timeline():
"""Get play count per time period for chart rendering."""
try:
time_range = request.args.get('range', '30d')
granularity = request.args.get('granularity', 'day')
database = get_database()
data = database.get_listening_timeline(time_range, granularity)
return jsonify({'success': True, 'timeline': data})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/stats/genres', methods=['GET'])
def stats_genres():
"""Get genre distribution by play count."""
try:
time_range = request.args.get('range', 'all')
database = get_database()
data = database.get_genre_breakdown(time_range)
return jsonify({'success': True, 'genres': data})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/stats/library-health', methods=['GET'])
def stats_library_health():
"""Get library health metrics."""
try:
database = get_database()
data = database.get_library_health()
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."""
try:
limit = int(request.args.get('limit', 20))
database = get_database()
conn = database._get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT title, artist, album, played_at, duration_ms
FROM listening_history
ORDER BY played_at DESC
LIMIT ?
""", (limit,))
rows = cursor.fetchall()
conn.close()
tracks = []
for row in rows:
tracks.append({
'title': row[0],
'artist': row[1],
'album': row[2],
'played_at': row[3],
'duration_ms': row[4],
})
return jsonify({'success': True, 'tracks': tracks})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/listening-stats/sync', methods=['POST'])
def listening_stats_sync():
"""Trigger an immediate listening stats poll."""
try:
if not listening_stats_worker:
return jsonify({'success': False, 'error': 'Listening stats worker not initialized'}), 400
import threading
def _do_sync():
try:
print("🔄 [Stats Sync] Starting manual poll...")
listening_stats_worker._poll()
listening_stats_worker.stats['polls_completed'] += 1
listening_stats_worker.stats['last_poll'] = time.strftime('%Y-%m-%d %H:%M:%S')
print("✅ [Stats Sync] Manual poll completed")
except Exception as e:
print(f"❌ [Stats Sync] Manual poll failed: {e}")
import traceback
traceback.print_exc()
logger.error(f"Manual stats sync failed: {e}")
threading.Thread(target=_do_sync, daemon=True).start()
return jsonify({'success': True, 'message': 'Sync started'})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/listening-stats/status', methods=['GET'])
def listening_stats_status():
"""Get listening stats worker status."""
try:
if listening_stats_worker is None:
return jsonify({'enabled': False, 'running': False, 'paused': False,
'idle': False, 'current_item': None, 'stats': {}})
return jsonify(listening_stats_worker.get_stats())
except Exception as e:
return jsonify({'error': str(e)}), 500
# ===================================================================
# Repair Worker — Library maintenance and repair jobs
# ===================================================================
@ -42977,6 +43255,7 @@ def _emit_enrichment_status_loop():
'qobuz-enrichment': lambda: qobuz_enrichment_worker,
'hydrabase': lambda: hydrabase_worker,
'soulid': lambda: soulid_worker,
'listening-stats': lambda: listening_stats_worker,
'repair': lambda: repair_worker,
}
while True:

View file

@ -167,6 +167,10 @@
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/><line x1="9" y1="7" x2="16" y2="7"/><line x1="9" y1="11" x2="14" y2="11"/></svg></span>
<span class="nav-text">Library</span>
</button>
<button class="nav-button" data-page="stats">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M18 20V10"/><path d="M12 20V4"/><path d="M6 20v-6"/></svg></span>
<span class="nav-text">Stats</span>
</button>
<button class="nav-button" data-page="import">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></span>
<span class="nav-text">Import</span>
@ -4656,6 +4660,25 @@
</div>
</div>
<!-- Listening Stats Settings -->
<div class="settings-group" data-stg="library">
<h3>📊 Listening Stats</h3>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="listening-stats-enabled">
Enable listening stats collection from media server
</label>
</div>
<div class="form-group">
<label>Poll Interval (minutes):</label>
<input type="number" id="listening-stats-interval" min="5" max="1440" value="30">
</div>
<div class="help-text">
Polls your active media server (Plex, Jellyfin, or Navidrome) for play history
and displays stats on the Stats page.
</div>
</div>
<!-- Discovery Settings -->
<div class="settings-group" data-stg="advanced">
<h3>🔍 Discovery Pool Settings</h3>
@ -4985,6 +5008,123 @@
</div>
</div>
<!-- Stats Page -->
<div class="page" id="stats-page">
<div class="stats-container">
<div class="stats-header">
<div class="stats-header-title">
<img src="/static/trans2.png" alt="Stats" class="page-header-icon">
<h1 class="header-title">Listening Stats</h1>
</div>
<div class="stats-header-controls">
<div class="stats-time-range" id="stats-time-range">
<button class="stats-range-btn active" data-range="7d">7 Days</button>
<button class="stats-range-btn" data-range="30d">30 Days</button>
<button class="stats-range-btn" data-range="12m">12 Months</button>
<button class="stats-range-btn" data-range="all">All Time</button>
</div>
<div class="stats-sync-controls">
<span class="stats-last-synced" id="stats-last-synced"></span>
<button class="stats-sync-btn" id="stats-sync-btn" onclick="triggerStatsSync()" title="Sync now"><span class="stats-sync-icon"></span></button>
</div>
</div>
</div>
<!-- Overview Cards -->
<div class="stats-overview" id="stats-overview">
<div class="stats-card">
<div class="stats-card-value" id="stats-total-plays">0</div>
<div class="stats-card-label">Total Plays</div>
</div>
<div class="stats-card">
<div class="stats-card-value" id="stats-listening-time">0h</div>
<div class="stats-card-label">Listening Time</div>
</div>
<div class="stats-card">
<div class="stats-card-value" id="stats-unique-artists">0</div>
<div class="stats-card-label">Artists</div>
</div>
<div class="stats-card">
<div class="stats-card-value" id="stats-unique-albums">0</div>
<div class="stats-card-label">Albums</div>
</div>
<div class="stats-card">
<div class="stats-card-value" id="stats-unique-tracks">0</div>
<div class="stats-card-label">Tracks</div>
</div>
</div>
<!-- Main Grid: Charts + Rankings -->
<div class="stats-main-grid">
<div class="stats-left-col">
<div class="stats-section-card">
<div class="stats-section-title">Listening Activity</div>
<div style="position:relative;height:220px;">
<canvas id="stats-timeline-chart"></canvas>
</div>
</div>
<div class="stats-section-card">
<div class="stats-section-title">Genre Breakdown</div>
<div class="stats-genre-chart-container">
<canvas id="stats-genre-chart"></canvas>
<div class="stats-genre-legend" id="stats-genre-legend"></div>
</div>
</div>
<div class="stats-section-card">
<div class="stats-section-title">Recently Played</div>
<div class="stats-recent-list" id="stats-recent-plays"></div>
</div>
</div>
<div class="stats-right-col">
<div class="stats-section-card">
<div class="stats-section-title">Top Artists</div>
<div class="stats-top-artists-visual" id="stats-top-artists-visual"></div>
<div class="stats-ranked-list" id="stats-top-artists"></div>
</div>
<div class="stats-section-card">
<div class="stats-section-title">Top Albums</div>
<div class="stats-ranked-list" id="stats-top-albums"></div>
</div>
<div class="stats-section-card">
<div class="stats-section-title">Top Tracks</div>
<div class="stats-ranked-list" id="stats-top-tracks"></div>
</div>
</div>
</div>
<!-- Library Health -->
<div class="stats-section-card stats-full-width">
<div class="stats-section-title">Library Health</div>
<div class="stats-health-grid" id="stats-library-health">
<div class="stats-health-item">
<div class="stats-health-label">Format Breakdown</div>
<div class="stats-format-bar" id="stats-format-bar"></div>
</div>
<div class="stats-health-item">
<div class="stats-health-value" id="stats-unplayed">0</div>
<div class="stats-health-label">Unplayed Tracks</div>
</div>
<div class="stats-health-item">
<div class="stats-health-value" id="stats-total-duration">0h</div>
<div class="stats-health-label">Total Duration</div>
</div>
<div class="stats-health-item">
<div class="stats-health-value" id="stats-total-tracks-count">0</div>
<div class="stats-health-label">Total Tracks</div>
</div>
</div>
<div class="stats-enrichment" id="stats-enrichment-coverage"></div>
</div>
<!-- Empty State -->
<div class="stats-empty hidden" id="stats-empty">
<div class="stats-empty-icon">📊</div>
<h3>No Listening Data Yet</h3>
<p>Enable "Listening Stats" in Settings to start tracking your listening activity from your media server.</p>
</div>
</div>
</div>
<!-- Import Page -->
<div class="page" id="import-page">
<div class="import-page-container">
@ -6166,6 +6306,7 @@
</div>
<script src="{{ url_for('static', filename='vendor/socket.io.min.js') }}"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<script src="{{ url_for('static', filename='script.js') }}"></script>
<script src="{{ url_for('static', filename='docs.js') }}"></script>
<script src="{{ url_for('static', filename='particles.js') }}"></script>

View file

@ -350,6 +350,7 @@ function initializeWebSocket() {
socket.on('enrichment:hydrabase', (data) => updateHydrabaseStatusFromData(data));
socket.on('enrichment:repair', (data) => updateRepairStatusFromData(data));
socket.on('enrichment:soulid', (data) => updateSoulIDStatusFromData(data));
socket.on('enrichment:listening-stats', () => {}); // Status only, no UI update needed
socket.on('repair:progress', (data) => updateRepairJobProgressFromData(data));
// Phase 4 event listeners (tool progress)
@ -2624,6 +2625,9 @@ async function loadPageData(pageId) {
loadApiKeys();
switchSettingsTab('connections');
break;
case 'stats':
initializeStatsPage();
break;
case 'import':
initializeImportPage();
break;
@ -5579,6 +5583,10 @@ async function loadSettingsData() {
document.getElementById('lossy-copy-codec').value = settings.lossy_copy?.codec || 'mp3';
document.getElementById('lossy-copy-bitrate').value = settings.lossy_copy?.bitrate || '320';
document.getElementById('lossy-copy-delete-original').checked = settings.lossy_copy?.delete_original === true;
// Populate Listening Stats settings
document.getElementById('listening-stats-enabled').checked = settings.listening_stats?.enabled === true;
document.getElementById('listening-stats-interval').value = settings.listening_stats?.poll_interval || 30;
document.getElementById('lossy-copy-options').style.display =
settings.lossy_copy?.enabled ? 'block' : 'none';
@ -5785,10 +5793,12 @@ function updateDownloadSourceUI() {
youtubeContainer.style.display = activeSources.has('youtube') ? 'block' : 'none';
hifiContainer.style.display = activeSources.has('hifi') ? 'block' : 'none';
// Quality profile is Soulseek-only (streaming sources handle quality via their own settings)
// Quality profile is Soulseek-only and downloads-tab-only
const qualityProfileSection = document.getElementById('quality-profile-section');
if (qualityProfileSection) {
qualityProfileSection.style.display = activeSources.has('soulseek') ? '' : 'none';
const activeTab = document.querySelector('.stg-tab.active');
const onDownloadsTab = activeTab && activeTab.dataset.tab === 'downloads';
qualityProfileSection.style.display = (activeSources.has('soulseek') && onDownloadsTab) ? '' : 'none';
}
if (activeSources.has('tidal')) {
@ -6564,6 +6574,10 @@ async function saveSettings(quiet = false) {
delete_original: document.getElementById('lossy-copy-delete-original').checked,
downsample_hires: document.getElementById('downsample-hires').checked
},
listening_stats: {
enabled: document.getElementById('listening-stats-enabled').checked,
poll_interval: parseInt(document.getElementById('listening-stats-interval').value) || 30,
},
import: {
staging_path: document.getElementById('staging-path').value || './Staging'
},
@ -54673,6 +54687,386 @@ const importPageState = {
tapSelectedChip: null, // for mobile tap-to-assign fallback
};
// ===============================
// STATS PAGE
// ===============================
let _statsRange = '7d';
let _statsTimelineChart = null;
let _statsGenreChart = null;
let _statsInitialized = false;
function initializeStatsPage() {
if (_statsInitialized) {
loadStatsData();
return;
}
_statsInitialized = true;
// Time range buttons
const rangeContainer = document.getElementById('stats-time-range');
if (rangeContainer) {
rangeContainer.addEventListener('click', (e) => {
const btn = e.target.closest('.stats-range-btn');
if (!btn) return;
_statsRange = btn.dataset.range;
rangeContainer.querySelectorAll('.stats-range-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
loadStatsData();
});
}
loadStatsData();
_updateStatsLastSynced();
}
async function triggerStatsSync() {
const btn = document.getElementById('stats-sync-btn');
if (btn) btn.classList.add('syncing');
try {
const resp = await fetch('/api/listening-stats/sync', { method: 'POST' });
const data = await resp.json();
if (data.success) {
showToast('Syncing listening data...', 'info');
// Wait a few seconds for the sync to complete, then reload
setTimeout(async () => {
await loadStatsData();
_updateStatsLastSynced();
if (btn) btn.classList.remove('syncing');
showToast('Listening stats updated', 'success');
}, 5000);
} else {
showToast(data.error || 'Sync failed', 'error');
if (btn) btn.classList.remove('syncing');
}
} catch (e) {
showToast('Sync failed', 'error');
if (btn) btn.classList.remove('syncing');
}
}
async function _updateStatsLastSynced() {
const el = document.getElementById('stats-last-synced');
if (!el) return;
try {
const resp = await fetch('/api/listening-stats/status');
const data = await resp.json();
if (data.stats && data.stats.last_poll) {
el.textContent = `Last synced: ${data.stats.last_poll}`;
} else {
el.textContent = 'Not synced yet';
}
} catch {
el.textContent = '';
}
}
async function loadStatsData() {
// Show loading state
document.querySelectorAll('.stats-card-value').forEach(el => el.style.opacity = '0.3');
// Single cached endpoint — instant response
let data;
try {
const resp = await fetch(`/api/stats/cached?range=${_statsRange}`);
data = await resp.json();
} catch {
data = {};
}
if (!data.success) {
// Cache not available — show empty state, user should hit Sync
data = { overview: {}, top_artists: [], top_albums: [], top_tracks: [],
timeline: [], genres: [], recent: [], health: {} };
}
const overview = data.overview || {};
const emptyEl = document.getElementById('stats-empty');
const hasData = (overview.total_plays || 0) > 0;
if (emptyEl) {
emptyEl.classList.toggle('hidden', hasData);
}
// Hide main content sections when no data
const mainSections = document.querySelectorAll('.stats-overview, .stats-main-grid, .stats-full-width');
mainSections.forEach(el => el.style.display = hasData ? '' : 'none');
// Overview cards
const _fmt = (n) => {
if (!n) return '0';
if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K';
return n.toLocaleString();
};
const _fmtTime = (ms) => {
if (!ms) return '0h';
const hours = Math.floor(ms / 3600000);
const mins = Math.floor((ms % 3600000) / 60000);
if (hours > 0) return `${hours}h ${mins}m`;
return `${mins}m`;
};
// Restore opacity
document.querySelectorAll('.stats-card-value').forEach(el => el.style.opacity = '1');
_setText('stats-total-plays', _fmt(overview.total_plays));
_setText('stats-listening-time', _fmtTime(overview.total_time_ms));
_setText('stats-unique-artists', _fmt(overview.unique_artists));
_setText('stats-unique-albums', _fmt(overview.unique_albums));
_setText('stats-unique-tracks', _fmt(overview.unique_tracks));
// Top Artists — visual bubbles
_renderTopArtistsVisual(data.top_artists || []);
// Top Artists — ranked list
_renderRankedList('stats-top-artists', data.top_artists || [], (item, i) => `
<div class="stats-ranked-item">
<span class="stats-ranked-num">${i + 1}</span>
${item.image_url ? `<img class="stats-ranked-img" src="${item.image_url}" alt="" onerror="this.style.display='none'">` : ''}
<div class="stats-ranked-info">
<div class="stats-ranked-name">${item.id ? `<a class="stats-artist-link" onclick="navigateToPage('library');setTimeout(()=>navigateToArtistDetail('${item.id}','${_esc(item.name).replace(/'/g,"\\'")}'),300)">${_esc(item.name)}</a>` : _esc(item.name)}${item.soul_id && !item.soul_id.startsWith('soul_unnamed_') ? ' <img src="/static/trans2.png" style="width:12px;height:12px;vertical-align:middle;opacity:0.5;" title="SoulID">' : ''}</div>
<div class="stats-ranked-meta">${item.global_listeners ? _fmt(item.global_listeners) + ' global listeners' : ''}</div>
</div>
<span class="stats-ranked-count">${_fmt(item.play_count)} plays</span>
</div>
`);
// Top Albums
_renderRankedList('stats-top-albums', data.top_albums || [], (item, i) => `
<div class="stats-ranked-item">
<span class="stats-ranked-num">${i + 1}</span>
${item.image_url ? `<img class="stats-ranked-img" src="${item.image_url}" alt="" onerror="this.style.display='none'">` : ''}
<div class="stats-ranked-info">
<div class="stats-ranked-name">${_esc(item.name)}</div>
<div class="stats-ranked-meta">${item.artist_id ? `<a class="stats-artist-link" onclick="navigateToPage('library');setTimeout(()=>navigateToArtistDetail('${item.artist_id}','${_esc(item.artist||'').replace(/'/g,"\\'")}'),300)">${_esc(item.artist || '')}</a>` : _esc(item.artist || '')}</div>
</div>
<span class="stats-ranked-count">${_fmt(item.play_count)} plays</span>
</div>
`);
// Top Tracks
_renderRankedList('stats-top-tracks', data.top_tracks || [], (item, i) => `
<div class="stats-ranked-item">
<span class="stats-ranked-num">${i + 1}</span>
${item.image_url ? `<img class="stats-ranked-img" src="${item.image_url}" alt="" onerror="this.style.display='none'">` : ''}
<div class="stats-ranked-info">
<div class="stats-ranked-name">${_esc(item.name)}</div>
<div class="stats-ranked-meta">${item.artist_id ? `<a class="stats-artist-link" onclick="navigateToPage('library');setTimeout(()=>navigateToArtistDetail('${item.artist_id}','${_esc(item.artist||'').replace(/'/g,"\\'")}'),300)">${_esc(item.artist || '')}</a>` : _esc(item.artist || '')}${item.album ? ' · ' + _esc(item.album) : ''}</div>
</div>
<span class="stats-ranked-count">${_fmt(item.play_count)} plays</span>
</div>
`);
// Timeline chart
_renderTimelineChart(data.timeline || []);
// Genre chart
_renderGenreChart(data.genres || []);
// Library health
_renderLibraryHealth(data.health || {});
// Recent plays
_renderRecentPlays(data.recent || []);
}
function _renderTopArtistsVisual(artists) {
const el = document.getElementById('stats-top-artists-visual');
if (!el || !artists.length) { if (el) el.innerHTML = ''; return; }
const top5 = artists.slice(0, 5);
const maxPlays = top5[0]?.play_count || 1;
const _fmt = (n) => {
if (!n) return '0';
if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K';
return n.toString();
};
el.innerHTML = `<div class="stats-artist-bubbles">
${top5.map((a, i) => {
const pct = Math.round((a.play_count / maxPlays) * 100);
const size = 44 + (4 - i) * 6; // Largest first: 68, 62, 56, 50, 44
return `<div class="stats-artist-bubble" onclick="${a.id ? `navigateToPage('library');setTimeout(()=>navigateToArtistDetail('${a.id}','${_esc(a.name).replace(/'/g,"\\\\'")}'),300)` : ''}" style="cursor:${a.id ? 'pointer' : 'default'}">
<div class="stats-bubble-img" style="width:${size}px;height:${size}px;${a.image_url ? `background-image:url('${a.image_url}')` : ''}">
${!a.image_url ? `<span>${(a.name || '?')[0]}</span>` : ''}
</div>
<div class="stats-bubble-bar-container">
<div class="stats-bubble-bar" style="width:${pct}%"></div>
</div>
<div class="stats-bubble-name">${_esc(a.name)}</div>
<div class="stats-bubble-count">${_fmt(a.play_count)}</div>
</div>`;
}).join('')}
</div>`;
}
function _setText(id, text) {
const el = document.getElementById(id);
if (el) el.textContent = text;
}
function _renderRankedList(containerId, items, template) {
const el = document.getElementById(containerId);
if (!el) return;
el.innerHTML = items.length
? items.map((item, i) => template(item, i)).join('')
: '<div style="color:rgba(255,255,255,0.3);font-size:0.85em;padding:12px;">No data yet</div>';
}
function _renderTimelineChart(data) {
const canvas = document.getElementById('stats-timeline-chart');
if (!canvas || typeof Chart === 'undefined') return;
if (_statsTimelineChart) _statsTimelineChart.destroy();
_statsTimelineChart = new Chart(canvas, {
type: 'bar',
data: {
labels: data.map(d => d.date),
datasets: [{
label: 'Plays',
data: data.map(d => d.plays),
backgroundColor: `rgba(${getComputedStyle(document.documentElement).getPropertyValue('--accent-rgb').trim() || '29,185,84'}, 0.5)`,
borderColor: `rgba(${getComputedStyle(document.documentElement).getPropertyValue('--accent-rgb').trim() || '29,185,84'}, 0.8)`,
borderWidth: 1,
borderRadius: 4,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { grid: { display: false }, ticks: { color: 'rgba(255,255,255,0.3)', font: { size: 10 }, maxTicksLimit: 12 } },
y: { grid: { color: 'rgba(255,255,255,0.04)' }, ticks: { color: 'rgba(255,255,255,0.3)', font: { size: 10 } }, beginAtZero: true },
}
}
});
}
function _renderGenreChart(data) {
const canvas = document.getElementById('stats-genre-chart');
const legend = document.getElementById('stats-genre-legend');
if (!canvas || typeof Chart === 'undefined') return;
if (_statsGenreChart) _statsGenreChart.destroy();
const colors = [
'#1db954', '#1ed760', '#4ade80', '#7c3aed', '#a855f7',
'#ec4899', '#f43f5e', '#f97316', '#eab308', '#06b6d4',
'#3b82f6', '#6366f1', '#14b8a6', '#84cc16', '#f59e0b',
];
const top = data.slice(0, 10);
_statsGenreChart = new Chart(canvas, {
type: 'doughnut',
data: {
labels: top.map(g => g.genre),
datasets: [{
data: top.map(g => g.play_count),
backgroundColor: colors.slice(0, top.length),
borderWidth: 0,
hoverOffset: 6,
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
cutout: '65%',
plugins: { legend: { display: false } },
}
});
if (legend) {
legend.innerHTML = top.map((g, i) => `
<div class="stats-genre-legend-item">
<span class="stats-genre-dot" style="background:${colors[i]}"></span>
<span>${g.genre}</span>
<span class="stats-genre-pct">${g.percentage}%</span>
</div>
`).join('');
}
}
function _renderLibraryHealth(data) {
if (!data || !data.total_tracks) return;
const _fmt = (n) => {
if (!n) return '0';
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
return n.toLocaleString();
};
_setText('stats-unplayed', `${_fmt(data.unplayed_count)} (${data.unplayed_percentage || 0}%)`);
_setText('stats-total-duration', data.total_duration_ms ? `${Math.floor(data.total_duration_ms / 3600000)}h` : '0h');
_setText('stats-total-tracks-count', _fmt(data.total_tracks));
// Format bar
const bar = document.getElementById('stats-format-bar');
if (bar && data.format_breakdown) {
const total = Object.values(data.format_breakdown).reduce((s, v) => s + v, 0) || 1;
const fmtColors = { FLAC: '#3b82f6', MP3: '#f97316', Opus: '#a855f7', AAC: '#14b8a6', OGG: '#eab308', WAV: '#ec4899', Other: '#555' };
bar.innerHTML = Object.entries(data.format_breakdown).map(([fmt, count]) => {
const pct = (count / total * 100).toFixed(1);
return `<div class="stats-format-segment" style="flex:${count};background:${fmtColors[fmt] || '#555'}" title="${fmt}: ${count} tracks (${pct}%)">${pct > 8 ? fmt : ''}</div>`;
}).join('');
}
// Enrichment coverage
const enrichEl = document.getElementById('stats-enrichment-coverage');
if (enrichEl && data.enrichment_coverage) {
const ec = data.enrichment_coverage;
const services = [
{ name: 'Spotify', pct: ec.spotify || 0, color: '#1db954' },
{ name: 'MusicBrainz', pct: ec.musicbrainz || 0, color: '#ba55d3' },
{ name: 'Deezer', pct: ec.deezer || 0, color: '#a238ff' },
{ name: 'Last.fm', pct: ec.lastfm || 0, color: '#d51007' },
];
enrichEl.innerHTML = services.map(s => `
<div class="stats-enrich-item">
<span class="stats-enrich-name">${s.name}</span>
<div class="stats-enrich-bar"><div class="stats-enrich-fill" style="width:${s.pct}%;background:${s.color}"></div></div>
<span class="stats-enrich-pct">${s.pct}%</span>
</div>
`).join('');
}
}
function _renderRecentPlays(tracks) {
const el = document.getElementById('stats-recent-plays');
if (!el) return;
if (!tracks.length) {
el.innerHTML = '<div style="color:rgba(255,255,255,0.3);font-size:0.85em;padding:12px;">No recent plays</div>';
return;
}
const _ago = (dateStr) => {
if (!dateStr) return '';
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
return `${Math.floor(days / 30)}mo ago`;
};
el.innerHTML = tracks.map(t => `
<div class="stats-recent-item">
<span class="stats-recent-title">${_esc(t.title)}</span>
<span class="stats-recent-artist">${_esc(t.artist || '')}</span>
<span class="stats-recent-time">${_ago(t.played_at)}</span>
</div>
`).join('');
}
// --- Initialization ---
function initializeImportPage() {

View file

@ -32548,6 +32548,705 @@ body {
IMPORT PAGE (full page, replaces modal)
======================================== */
/* ============================================================================
STATS PAGE
============================================================================ */
/* Stats page uses dashboard-container pattern for consistency */
.stats-container {
display: flex;
flex-direction: column;
gap: 20px;
padding: 28px 24px 30px;
background: linear-gradient(135deg,
rgba(20, 20, 20, 0.55) 0%,
rgba(12, 12, 12, 0.62) 100%);
border-radius: 24px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-top: 1px solid rgba(255, 255, 255, 0.12);
margin: 20px;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.3),
0 4px 16px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
/* Header uses same pattern as .dashboard-header */
.stats-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
margin: -28px -24px 0 -24px;
position: relative;
overflow: hidden;
flex-wrap: wrap;
gap: 16px;
background: linear-gradient(180deg,
rgba(var(--accent-rgb), 0.10) 0%,
rgba(var(--accent-rgb), 0.04) 40%,
transparent 100%);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
border-top-left-radius: 24px;
border-top-right-radius: 24px;
}
.stats-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 12s ease-in-out infinite;
}
@keyframes stats-header-sweep {
0%, 100% { left: -100%; }
50% { left: 150%; }
}
.stats-header-title {
display: flex;
align-items: center;
gap: 14px;
}
.stats-header-title h1 {
font-size: 28px;
font-weight: 700;
color: #fff;
margin: 0;
font-family: 'SF Pro Display', -apple-system, sans-serif;
}
/* Time range pills */
.stats-time-range {
display: flex;
gap: 4px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 10px;
padding: 3px;
}
.stats-range-btn {
padding: 7px 16px;
border: none;
border-radius: 8px;
background: transparent;
color: rgba(255, 255, 255, 0.5);
font-size: 0.82em;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
}
.stats-range-btn:hover {
color: rgba(255, 255, 255, 0.8);
background: rgba(255, 255, 255, 0.04);
}
.stats-range-btn.active {
background: rgb(var(--accent-rgb));
color: #fff;
box-shadow: 0 2px 8px rgba(var(--accent-rgb), 0.3);
}
.stats-header-controls {
display: flex;
align-items: center;
gap: 16px;
}
.stats-sync-controls {
display: flex;
align-items: center;
gap: 8px;
}
.stats-last-synced {
font-size: 0.72em;
color: rgba(255, 255, 255, 0.3);
}
.stats-sync-btn {
width: 32px;
height: 32px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.5);
font-size: 16px;
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
display: flex;
align-items: center;
justify-content: center;
}
.stats-sync-btn:hover {
background: rgba(255, 255, 255, 0.08);
color: #fff;
border-color: rgba(255, 255, 255, 0.15);
}
.stats-sync-btn.syncing {
pointer-events: none;
color: transparent;
position: relative;
}
.stats-sync-btn.syncing::after {
content: '';
position: absolute;
width: 14px;
height: 14px;
border: 2px solid rgba(var(--accent-rgb), 0.2);
border-top-color: rgba(var(--accent-rgb), 0.8);
border-radius: 50%;
animation: stats-spin 0.8s linear infinite;
}
@keyframes stats-spin {
to { transform: rotate(360deg); }
}
/* Overview cards */
.stats-overview {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 14px;
}
.stats-card {
background: linear-gradient(135deg, rgba(20, 20, 20, 0.95) 0%, rgba(12, 12, 12, 0.98) 100%);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
padding: 20px;
text-align: center;
position: relative;
overflow: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.stats-card::before {
content: '';
position: absolute;
top: 0;
left: 20%;
right: 20%;
height: 2px;
background: linear-gradient(90deg, transparent, rgba(var(--accent-rgb), 0.5), transparent);
border-radius: 2px;
transition: all 0.3s;
}
.stats-card:hover {
transform: translateY(-3px);
border-color: rgba(var(--accent-rgb), 0.2);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4), 0 0 20px rgba(var(--accent-rgb), 0.08);
}
.stats-card:hover::before {
left: 10%;
right: 10%;
height: 3px;
box-shadow: 0 0 12px rgba(var(--accent-rgb), 0.4);
}
.stats-card-value {
font-size: 2em;
font-weight: 700;
color: #fff;
line-height: 1.2;
margin-bottom: 6px;
font-family: 'SF Pro Display', -apple-system, sans-serif;
}
.stats-card-label {
font-size: 0.78em;
color: rgba(255, 255, 255, 0.45);
text-transform: uppercase;
letter-spacing: 0.06em;
font-weight: 600;
}
/* Main grid */
.stats-main-grid {
display: grid;
grid-template-columns: 1fr 360px;
gap: 20px;
}
.stats-left-col, .stats-right-col {
display: flex;
flex-direction: column;
gap: 20px;
}
.stats-two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
/* Section cards */
.stats-section-card {
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 14px;
padding: 20px;
transition: border-color 0.2s;
}
.stats-section-card:hover {
border-color: rgba(255, 255, 255, 0.1);
}
.stats-full-width {
/* No extra margin — container handles it */
}
.stats-section-title {
font-size: 0.78em;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: rgba(255, 255, 255, 0.4);
margin-bottom: 16px;
padding-bottom: 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
/* Genre chart */
.stats-genre-chart-container {
display: flex;
align-items: center;
gap: 24px;
}
.stats-genre-chart-container canvas {
width: 180px !important;
height: 180px !important;
flex-shrink: 0;
}
.stats-genre-legend {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
}
.stats-genre-legend-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.82em;
color: rgba(255, 255, 255, 0.7);
}
.stats-genre-dot {
width: 10px;
height: 10px;
border-radius: 3px;
flex-shrink: 0;
}
.stats-genre-pct {
margin-left: auto;
color: rgba(255, 255, 255, 0.4);
font-variant-numeric: tabular-nums;
}
/* Top artists visual bubbles */
.stats-top-artists-visual {
margin-bottom: 16px;
padding-bottom: 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
}
.stats-artist-bubbles {
display: flex;
justify-content: space-around;
align-items: flex-end;
gap: 8px;
}
.stats-artist-bubble {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
min-width: 0;
flex: 1;
transition: transform 0.2s;
}
.stats-artist-bubble:hover {
transform: translateY(-3px);
}
.stats-bubble-img {
border-radius: 50%;
background-size: cover;
background-position: center;
background-color: rgba(255, 255, 255, 0.06);
border: 2px solid rgba(var(--accent-rgb), 0.2);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: border-color 0.2s, box-shadow 0.2s;
}
.stats-artist-bubble:hover .stats-bubble-img {
border-color: rgba(var(--accent-rgb), 0.5);
box-shadow: 0 4px 20px rgba(var(--accent-rgb), 0.2);
}
.stats-bubble-img span {
font-size: 1.2em;
font-weight: 700;
color: rgba(255, 255, 255, 0.4);
}
.stats-bubble-bar-container {
width: 100%;
height: 3px;
background: rgba(255, 255, 255, 0.06);
border-radius: 2px;
overflow: hidden;
}
.stats-bubble-bar {
height: 100%;
background: linear-gradient(90deg, rgb(var(--accent-rgb)), rgba(var(--accent-rgb), 0.4));
border-radius: 2px;
transition: width 0.5s ease;
}
.stats-bubble-name {
font-size: 0.7em;
color: rgba(255, 255, 255, 0.7);
font-weight: 500;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.stats-bubble-count {
font-size: 0.65em;
color: rgba(var(--accent-rgb), 0.7);
font-weight: 600;
}
/* Ranked lists */
.stats-ranked-list {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 280px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
}
.stats-ranked-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 8px;
transition: background 0.15s;
cursor: default;
}
.stats-ranked-item:hover {
background: rgba(255, 255, 255, 0.04);
}
.stats-ranked-num {
font-size: 0.75em;
color: rgba(255, 255, 255, 0.25);
font-weight: 700;
width: 18px;
text-align: right;
flex-shrink: 0;
}
.stats-ranked-img {
width: 36px;
height: 36px;
border-radius: 6px;
object-fit: cover;
flex-shrink: 0;
background: rgba(255, 255, 255, 0.05);
}
.stats-ranked-info {
flex: 1;
min-width: 0;
}
.stats-ranked-name {
font-size: 0.88em;
color: rgba(255, 255, 255, 0.85);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.stats-ranked-meta {
font-size: 0.72em;
color: rgba(255, 255, 255, 0.4);
}
.stats-ranked-count {
font-size: 0.78em;
color: rgba(var(--accent-rgb), 0.8);
font-weight: 600;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.stats-artist-link {
color: inherit;
text-decoration: none;
cursor: pointer;
transition: color 0.15s;
}
.stats-artist-link:hover {
color: rgb(var(--accent-rgb));
}
/* Library health */
.stats-health-grid {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr;
gap: 16px;
align-items: start;
}
.stats-health-item {
text-align: center;
}
.stats-health-item:first-child {
text-align: left;
}
.stats-health-value {
font-size: 1.6em;
font-weight: 700;
color: #fff;
line-height: 1.2;
margin-bottom: 4px;
}
.stats-health-label {
font-size: 0.75em;
color: rgba(255, 255, 255, 0.4);
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 600;
}
/* Format breakdown bar */
.stats-format-bar {
display: flex;
height: 28px;
border-radius: 8px;
overflow: hidden;
margin-top: 8px;
background: rgba(255, 255, 255, 0.04);
}
.stats-format-segment {
display: flex;
align-items: center;
justify-content: center;
font-size: 0.68em;
font-weight: 600;
color: #fff;
white-space: nowrap;
min-width: 30px;
transition: flex 0.5s ease;
}
/* Recent plays */
.stats-recent-list {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 300px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
}
.stats-recent-item {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 8px;
border-radius: 6px;
}
.stats-recent-item:hover {
background: rgba(255, 255, 255, 0.03);
}
.stats-recent-title {
flex: 1;
font-size: 0.85em;
color: rgba(255, 255, 255, 0.8);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.stats-recent-artist {
font-size: 0.78em;
color: rgba(255, 255, 255, 0.4);
flex-shrink: 0;
}
.stats-recent-time {
font-size: 0.72em;
color: rgba(255, 255, 255, 0.25);
flex-shrink: 0;
min-width: 65px;
text-align: right;
}
/* Enrichment coverage */
.stats-enrichment {
display: flex;
gap: 16px;
margin-top: 16px;
padding-top: 14px;
border-top: 1px solid rgba(255, 255, 255, 0.04);
flex-wrap: wrap;
}
.stats-enrich-item {
flex: 1;
min-width: 120px;
display: flex;
align-items: center;
gap: 8px;
}
.stats-enrich-name {
font-size: 0.72em;
color: rgba(255, 255, 255, 0.45);
min-width: 70px;
font-weight: 500;
}
.stats-enrich-bar {
flex: 1;
height: 4px;
background: rgba(255, 255, 255, 0.06);
border-radius: 2px;
overflow: hidden;
}
.stats-enrich-fill {
height: 100%;
border-radius: 2px;
transition: width 0.5s ease;
}
.stats-enrich-pct {
font-size: 0.72em;
color: rgba(255, 255, 255, 0.4);
font-variant-numeric: tabular-nums;
min-width: 30px;
text-align: right;
}
/* Stats empty state */
.stats-empty {
text-align: center;
padding: 80px 20px;
color: rgba(255, 255, 255, 0.5);
}
.stats-empty-icon {
font-size: 48px;
margin-bottom: 16px;
}
.stats-empty h3 {
font-size: 1.2em;
color: rgba(255, 255, 255, 0.7);
margin-bottom: 8px;
}
.stats-empty p {
font-size: 0.88em;
max-width: 400px;
margin: 0 auto;
line-height: 1.5;
}
/* Mobile responsive */
@media (max-width: 768px) {
.stats-container {
margin: 10px;
padding: 16px;
}
.stats-overview {
grid-template-columns: repeat(2, 1fr);
}
.stats-main-grid {
grid-template-columns: 1fr;
}
.stats-health-grid {
grid-template-columns: 1fr 1fr;
}
.stats-genre-chart-container {
flex-direction: column;
}
.stats-two-col {
grid-template-columns: 1fr;
}
.stats-genre-chart-container canvas {
width: 150px !important;
height: 150px !important;
}
.stats-header {
flex-direction: column;
align-items: flex-start;
padding: 16px;
}
.stats-header-controls {
flex-direction: column;
align-items: flex-start;
gap: 10px;
width: 100%;
}
.stats-card-value {
font-size: 1.5em;
}
}
/* ============================================================================
IMPORT PAGE
============================================================================ */
.import-page-container {
max-width: 1200px;
margin: 0 auto;