@@ -6166,6 +6306,7 @@
+
diff --git a/webui/static/script.js b/webui/static/script.js
index 04a0d771..a6e5b3da 100644
--- a/webui/static/script.js
+++ b/webui/static/script.js
@@ -350,6 +350,7 @@ function initializeWebSocket() {
socket.on('enrichment:hydrabase', (data) => updateHydrabaseStatusFromData(data));
socket.on('enrichment:repair', (data) => updateRepairStatusFromData(data));
socket.on('enrichment:soulid', (data) => updateSoulIDStatusFromData(data));
+ socket.on('enrichment:listening-stats', () => {}); // Status only, no UI update needed
socket.on('repair:progress', (data) => updateRepairJobProgressFromData(data));
// Phase 4 event listeners (tool progress)
@@ -2624,6 +2625,9 @@ async function loadPageData(pageId) {
loadApiKeys();
switchSettingsTab('connections');
break;
+ case 'stats':
+ initializeStatsPage();
+ break;
case 'import':
initializeImportPage();
break;
@@ -5579,6 +5583,10 @@ async function loadSettingsData() {
document.getElementById('lossy-copy-codec').value = settings.lossy_copy?.codec || 'mp3';
document.getElementById('lossy-copy-bitrate').value = settings.lossy_copy?.bitrate || '320';
document.getElementById('lossy-copy-delete-original').checked = settings.lossy_copy?.delete_original === true;
+
+ // Populate Listening Stats settings
+ document.getElementById('listening-stats-enabled').checked = settings.listening_stats?.enabled === true;
+ document.getElementById('listening-stats-interval').value = settings.listening_stats?.poll_interval || 30;
document.getElementById('lossy-copy-options').style.display =
settings.lossy_copy?.enabled ? 'block' : 'none';
@@ -5785,10 +5793,12 @@ function updateDownloadSourceUI() {
youtubeContainer.style.display = activeSources.has('youtube') ? 'block' : 'none';
hifiContainer.style.display = activeSources.has('hifi') ? 'block' : 'none';
- // Quality profile is Soulseek-only (streaming sources handle quality via their own settings)
+ // Quality profile is Soulseek-only and downloads-tab-only
const qualityProfileSection = document.getElementById('quality-profile-section');
if (qualityProfileSection) {
- qualityProfileSection.style.display = activeSources.has('soulseek') ? '' : 'none';
+ const activeTab = document.querySelector('.stg-tab.active');
+ const onDownloadsTab = activeTab && activeTab.dataset.tab === 'downloads';
+ qualityProfileSection.style.display = (activeSources.has('soulseek') && onDownloadsTab) ? '' : 'none';
}
if (activeSources.has('tidal')) {
@@ -6564,6 +6574,10 @@ async function saveSettings(quiet = false) {
delete_original: document.getElementById('lossy-copy-delete-original').checked,
downsample_hires: document.getElementById('downsample-hires').checked
},
+ listening_stats: {
+ enabled: document.getElementById('listening-stats-enabled').checked,
+ poll_interval: parseInt(document.getElementById('listening-stats-interval').value) || 30,
+ },
import: {
staging_path: document.getElementById('staging-path').value || './Staging'
},
@@ -54673,6 +54687,386 @@ const importPageState = {
tapSelectedChip: null, // for mobile tap-to-assign fallback
};
+// ===============================
+// STATS PAGE
+// ===============================
+
+let _statsRange = '7d';
+let _statsTimelineChart = null;
+let _statsGenreChart = null;
+let _statsInitialized = false;
+
+function initializeStatsPage() {
+ if (_statsInitialized) {
+ loadStatsData();
+ return;
+ }
+ _statsInitialized = true;
+
+ // Time range buttons
+ const rangeContainer = document.getElementById('stats-time-range');
+ if (rangeContainer) {
+ rangeContainer.addEventListener('click', (e) => {
+ const btn = e.target.closest('.stats-range-btn');
+ if (!btn) return;
+ _statsRange = btn.dataset.range;
+ rangeContainer.querySelectorAll('.stats-range-btn').forEach(b => b.classList.remove('active'));
+ btn.classList.add('active');
+ loadStatsData();
+ });
+ }
+
+ loadStatsData();
+ _updateStatsLastSynced();
+}
+
+async function triggerStatsSync() {
+ const btn = document.getElementById('stats-sync-btn');
+ if (btn) btn.classList.add('syncing');
+
+ try {
+ const resp = await fetch('/api/listening-stats/sync', { method: 'POST' });
+ const data = await resp.json();
+ if (data.success) {
+ showToast('Syncing listening data...', 'info');
+ // Wait a few seconds for the sync to complete, then reload
+ setTimeout(async () => {
+ await loadStatsData();
+ _updateStatsLastSynced();
+ if (btn) btn.classList.remove('syncing');
+ showToast('Listening stats updated', 'success');
+ }, 5000);
+ } else {
+ showToast(data.error || 'Sync failed', 'error');
+ if (btn) btn.classList.remove('syncing');
+ }
+ } catch (e) {
+ showToast('Sync failed', 'error');
+ if (btn) btn.classList.remove('syncing');
+ }
+}
+
+async function _updateStatsLastSynced() {
+ const el = document.getElementById('stats-last-synced');
+ if (!el) return;
+ try {
+ const resp = await fetch('/api/listening-stats/status');
+ const data = await resp.json();
+ if (data.stats && data.stats.last_poll) {
+ el.textContent = `Last synced: ${data.stats.last_poll}`;
+ } else {
+ el.textContent = 'Not synced yet';
+ }
+ } catch {
+ el.textContent = '';
+ }
+}
+
+async function loadStatsData() {
+ // Show loading state
+ document.querySelectorAll('.stats-card-value').forEach(el => el.style.opacity = '0.3');
+
+ // Single cached endpoint — instant response
+ let data;
+ try {
+ const resp = await fetch(`/api/stats/cached?range=${_statsRange}`);
+ data = await resp.json();
+ } catch {
+ data = {};
+ }
+
+ if (!data.success) {
+ // Cache not available — show empty state, user should hit Sync
+ data = { overview: {}, top_artists: [], top_albums: [], top_tracks: [],
+ timeline: [], genres: [], recent: [], health: {} };
+ }
+
+ const overview = data.overview || {};
+ const emptyEl = document.getElementById('stats-empty');
+ const hasData = (overview.total_plays || 0) > 0;
+
+ if (emptyEl) {
+ emptyEl.classList.toggle('hidden', hasData);
+ }
+ // Hide main content sections when no data
+ const mainSections = document.querySelectorAll('.stats-overview, .stats-main-grid, .stats-full-width');
+ mainSections.forEach(el => el.style.display = hasData ? '' : 'none');
+
+ // Overview cards
+ const _fmt = (n) => {
+ if (!n) return '0';
+ if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
+ if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K';
+ return n.toLocaleString();
+ };
+ const _fmtTime = (ms) => {
+ if (!ms) return '0h';
+ const hours = Math.floor(ms / 3600000);
+ const mins = Math.floor((ms % 3600000) / 60000);
+ if (hours > 0) return `${hours}h ${mins}m`;
+ return `${mins}m`;
+ };
+
+ // Restore opacity
+ document.querySelectorAll('.stats-card-value').forEach(el => el.style.opacity = '1');
+
+ _setText('stats-total-plays', _fmt(overview.total_plays));
+ _setText('stats-listening-time', _fmtTime(overview.total_time_ms));
+ _setText('stats-unique-artists', _fmt(overview.unique_artists));
+ _setText('stats-unique-albums', _fmt(overview.unique_albums));
+ _setText('stats-unique-tracks', _fmt(overview.unique_tracks));
+
+ // Top Artists — visual bubbles
+ _renderTopArtistsVisual(data.top_artists || []);
+
+ // Top Artists — ranked list
+ _renderRankedList('stats-top-artists', data.top_artists || [], (item, i) => `
+
+
${i + 1}
+ ${item.image_url ? `

` : ''}
+
+
${item.id ? `
${_esc(item.name)}` : _esc(item.name)}${item.soul_id && !item.soul_id.startsWith('soul_unnamed_') ? '

' : ''}
+
${item.global_listeners ? _fmt(item.global_listeners) + ' global listeners' : ''}
+
+
${_fmt(item.play_count)} plays
+
+ `);
+
+ // Top Albums
+ _renderRankedList('stats-top-albums', data.top_albums || [], (item, i) => `
+
+
${i + 1}
+ ${item.image_url ? `

` : ''}
+
+
${_fmt(item.play_count)} plays
+
+ `);
+
+ // Top Tracks
+ _renderRankedList('stats-top-tracks', data.top_tracks || [], (item, i) => `
+
+
${i + 1}
+ ${item.image_url ? `

` : ''}
+
+
${_fmt(item.play_count)} plays
+
+ `);
+
+ // Timeline chart
+ _renderTimelineChart(data.timeline || []);
+
+ // Genre chart
+ _renderGenreChart(data.genres || []);
+
+ // Library health
+ _renderLibraryHealth(data.health || {});
+
+ // Recent plays
+ _renderRecentPlays(data.recent || []);
+}
+
+function _renderTopArtistsVisual(artists) {
+ const el = document.getElementById('stats-top-artists-visual');
+ if (!el || !artists.length) { if (el) el.innerHTML = ''; return; }
+
+ const top5 = artists.slice(0, 5);
+ const maxPlays = top5[0]?.play_count || 1;
+ const _fmt = (n) => {
+ if (!n) return '0';
+ if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
+ if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K';
+ return n.toString();
+ };
+
+ el.innerHTML = `
+ ${top5.map((a, i) => {
+ const pct = Math.round((a.play_count / maxPlays) * 100);
+ const size = 44 + (4 - i) * 6; // Largest first: 68, 62, 56, 50, 44
+ return `
+
+ ${!a.image_url ? `${(a.name || '?')[0]}` : ''}
+
+
+
${_esc(a.name)}
+
${_fmt(a.play_count)}
+
`;
+ }).join('')}
+
`;
+}
+
+function _setText(id, text) {
+ const el = document.getElementById(id);
+ if (el) el.textContent = text;
+}
+
+function _renderRankedList(containerId, items, template) {
+ const el = document.getElementById(containerId);
+ if (!el) return;
+ el.innerHTML = items.length
+ ? items.map((item, i) => template(item, i)).join('')
+ : '
No data yet
';
+}
+
+function _renderTimelineChart(data) {
+ const canvas = document.getElementById('stats-timeline-chart');
+ if (!canvas || typeof Chart === 'undefined') return;
+
+ if (_statsTimelineChart) _statsTimelineChart.destroy();
+
+ _statsTimelineChart = new Chart(canvas, {
+ type: 'bar',
+ data: {
+ labels: data.map(d => d.date),
+ datasets: [{
+ label: 'Plays',
+ data: data.map(d => d.plays),
+ backgroundColor: `rgba(${getComputedStyle(document.documentElement).getPropertyValue('--accent-rgb').trim() || '29,185,84'}, 0.5)`,
+ borderColor: `rgba(${getComputedStyle(document.documentElement).getPropertyValue('--accent-rgb').trim() || '29,185,84'}, 0.8)`,
+ borderWidth: 1,
+ borderRadius: 4,
+ }]
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: { legend: { display: false } },
+ scales: {
+ x: { grid: { display: false }, ticks: { color: 'rgba(255,255,255,0.3)', font: { size: 10 }, maxTicksLimit: 12 } },
+ y: { grid: { color: 'rgba(255,255,255,0.04)' }, ticks: { color: 'rgba(255,255,255,0.3)', font: { size: 10 } }, beginAtZero: true },
+ }
+ }
+ });
+}
+
+function _renderGenreChart(data) {
+ const canvas = document.getElementById('stats-genre-chart');
+ const legend = document.getElementById('stats-genre-legend');
+ if (!canvas || typeof Chart === 'undefined') return;
+
+ if (_statsGenreChart) _statsGenreChart.destroy();
+
+ const colors = [
+ '#1db954', '#1ed760', '#4ade80', '#7c3aed', '#a855f7',
+ '#ec4899', '#f43f5e', '#f97316', '#eab308', '#06b6d4',
+ '#3b82f6', '#6366f1', '#14b8a6', '#84cc16', '#f59e0b',
+ ];
+
+ const top = data.slice(0, 10);
+
+ _statsGenreChart = new Chart(canvas, {
+ type: 'doughnut',
+ data: {
+ labels: top.map(g => g.genre),
+ datasets: [{
+ data: top.map(g => g.play_count),
+ backgroundColor: colors.slice(0, top.length),
+ borderWidth: 0,
+ hoverOffset: 6,
+ }]
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: true,
+ cutout: '65%',
+ plugins: { legend: { display: false } },
+ }
+ });
+
+ if (legend) {
+ legend.innerHTML = top.map((g, i) => `
+
+
+ ${g.genre}
+ ${g.percentage}%
+
+ `).join('');
+ }
+}
+
+function _renderLibraryHealth(data) {
+ if (!data || !data.total_tracks) return;
+
+ const _fmt = (n) => {
+ if (!n) return '0';
+ if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
+ if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
+ return n.toLocaleString();
+ };
+
+ _setText('stats-unplayed', `${_fmt(data.unplayed_count)} (${data.unplayed_percentage || 0}%)`);
+ _setText('stats-total-duration', data.total_duration_ms ? `${Math.floor(data.total_duration_ms / 3600000)}h` : '0h');
+ _setText('stats-total-tracks-count', _fmt(data.total_tracks));
+
+ // Format bar
+ const bar = document.getElementById('stats-format-bar');
+ if (bar && data.format_breakdown) {
+ const total = Object.values(data.format_breakdown).reduce((s, v) => s + v, 0) || 1;
+ const fmtColors = { FLAC: '#3b82f6', MP3: '#f97316', Opus: '#a855f7', AAC: '#14b8a6', OGG: '#eab308', WAV: '#ec4899', Other: '#555' };
+
+ bar.innerHTML = Object.entries(data.format_breakdown).map(([fmt, count]) => {
+ const pct = (count / total * 100).toFixed(1);
+ return `
${pct > 8 ? fmt : ''}
`;
+ }).join('');
+ }
+
+ // Enrichment coverage
+ const enrichEl = document.getElementById('stats-enrichment-coverage');
+ if (enrichEl && data.enrichment_coverage) {
+ const ec = data.enrichment_coverage;
+ const services = [
+ { name: 'Spotify', pct: ec.spotify || 0, color: '#1db954' },
+ { name: 'MusicBrainz', pct: ec.musicbrainz || 0, color: '#ba55d3' },
+ { name: 'Deezer', pct: ec.deezer || 0, color: '#a238ff' },
+ { name: 'Last.fm', pct: ec.lastfm || 0, color: '#d51007' },
+ ];
+ enrichEl.innerHTML = services.map(s => `
+
+
${s.name}
+
+
${s.pct}%
+
+ `).join('');
+ }
+}
+
+function _renderRecentPlays(tracks) {
+ const el = document.getElementById('stats-recent-plays');
+ if (!el) return;
+
+ if (!tracks.length) {
+ el.innerHTML = '
No recent plays
';
+ return;
+ }
+
+ const _ago = (dateStr) => {
+ if (!dateStr) return '';
+ const diff = Date.now() - new Date(dateStr).getTime();
+ const mins = Math.floor(diff / 60000);
+ if (mins < 60) return `${mins}m ago`;
+ const hours = Math.floor(mins / 60);
+ if (hours < 24) return `${hours}h ago`;
+ const days = Math.floor(hours / 24);
+ if (days < 30) return `${days}d ago`;
+ return `${Math.floor(days / 30)}mo ago`;
+ };
+
+ el.innerHTML = tracks.map(t => `
+
+ ${_esc(t.title)}
+ ${_esc(t.artist || '')}
+ ${_ago(t.played_at)}
+
+ `).join('');
+}
+
// --- Initialization ---
function initializeImportPage() {
diff --git a/webui/static/style.css b/webui/static/style.css
index 94ece033..671c60a2 100644
--- a/webui/static/style.css
+++ b/webui/static/style.css
@@ -32548,6 +32548,705 @@ body {
IMPORT PAGE (full page, replaces modal)
======================================== */
+/* ============================================================================
+ STATS PAGE
+ ============================================================================ */
+
+/* Stats page uses dashboard-container pattern for consistency */
+.stats-container {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+ padding: 28px 24px 30px;
+ background: linear-gradient(135deg,
+ rgba(20, 20, 20, 0.55) 0%,
+ rgba(12, 12, 12, 0.62) 100%);
+ border-radius: 24px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-top: 1px solid rgba(255, 255, 255, 0.12);
+ margin: 20px;
+ box-shadow:
+ 0 8px 32px rgba(0, 0, 0, 0.3),
+ 0 4px 16px rgba(0, 0, 0, 0.2),
+ inset 0 1px 0 rgba(255, 255, 255, 0.08);
+}
+
+/* Header uses same pattern as .dashboard-header */
+.stats-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 20px 24px;
+ margin: -28px -24px 0 -24px;
+ position: relative;
+ overflow: hidden;
+ flex-wrap: wrap;
+ gap: 16px;
+ background: linear-gradient(180deg,
+ rgba(var(--accent-rgb), 0.10) 0%,
+ rgba(var(--accent-rgb), 0.04) 40%,
+ transparent 100%);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
+ border-top-left-radius: 24px;
+ border-top-right-radius: 24px;
+}
+
+.stats-header::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: -100%;
+ width: 50%;
+ height: 100%;
+ background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.03), transparent);
+ animation: stats-header-sweep 12s ease-in-out infinite;
+}
+
+@keyframes stats-header-sweep {
+ 0%, 100% { left: -100%; }
+ 50% { left: 150%; }
+}
+
+.stats-header-title {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+}
+
+.stats-header-title h1 {
+ font-size: 28px;
+ font-weight: 700;
+ color: #fff;
+ margin: 0;
+ font-family: 'SF Pro Display', -apple-system, sans-serif;
+}
+
+/* Time range pills */
+.stats-time-range {
+ display: flex;
+ gap: 4px;
+ background: rgba(255, 255, 255, 0.04);
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ border-radius: 10px;
+ padding: 3px;
+}
+
+.stats-range-btn {
+ padding: 7px 16px;
+ border: none;
+ border-radius: 8px;
+ background: transparent;
+ color: rgba(255, 255, 255, 0.5);
+ font-size: 0.82em;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s;
+ font-family: inherit;
+}
+
+.stats-range-btn:hover {
+ color: rgba(255, 255, 255, 0.8);
+ background: rgba(255, 255, 255, 0.04);
+}
+
+.stats-range-btn.active {
+ background: rgb(var(--accent-rgb));
+ color: #fff;
+ box-shadow: 0 2px 8px rgba(var(--accent-rgb), 0.3);
+}
+
+.stats-header-controls {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+}
+
+.stats-sync-controls {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.stats-last-synced {
+ font-size: 0.72em;
+ color: rgba(255, 255, 255, 0.3);
+}
+
+.stats-sync-btn {
+ width: 32px;
+ height: 32px;
+ border-radius: 8px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ background: rgba(255, 255, 255, 0.04);
+ color: rgba(255, 255, 255, 0.5);
+ font-size: 16px;
+ cursor: pointer;
+ transition: all 0.2s;
+ font-family: inherit;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.stats-sync-btn:hover {
+ background: rgba(255, 255, 255, 0.08);
+ color: #fff;
+ border-color: rgba(255, 255, 255, 0.15);
+}
+
+.stats-sync-btn.syncing {
+ pointer-events: none;
+ color: transparent;
+ position: relative;
+}
+
+.stats-sync-btn.syncing::after {
+ content: '';
+ position: absolute;
+ width: 14px;
+ height: 14px;
+ border: 2px solid rgba(var(--accent-rgb), 0.2);
+ border-top-color: rgba(var(--accent-rgb), 0.8);
+ border-radius: 50%;
+ animation: stats-spin 0.8s linear infinite;
+}
+
+@keyframes stats-spin {
+ to { transform: rotate(360deg); }
+}
+
+/* Overview cards */
+.stats-overview {
+ display: grid;
+ grid-template-columns: repeat(5, 1fr);
+ gap: 14px;
+}
+
+.stats-card {
+ background: linear-gradient(135deg, rgba(20, 20, 20, 0.95) 0%, rgba(12, 12, 12, 0.98) 100%);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 14px;
+ padding: 20px;
+ text-align: center;
+ position: relative;
+ overflow: hidden;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.06);
+}
+
+.stats-card::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 20%;
+ right: 20%;
+ height: 2px;
+ background: linear-gradient(90deg, transparent, rgba(var(--accent-rgb), 0.5), transparent);
+ border-radius: 2px;
+ transition: all 0.3s;
+}
+
+.stats-card:hover {
+ transform: translateY(-3px);
+ border-color: rgba(var(--accent-rgb), 0.2);
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4), 0 0 20px rgba(var(--accent-rgb), 0.08);
+}
+
+.stats-card:hover::before {
+ left: 10%;
+ right: 10%;
+ height: 3px;
+ box-shadow: 0 0 12px rgba(var(--accent-rgb), 0.4);
+}
+
+.stats-card-value {
+ font-size: 2em;
+ font-weight: 700;
+ color: #fff;
+ line-height: 1.2;
+ margin-bottom: 6px;
+ font-family: 'SF Pro Display', -apple-system, sans-serif;
+}
+
+.stats-card-label {
+ font-size: 0.78em;
+ color: rgba(255, 255, 255, 0.45);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ font-weight: 600;
+}
+
+/* Main grid */
+.stats-main-grid {
+ display: grid;
+ grid-template-columns: 1fr 360px;
+ gap: 20px;
+}
+
+.stats-left-col, .stats-right-col {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.stats-two-col {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 20px;
+}
+
+/* Section cards */
+.stats-section-card {
+ background: rgba(255, 255, 255, 0.02);
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ border-radius: 14px;
+ padding: 20px;
+ transition: border-color 0.2s;
+}
+
+.stats-section-card:hover {
+ border-color: rgba(255, 255, 255, 0.1);
+}
+
+.stats-full-width {
+ /* No extra margin — container handles it */
+}
+
+.stats-section-title {
+ font-size: 0.78em;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: rgba(255, 255, 255, 0.4);
+ margin-bottom: 16px;
+ padding-bottom: 10px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
+}
+
+/* Genre chart */
+.stats-genre-chart-container {
+ display: flex;
+ align-items: center;
+ gap: 24px;
+}
+
+.stats-genre-chart-container canvas {
+ width: 180px !important;
+ height: 180px !important;
+ flex-shrink: 0;
+}
+
+.stats-genre-legend {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.stats-genre-legend-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 0.82em;
+ color: rgba(255, 255, 255, 0.7);
+}
+
+.stats-genre-dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 3px;
+ flex-shrink: 0;
+}
+
+.stats-genre-pct {
+ margin-left: auto;
+ color: rgba(255, 255, 255, 0.4);
+ font-variant-numeric: tabular-nums;
+}
+
+/* Top artists visual bubbles */
+.stats-top-artists-visual {
+ margin-bottom: 16px;
+ padding-bottom: 14px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.04);
+}
+
+.stats-artist-bubbles {
+ display: flex;
+ justify-content: space-around;
+ align-items: flex-end;
+ gap: 8px;
+}
+
+.stats-artist-bubble {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+ flex: 1;
+ transition: transform 0.2s;
+}
+
+.stats-artist-bubble:hover {
+ transform: translateY(-3px);
+}
+
+.stats-bubble-img {
+ border-radius: 50%;
+ background-size: cover;
+ background-position: center;
+ background-color: rgba(255, 255, 255, 0.06);
+ border: 2px solid rgba(var(--accent-rgb), 0.2);
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ transition: border-color 0.2s, box-shadow 0.2s;
+}
+
+.stats-artist-bubble:hover .stats-bubble-img {
+ border-color: rgba(var(--accent-rgb), 0.5);
+ box-shadow: 0 4px 20px rgba(var(--accent-rgb), 0.2);
+}
+
+.stats-bubble-img span {
+ font-size: 1.2em;
+ font-weight: 700;
+ color: rgba(255, 255, 255, 0.4);
+}
+
+.stats-bubble-bar-container {
+ width: 100%;
+ height: 3px;
+ background: rgba(255, 255, 255, 0.06);
+ border-radius: 2px;
+ overflow: hidden;
+}
+
+.stats-bubble-bar {
+ height: 100%;
+ background: linear-gradient(90deg, rgb(var(--accent-rgb)), rgba(var(--accent-rgb), 0.4));
+ border-radius: 2px;
+ transition: width 0.5s ease;
+}
+
+.stats-bubble-name {
+ font-size: 0.7em;
+ color: rgba(255, 255, 255, 0.7);
+ font-weight: 500;
+ text-align: center;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 100%;
+}
+
+.stats-bubble-count {
+ font-size: 0.65em;
+ color: rgba(var(--accent-rgb), 0.7);
+ font-weight: 600;
+}
+
+/* Ranked lists */
+.stats-ranked-list {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ max-height: 280px;
+ overflow-y: auto;
+ scrollbar-width: thin;
+ scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
+}
+
+.stats-ranked-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 10px;
+ border-radius: 8px;
+ transition: background 0.15s;
+ cursor: default;
+}
+
+.stats-ranked-item:hover {
+ background: rgba(255, 255, 255, 0.04);
+}
+
+.stats-ranked-num {
+ font-size: 0.75em;
+ color: rgba(255, 255, 255, 0.25);
+ font-weight: 700;
+ width: 18px;
+ text-align: right;
+ flex-shrink: 0;
+}
+
+.stats-ranked-img {
+ width: 36px;
+ height: 36px;
+ border-radius: 6px;
+ object-fit: cover;
+ flex-shrink: 0;
+ background: rgba(255, 255, 255, 0.05);
+}
+
+.stats-ranked-info {
+ flex: 1;
+ min-width: 0;
+}
+
+.stats-ranked-name {
+ font-size: 0.88em;
+ color: rgba(255, 255, 255, 0.85);
+ font-weight: 500;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.stats-ranked-meta {
+ font-size: 0.72em;
+ color: rgba(255, 255, 255, 0.4);
+}
+
+.stats-ranked-count {
+ font-size: 0.78em;
+ color: rgba(var(--accent-rgb), 0.8);
+ font-weight: 600;
+ flex-shrink: 0;
+ font-variant-numeric: tabular-nums;
+}
+
+.stats-artist-link {
+ color: inherit;
+ text-decoration: none;
+ cursor: pointer;
+ transition: color 0.15s;
+}
+
+.stats-artist-link:hover {
+ color: rgb(var(--accent-rgb));
+}
+
+/* Library health */
+.stats-health-grid {
+ display: grid;
+ grid-template-columns: 2fr 1fr 1fr 1fr;
+ gap: 16px;
+ align-items: start;
+}
+
+.stats-health-item {
+ text-align: center;
+}
+
+.stats-health-item:first-child {
+ text-align: left;
+}
+
+.stats-health-value {
+ font-size: 1.6em;
+ font-weight: 700;
+ color: #fff;
+ line-height: 1.2;
+ margin-bottom: 4px;
+}
+
+.stats-health-label {
+ font-size: 0.75em;
+ color: rgba(255, 255, 255, 0.4);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ font-weight: 600;
+}
+
+/* Format breakdown bar */
+.stats-format-bar {
+ display: flex;
+ height: 28px;
+ border-radius: 8px;
+ overflow: hidden;
+ margin-top: 8px;
+ background: rgba(255, 255, 255, 0.04);
+}
+
+.stats-format-segment {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.68em;
+ font-weight: 600;
+ color: #fff;
+ white-space: nowrap;
+ min-width: 30px;
+ transition: flex 0.5s ease;
+}
+
+/* Recent plays */
+.stats-recent-list {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ max-height: 300px;
+ overflow-y: auto;
+ scrollbar-width: thin;
+ scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
+}
+
+.stats-recent-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 6px 8px;
+ border-radius: 6px;
+}
+
+.stats-recent-item:hover {
+ background: rgba(255, 255, 255, 0.03);
+}
+
+.stats-recent-title {
+ flex: 1;
+ font-size: 0.85em;
+ color: rgba(255, 255, 255, 0.8);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.stats-recent-artist {
+ font-size: 0.78em;
+ color: rgba(255, 255, 255, 0.4);
+ flex-shrink: 0;
+}
+
+.stats-recent-time {
+ font-size: 0.72em;
+ color: rgba(255, 255, 255, 0.25);
+ flex-shrink: 0;
+ min-width: 65px;
+ text-align: right;
+}
+
+/* Enrichment coverage */
+.stats-enrichment {
+ display: flex;
+ gap: 16px;
+ margin-top: 16px;
+ padding-top: 14px;
+ border-top: 1px solid rgba(255, 255, 255, 0.04);
+ flex-wrap: wrap;
+}
+
+.stats-enrich-item {
+ flex: 1;
+ min-width: 120px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.stats-enrich-name {
+ font-size: 0.72em;
+ color: rgba(255, 255, 255, 0.45);
+ min-width: 70px;
+ font-weight: 500;
+}
+
+.stats-enrich-bar {
+ flex: 1;
+ height: 4px;
+ background: rgba(255, 255, 255, 0.06);
+ border-radius: 2px;
+ overflow: hidden;
+}
+
+.stats-enrich-fill {
+ height: 100%;
+ border-radius: 2px;
+ transition: width 0.5s ease;
+}
+
+.stats-enrich-pct {
+ font-size: 0.72em;
+ color: rgba(255, 255, 255, 0.4);
+ font-variant-numeric: tabular-nums;
+ min-width: 30px;
+ text-align: right;
+}
+
+/* Stats empty state */
+.stats-empty {
+ text-align: center;
+ padding: 80px 20px;
+ color: rgba(255, 255, 255, 0.5);
+}
+
+.stats-empty-icon {
+ font-size: 48px;
+ margin-bottom: 16px;
+}
+
+.stats-empty h3 {
+ font-size: 1.2em;
+ color: rgba(255, 255, 255, 0.7);
+ margin-bottom: 8px;
+}
+
+.stats-empty p {
+ font-size: 0.88em;
+ max-width: 400px;
+ margin: 0 auto;
+ line-height: 1.5;
+}
+
+/* Mobile responsive */
+@media (max-width: 768px) {
+ .stats-container {
+ margin: 10px;
+ padding: 16px;
+ }
+ .stats-overview {
+ grid-template-columns: repeat(2, 1fr);
+ }
+ .stats-main-grid {
+ grid-template-columns: 1fr;
+ }
+ .stats-health-grid {
+ grid-template-columns: 1fr 1fr;
+ }
+ .stats-genre-chart-container {
+ flex-direction: column;
+ }
+ .stats-two-col {
+ grid-template-columns: 1fr;
+ }
+ .stats-genre-chart-container canvas {
+ width: 150px !important;
+ height: 150px !important;
+ }
+ .stats-header {
+ flex-direction: column;
+ align-items: flex-start;
+ padding: 16px;
+ }
+ .stats-header-controls {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 10px;
+ width: 100%;
+ }
+ .stats-card-value {
+ font-size: 1.5em;
+ }
+}
+
+/* ============================================================================
+ IMPORT PAGE
+ ============================================================================ */
+
.import-page-container {
max-width: 1200px;
margin: 0 auto;