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:
parent
aca8a8e996
commit
cfb0e85564
10 changed files with 2497 additions and 3 deletions
|
|
@ -433,6 +433,10 @@ class ConfigManager:
|
|||
"delete_original": False,
|
||||
"downsample_hires": False
|
||||
},
|
||||
"listening_stats": {
|
||||
"enabled": True,
|
||||
"poll_interval": 30
|
||||
},
|
||||
"import": {
|
||||
"staging_path": "./Staging"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
366
core/listening_stats_worker.py
Normal file
366
core/listening_stats_worker.py
Normal 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()
|
||||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)."""
|
||||
|
|
|
|||
281
web_server.py
281
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:
|
||||
|
|
|
|||
141
webui/index.html
141
webui/index.html
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue