diff --git a/web_server.py b/web_server.py index fb541a4d..d641a2ab 100644 --- a/web_server.py +++ b/web_server.py @@ -14,6 +14,7 @@ import uuid import re import sqlite3 import types +import collections from pathlib import Path from urllib.parse import urljoin @@ -3866,6 +3867,36 @@ def index(): # --- API Endpoints --- +# Tracks cumulative item-processed totals over time for windowed counting. +# Each entry: (timestamp, cumulative_total). Polled every ~5s, 24h = ~17280 entries. +_enrichment_activity_log = {} # key -> deque of (timestamp, total) + +def _get_windowed_calls(key, current_total): + """Record current cumulative total and return (calls_1h, calls_24h). + Deque stores (timestamp, cumulative_total) in chronological order. + To get calls in a window: current_total minus the oldest total within that window.""" + now = time.time() + history = _enrichment_activity_log.setdefault(key, collections.deque(maxlen=17300)) + history.append((now, current_total)) + + cutoff_1h = now - 3600 + cutoff_24h = now - 86400 + + # Forward scan: first entry with ts >= cutoff is the oldest in that window + oldest_1h_total = current_total + oldest_24h_total = current_total + found_24h = False + for ts, total in history: + if not found_24h and ts >= cutoff_24h: + oldest_24h_total = total + found_24h = True + if ts >= cutoff_1h: + oldest_1h_total = total + break + + return max(0, current_total - oldest_1h_total), max(0, current_total - oldest_24h_total) + + def _get_enrichment_status(): """Get lightweight status for all enrichment services (no DB queries). Reads worker properties directly to avoid expensive get_stats() calls.""" @@ -3901,13 +3932,30 @@ def _get_enrichment_status(): configured = configured_checks.get(key, lambda: True)() except Exception: configured = False - services[key] = { + + # Compute windowed API call counts from cumulative stats + stats = worker.stats + total_processed = stats.get('matched', 0) + stats.get('not_found', 0) + stats.get('errors', 0) + calls_1h, calls_24h = _get_windowed_calls(key, total_processed) + + svc_data = { 'name': name, 'configured': configured, 'running': worker.running and is_alive and not worker.paused, 'paused': worker.paused, 'idle': is_alive and not worker.paused and getattr(worker, 'current_item', None) is None, + 'calls_1h': calls_1h, + 'calls_24h': calls_24h, } + + # Spotify-specific: include daily budget info + if key == 'spotify_enrichment': + try: + svc_data['daily_budget'] = worker._get_daily_budget_info() + except Exception: + pass + + services[key] = svc_data else: services[key] = { 'name': name, @@ -3915,6 +3963,8 @@ def _get_enrichment_status(): 'running': False, 'paused': False, 'idle': False, + 'calls_1h': 0, + 'calls_24h': 0, } # Non-worker services (configured status only) @@ -19190,7 +19240,8 @@ def get_version_info(): "description": "Dashboard now shows connection status for all external services, not just the core three", "features": [ "• Enrichment services shown as color-coded chips below core service cards", - "• Status indicators: Running, Idle, Paused, Stopped, or Not Configured with accent-colored left borders", + "• API call counts per service: 1-hour and 24-hour windowed totals shown on each chip", + "• Spotify chip includes daily budget bar (used/3000) with color-coded fill", "• Unconfigured services show dashed border — click to jump directly to their Settings section", "• All configurable services clickable — navigates to Settings → Connections and scrolls to the service", "• Spotify card always labeled 'Spotify' — no longer confusingly switches to 'Apple Music'", diff --git a/webui/index.html b/webui/index.html index 66bfe63e..7bcfa9b4 100644 --- a/webui/index.html +++ b/webui/index.html @@ -665,8 +665,13 @@ -
- +
+
+ +
+
+ +
diff --git a/webui/static/helper.js b/webui/static/helper.js index 98cded87..c24aea18 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3406,7 +3406,7 @@ const WHATS_NEW = { { title: 'Fix Collab Artist on Singles', desc: 'Single/playlist path templates now respect First Listed Artist setting — $albumartist available for all template types' }, { title: 'Fix Enrichment Breaking Manual Matches', desc: 'Enriching a manually matched artist no longer reverts status to not_found — uses stored ID for direct lookup' }, { title: 'Fix Spotify OAuth Empty Response', desc: 'OAuth callback server now always sends a response in Docker — added health check and proper logging' }, - { title: 'All Services on Dashboard', desc: 'Dashboard shows all enrichment services as live-status chips — click unconfigured ones to jump to Settings. Spotify card no longer shows "Apple Music"', page: 'dashboard' }, + { title: 'All Services on Dashboard', desc: 'Dashboard shows all enrichment services with live API call counts (1h/24h), Spotify budget bar, and click-to-configure', page: 'dashboard' }, { title: 'Qobuz on Connections Tab', desc: 'Qobuz credentials now on Settings → Connections for metadata enrichment without needing it as download source' }, { title: 'Fix Enrichment Status Widget', desc: 'Enrichment tooltip now shows Rate Limited or Daily Limit instead of stuck on Running' }, { title: 'Cache Maintenance', desc: 'Cache evictor now cleans junk entities, orphaned searches, and stale MusicBrainz nulls' }, diff --git a/webui/static/script.js b/webui/static/script.js index e1f6eed8..6edce13e 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -36239,15 +36239,55 @@ function renderEnrichmentCards(enrichment) { const clickAttr = selector ? `onclick="navigateToPage('settings'); setTimeout(() => { switchSettingsTab('connections'); setTimeout(() => { const el = document.querySelector('${selector}'); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); }, 100); }, 50);"` : ''; - const titleAttr = selector && statusClass === 'not-configured' - ? 'title="Click to configure in Settings"' - : `title="${svc.name} — ${statusLabel}"`; + + // Build activity display — human-readable, not cryptic numbers + let activityHtml = ''; + let metaHtml = ''; + const isSpotify = key === 'spotify_enrichment'; + + if ('running' in svc && svc.configured) { + const c1h = svc.calls_1h || 0; + const c24h = svc.calls_24h || 0; + + if (isSpotify && svc.daily_budget) { + // Spotify: show budget usage prominently + const b = svc.daily_budget; + const pct = Math.min(100, Math.round((b.used / b.limit) * 100)); + const barClass = b.exhausted ? 'exhausted' : pct > 80 ? 'high' : ''; + activityHtml = `${b.used.toLocaleString()} / ${b.limit.toLocaleString()}`; + metaHtml = `
+
+
`; + } else if (c24h > 0) { + // Other services: show 24h count + activityHtml = `${c24h.toLocaleString()} / 24h`; + } + } + + // Tooltip: full details including 1h breakdown + let tooltipLines = [svc.name + ' — ' + statusLabel]; + if ('running' in svc && svc.configured) { + const c1h = svc.calls_1h || 0; + const c24h = svc.calls_24h || 0; + if (c24h > 0 || c1h > 0) tooltipLines.push('Last hour: ' + c1h + ' · Last 24h: ' + c24h); + } + if (isSpotify && svc.daily_budget) { + const b = svc.daily_budget; + tooltipLines.push('Daily budget: ' + b.used + ' / ' + b.limit + (b.exhausted ? ' (exhausted)' : '')); + } + if (selector && statusClass === 'not-configured') { + tooltipLines = ['Click to configure in Settings']; + } + + const statusDisplay = statusClass === 'not-configured' && selector ? 'Configure →' : statusLabel; chips.push(` -
+
${svc.name} - ${statusLabel} + ${activityHtml} + ${statusDisplay} + ${metaHtml}
`); } diff --git a/webui/static/style.css b/webui/static/style.css index c89a08f3..daee60eb 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -7462,14 +7462,31 @@ body.helper-mode-active #dashboard-activity-feed:hover { } -/* Enrichment Services Grid */ +/* Enrichment Services Section */ +.enrichment-section { + margin-top: 16px; + padding-top: 14px; + border-top: 1px solid rgba(255, 255, 255, 0.04); +} + +.enrichment-section-header { + display: flex; + align-items: center; + margin-bottom: 10px; +} + +.enrichment-section-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.8px; + color: rgba(255, 255, 255, 0.3); +} + .enrichment-status-grid { display: flex; flex-wrap: wrap; gap: 8px; - margin-top: 16px; - padding-top: 16px; - border-top: 1px solid rgba(255, 255, 255, 0.04); } .enrichment-chip { @@ -7594,8 +7611,6 @@ body.helper-mode-active #dashboard-activity-feed:hover { font-size: 10px; color: rgba(255, 255, 255, 0.4); white-space: nowrap; - margin-left: auto; - padding-left: 8px; } .enrichment-chip.status-not-configured .enrichment-chip-status { @@ -7610,6 +7625,46 @@ body.helper-mode-active #dashboard-activity-feed:hover { color: rgba(255, 71, 87, 0.7); } +.enrichment-chip-activity { + font-size: 10px; + color: rgba(255, 255, 255, 0.35); + white-space: nowrap; + font-variant-numeric: tabular-nums; + margin-left: auto; + padding-left: 8px; +} + +/* Spotify budget bar — parent chip needs extra bottom padding */ +.enrichment-chip:has(.enrichment-chip-budget) { + padding-bottom: 12px; +} + +.enrichment-chip-budget { + position: absolute; + bottom: 3px; + left: 8px; + right: 8px; + height: 3px; + background: rgba(255, 255, 255, 0.06); + border-radius: 3px; + overflow: hidden; +} + +.enrichment-chip-budget-bar { + height: 100%; + background: linear-gradient(90deg, rgba(var(--accent-rgb), 0.7), var(--accent)); + border-radius: 3px; + transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1); +} + +.enrichment-chip-budget-bar.high { + background: linear-gradient(90deg, rgba(245, 166, 35, 0.7), #f5a623); +} + +.enrichment-chip-budget-bar.exhausted { + background: linear-gradient(90deg, rgba(255, 71, 87, 0.7), #ff4757); +} + /* System Stats Grid */ .stats-grid-dashboard {