diff --git a/config/settings.py b/config/settings.py index 79fde560..97c958e7 100644 --- a/config/settings.py +++ b/config/settings.py @@ -433,6 +433,10 @@ class ConfigManager: "delete_original": False, "downsample_hires": False }, + "listening_stats": { + "enabled": True, + "poll_interval": 30 + }, "import": { "staging_path": "./Staging" }, diff --git a/core/jellyfin_client.py b/core/jellyfin_client.py index 94e0485d..bf1ead88 100644 --- a/core/jellyfin_client.py +++ b/core/jellyfin_client.py @@ -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() diff --git a/core/listening_stats_worker.py b/core/listening_stats_worker.py new file mode 100644 index 00000000..9a18074d --- /dev/null +++ b/core/listening_stats_worker.py @@ -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() diff --git a/core/navidrome_client.py b/core/navidrome_client.py index bd93b449..a4adba5e 100644 --- a/core/navidrome_client.py +++ b/core/navidrome_client.py @@ -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(): diff --git a/core/plex_client.py b/core/plex_client.py index 26f6fbdf..fd3de9ce 100644 --- a/core/plex_client.py +++ b/core/plex_client.py @@ -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: diff --git a/database/music_database.py b/database/music_database.py index 749efd1b..a61e8ce3 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -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).""" diff --git a/web_server.py b/web_server.py index c8f49ec2..80e27070 100644 --- a/web_server.py +++ b/web_server.py @@ -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: diff --git a/webui/index.html b/webui/index.html index 1cca0c62..0925dfc5 100644 --- a/webui/index.html +++ b/webui/index.html @@ -167,6 +167,10 @@ Library + + + + + +
+ + +
+ + + + +
+
+
0
+
Total Plays
+
+
+
0h
+
Listening Time
+
+
+
0
+
Artists
+
+
+
0
+
Albums
+
+
+
0
+
Tracks
+
+
+ + +
+
+
+
Listening Activity
+
+ +
+
+
+
Genre Breakdown
+
+ +
+
+
+
+
Recently Played
+
+
+
+
+
+
Top Artists
+
+
+
+
+
Top Albums
+
+
+
+
Top Tracks
+
+
+
+
+ + +
+
Library Health
+
+
+
Format Breakdown
+
+
+
+
0
+
Unplayed Tracks
+
+
+
0h
+
Total Duration
+
+
+
0
+
Total Tracks
+
+
+
+
+ + + + + +
@@ -6166,6 +6306,7 @@
+ diff --git a/webui/static/script.js b/webui/static/script.js index 04a0d771..a6e5b3da 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -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) => ` +
+ ${i + 1} + ${item.image_url ? `` : ''} +
+
${item.id ? `${_esc(item.name)}` : _esc(item.name)}${item.soul_id && !item.soul_id.startsWith('soul_unnamed_') ? ' ' : ''}
+
${item.global_listeners ? _fmt(item.global_listeners) + ' global listeners' : ''}
+
+ ${_fmt(item.play_count)} plays +
+ `); + + // Top Albums + _renderRankedList('stats-top-albums', data.top_albums || [], (item, i) => ` +
+ ${i + 1} + ${item.image_url ? `` : ''} +
+
${_esc(item.name)}
+
${item.artist_id ? `${_esc(item.artist || '')}` : _esc(item.artist || '')}
+
+ ${_fmt(item.play_count)} plays +
+ `); + + // Top Tracks + _renderRankedList('stats-top-tracks', data.top_tracks || [], (item, i) => ` +
+ ${i + 1} + ${item.image_url ? `` : ''} +
+
${_esc(item.name)}
+
${item.artist_id ? `${_esc(item.artist || '')}` : _esc(item.artist || '')}${item.album ? ' · ' + _esc(item.album) : ''}
+
+ ${_fmt(item.play_count)} plays +
+ `); + + // 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 = `
+ ${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 `
+
+ ${!a.image_url ? `${(a.name || '?')[0]}` : ''} +
+
+
+
+
${_esc(a.name)}
+
${_fmt(a.play_count)}
+
`; + }).join('')} +
`; +} + +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('') + : '
No data yet
'; +} + +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) => ` +
+ + ${g.genre} + ${g.percentage}% +
+ `).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 `
${pct > 8 ? fmt : ''}
`; + }).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 => ` +
+ ${s.name} +
+ ${s.pct}% +
+ `).join(''); + } +} + +function _renderRecentPlays(tracks) { + const el = document.getElementById('stats-recent-plays'); + if (!el) return; + + if (!tracks.length) { + el.innerHTML = '
No recent plays
'; + 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 => ` +
+ ${_esc(t.title)} + ${_esc(t.artist || '')} + ${_ago(t.played_at)} +
+ `).join(''); +} + // --- Initialization --- function initializeImportPage() { diff --git a/webui/static/style.css b/webui/static/style.css index 94ece033..671c60a2 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -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;