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,
|
"delete_original": False,
|
||||||
"downsample_hires": False
|
"downsample_hires": False
|
||||||
},
|
},
|
||||||
|
"listening_stats": {
|
||||||
|
"enabled": True,
|
||||||
|
"poll_interval": 30
|
||||||
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"staging_path": "./Staging"
|
"staging_path": "./Staging"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1085,6 +1085,90 @@ class JellyfinClient:
|
||||||
logger.error(f"Error getting library stats: {e}")
|
logger.error(f"Error getting library stats: {e}")
|
||||||
return {}
|
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):
|
def clear_cache(self):
|
||||||
"""Clear all caches to force fresh data on next request"""
|
"""Clear all caches to force fresh data on next request"""
|
||||||
self._album_cache.clear()
|
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}")
|
logger.error(f"Error getting library stats: {e}")
|
||||||
return {}
|
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]:
|
def get_all_playlists(self) -> List[NavidromePlaylistInfo]:
|
||||||
"""Get all playlists from Navidrome server"""
|
"""Get all playlists from Navidrome server"""
|
||||||
if not self.ensure_connection():
|
if not self.ensure_connection():
|
||||||
|
|
|
||||||
|
|
@ -612,6 +612,60 @@ class PlexClient:
|
||||||
logger.error(f"Error getting library stats: {e}")
|
logger.error(f"Error getting library stats: {e}")
|
||||||
return {}
|
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]:
|
def get_all_artists(self) -> List[PlexArtist]:
|
||||||
"""Get all artists from the music library"""
|
"""Get all artists from the music library"""
|
||||||
if not self.ensure_connection() or not self.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_listenbrainz_support(cursor)
|
||||||
self._add_profile_service_credentials(cursor)
|
self._add_profile_service_credentials(cursor)
|
||||||
self._add_soul_id_columns(cursor)
|
self._add_soul_id_columns(cursor)
|
||||||
|
self._add_listening_history_table(cursor)
|
||||||
|
|
||||||
# Spotify library cache
|
# Spotify library cache
|
||||||
self._add_spotify_library_cache_table(cursor)
|
self._add_spotify_library_cache_table(cursor)
|
||||||
|
|
@ -2662,6 +2663,378 @@ class MusicDatabase:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error adding soul_id columns: {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,
|
def set_profile_spotify(self, profile_id: int, client_id: str, client_secret: str,
|
||||||
redirect_uri: str = '') -> bool:
|
redirect_uri: str = '') -> bool:
|
||||||
"""Save Spotify API credentials for a profile (encrypted)."""
|
"""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:
|
if 'active_media_server' in new_settings:
|
||||||
config_manager.set_active_media_server(new_settings['active_media_server'])
|
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:
|
if service in new_settings:
|
||||||
for key, value in new_settings[service].items():
|
for key, value in new_settings[service].items():
|
||||||
config_manager.set(f'{service}.{key}', value)
|
config_manager.set(f'{service}.{key}', value)
|
||||||
|
|
@ -41454,6 +41454,284 @@ def soulid_status():
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'error': str(e)}), 500
|
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
|
# Repair Worker — Library maintenance and repair jobs
|
||||||
# ===================================================================
|
# ===================================================================
|
||||||
|
|
@ -42977,6 +43255,7 @@ def _emit_enrichment_status_loop():
|
||||||
'qobuz-enrichment': lambda: qobuz_enrichment_worker,
|
'qobuz-enrichment': lambda: qobuz_enrichment_worker,
|
||||||
'hydrabase': lambda: hydrabase_worker,
|
'hydrabase': lambda: hydrabase_worker,
|
||||||
'soulid': lambda: soulid_worker,
|
'soulid': lambda: soulid_worker,
|
||||||
|
'listening-stats': lambda: listening_stats_worker,
|
||||||
'repair': lambda: repair_worker,
|
'repair': lambda: repair_worker,
|
||||||
}
|
}
|
||||||
while True:
|
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-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>
|
<span class="nav-text">Library</span>
|
||||||
</button>
|
</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">
|
<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-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>
|
<span class="nav-text">Import</span>
|
||||||
|
|
@ -4656,6 +4660,25 @@
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Discovery Settings -->
|
||||||
<div class="settings-group" data-stg="advanced">
|
<div class="settings-group" data-stg="advanced">
|
||||||
<h3>🔍 Discovery Pool Settings</h3>
|
<h3>🔍 Discovery Pool Settings</h3>
|
||||||
|
|
@ -4985,6 +5008,123 @@
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Import Page -->
|
||||||
<div class="page" id="import-page">
|
<div class="page" id="import-page">
|
||||||
<div class="import-page-container">
|
<div class="import-page-container">
|
||||||
|
|
@ -6166,6 +6306,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="{{ url_for('static', filename='vendor/socket.io.min.js') }}"></script>
|
<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='script.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='docs.js') }}"></script>
|
<script src="{{ url_for('static', filename='docs.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='particles.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:hydrabase', (data) => updateHydrabaseStatusFromData(data));
|
||||||
socket.on('enrichment:repair', (data) => updateRepairStatusFromData(data));
|
socket.on('enrichment:repair', (data) => updateRepairStatusFromData(data));
|
||||||
socket.on('enrichment:soulid', (data) => updateSoulIDStatusFromData(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));
|
socket.on('repair:progress', (data) => updateRepairJobProgressFromData(data));
|
||||||
|
|
||||||
// Phase 4 event listeners (tool progress)
|
// Phase 4 event listeners (tool progress)
|
||||||
|
|
@ -2624,6 +2625,9 @@ async function loadPageData(pageId) {
|
||||||
loadApiKeys();
|
loadApiKeys();
|
||||||
switchSettingsTab('connections');
|
switchSettingsTab('connections');
|
||||||
break;
|
break;
|
||||||
|
case 'stats':
|
||||||
|
initializeStatsPage();
|
||||||
|
break;
|
||||||
case 'import':
|
case 'import':
|
||||||
initializeImportPage();
|
initializeImportPage();
|
||||||
break;
|
break;
|
||||||
|
|
@ -5579,6 +5583,10 @@ async function loadSettingsData() {
|
||||||
document.getElementById('lossy-copy-codec').value = settings.lossy_copy?.codec || 'mp3';
|
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-bitrate').value = settings.lossy_copy?.bitrate || '320';
|
||||||
document.getElementById('lossy-copy-delete-original').checked = settings.lossy_copy?.delete_original === true;
|
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 =
|
document.getElementById('lossy-copy-options').style.display =
|
||||||
settings.lossy_copy?.enabled ? 'block' : 'none';
|
settings.lossy_copy?.enabled ? 'block' : 'none';
|
||||||
|
|
||||||
|
|
@ -5785,10 +5793,12 @@ function updateDownloadSourceUI() {
|
||||||
youtubeContainer.style.display = activeSources.has('youtube') ? 'block' : 'none';
|
youtubeContainer.style.display = activeSources.has('youtube') ? 'block' : 'none';
|
||||||
hifiContainer.style.display = activeSources.has('hifi') ? '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');
|
const qualityProfileSection = document.getElementById('quality-profile-section');
|
||||||
if (qualityProfileSection) {
|
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')) {
|
if (activeSources.has('tidal')) {
|
||||||
|
|
@ -6564,6 +6574,10 @@ async function saveSettings(quiet = false) {
|
||||||
delete_original: document.getElementById('lossy-copy-delete-original').checked,
|
delete_original: document.getElementById('lossy-copy-delete-original').checked,
|
||||||
downsample_hires: document.getElementById('downsample-hires').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: {
|
import: {
|
||||||
staging_path: document.getElementById('staging-path').value || './Staging'
|
staging_path: document.getElementById('staging-path').value || './Staging'
|
||||||
},
|
},
|
||||||
|
|
@ -54673,6 +54687,386 @@ const importPageState = {
|
||||||
tapSelectedChip: null, // for mobile tap-to-assign fallback
|
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 ---
|
// --- Initialization ---
|
||||||
|
|
||||||
function initializeImportPage() {
|
function initializeImportPage() {
|
||||||
|
|
|
||||||
|
|
@ -32548,6 +32548,705 @@ body {
|
||||||
IMPORT PAGE (full page, replaces modal)
|
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 {
|
.import-page-container {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue