From c5652b0d4b6bec1224c6cc10ebf0ba09fa445ac3 Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Mon, 30 Mar 2026 13:20:22 -0700
Subject: [PATCH] Add API call counts and Spotify budget to dashboard service
chips
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Enrichment chips now show live activity: 24h call count for all
services and daily budget usage (used/3,000) with gradient progress
bar for Spotify. Tracking is centralized in _get_enrichment_status
using cumulative stat diffs over a rolling deque — no worker files
modified. Added section header, "Configure →" label for unconfigured
services, and full 1h/24h breakdown in tooltips.
---
web_server.py | 55 ++++++++++++++++++++++++++++++++--
webui/index.html | 9 ++++--
webui/static/helper.js | 2 +-
webui/static/script.js | 50 +++++++++++++++++++++++++++----
webui/static/style.css | 67 ++++++++++++++++++++++++++++++++++++++----
5 files changed, 167 insertions(+), 16 deletions(-)
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 {