From d5bb0dfaa1deb89f72701672f5b357d19b0f9697 Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Thu, 2 Apr 2026 11:17:04 -0700
Subject: [PATCH] Combine enrichment pills into unified rate monitor cards,
debounce idle
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Merge enrichment worker status into rate monitor WebSocket payload
- Hide old enrichment pills — rate monitor cards now show: service name,
worker status badge, arc gauge, calls/min, 1h/24h counts, budget bar
- Debounce idle detection with 5s grace period — prevents status
flickering between Running and Idle on every worker cycle
- Responsive grid layout with richer card design
---
web_server.py | 47 ++++++++++-
webui/index.html | 8 +-
webui/static/script.js | 128 ++++++++++++++++++++++++------
webui/static/style.css | 175 ++++++++++++++++++++++++++++++++---------
4 files changed, 287 insertions(+), 71 deletions(-)
diff --git a/web_server.py b/web_server.py
index fa054e94..8a41290f 100644
--- a/web_server.py
+++ b/web_server.py
@@ -4009,6 +4009,9 @@ def _get_windowed_calls(key, current_total):
return max(0, current_total - oldest_1h_total), max(0, current_total - oldest_24h_total)
+_idle_since = {} # worker_key -> timestamp when current_item first became None
+_IDLE_GRACE_SECONDS = 5 # Don't report idle until current_item has been None this long
+
def _get_enrichment_status():
"""Get lightweight status for all enrichment services (no DB queries).
Reads worker properties directly to avoid expensive get_stats() calls."""
@@ -4050,12 +4053,22 @@ def _get_enrichment_status():
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)
+ # Debounced idle detection — only report idle after 5s of no current_item
+ has_item = getattr(worker, 'current_item', None) is not None
+ if has_item:
+ _idle_since.pop(key, None)
+ is_idle = False
+ else:
+ if key not in _idle_since:
+ _idle_since[key] = time.time()
+ is_idle = (time.time() - _idle_since[key]) >= _IDLE_GRACE_SECONDS
+
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,
+ 'idle': is_alive and not worker.paused and is_idle,
'calls_1h': calls_1h,
'calls_24h': calls_24h,
}
@@ -48300,13 +48313,43 @@ _download_yield_override = set() # Workers the user explicitly resumed during d
def _emit_rate_monitor_loop():
- """Background thread that pushes API call rate data every 1 second for speedometer gauges."""
+ """Background thread that pushes API call rate data every 1 second for speedometer gauges.
+ Also includes enrichment worker status so the combined cards have everything."""
+ # Map rate monitor service keys to enrichment status keys
+ _enrichment_key_map = {
+ 'spotify': 'spotify_enrichment', 'itunes': 'itunes_enrichment',
+ 'deezer': 'deezer_enrichment', 'lastfm': 'lastfm', 'genius': 'genius',
+ 'musicbrainz': 'musicbrainz', 'audiodb': 'audiodb',
+ 'tidal': 'tidal_enrichment', 'qobuz': 'qobuz_enrichment',
+ }
+
while True:
socketio.sleep(1)
try:
from core.api_call_tracker import api_call_tracker
payload = api_call_tracker.get_all_rates()
+ # Merge enrichment worker status into each service
+ try:
+ enrichment = _get_enrichment_status()
+ for svc_key, entry in payload.items():
+ enr_key = _enrichment_key_map.get(svc_key)
+ enr = enrichment.get(enr_key) if enr_key else None
+ if enr:
+ entry['worker'] = {
+ 'status': 'not_configured' if not enr.get('configured') else
+ 'paused' if enr.get('paused') else
+ 'idle' if enr.get('idle') else
+ 'running' if enr.get('running') else 'stopped',
+ 'yield_reason': enr.get('yield_reason', ''),
+ 'calls_1h': enr.get('calls_1h', 0),
+ 'calls_24h': enr.get('calls_24h', 0),
+ }
+ if svc_key == 'spotify' and enr.get('daily_budget'):
+ entry['worker']['daily_budget'] = enr['daily_budget']
+ except Exception:
+ pass
+
# Add Spotify rate limit state
try:
if spotify_client:
diff --git a/webui/index.html b/webui/index.html
index 7e4a17de..98475985 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -669,19 +669,19 @@
-
-
API Rate Monitor
+
Enrichment Services
diff --git a/webui/static/script.js b/webui/static/script.js
index 54968225..83dbc74c 100644
--- a/webui/static/script.js
+++ b/webui/static/script.js
@@ -37685,8 +37685,8 @@ const _RATE_GAUGE_COLORS = {
audiodb: '#00BCD4', tidal: '#00FFFF', qobuz: '#FF6B35',
};
-// SVG constants — 240° arc, gap at bottom, extra padding for glow
-const _G = { size: 220, cx: 110, cy: 116, r: 74, stroke: 10, startAngle: 240, totalArc: 240 };
+// SVG constants — 240° arc, gap at bottom
+const _G = { size: 160, cx: 80, cy: 84, r: 56, stroke: 8, startAngle: 240, totalArc: 240 };
function _gPt(angle, radius) {
const rad = (angle - 90) * Math.PI / 180;
@@ -37726,36 +37726,112 @@ function _handleRateMonitorUpdate(data) {
const value = d.cpm || 0;
const max = d.limit || 60;
const pct = Math.min(value / max, 1);
-
- let svg = container.querySelector('svg');
- if (!svg) {
- container.innerHTML = _buildGaugeSVG(svc, value, max);
- } else {
- _updateGauge(container, value, max, svc);
- }
-
- container.classList.toggle('danger', pct > 0.8);
- container.classList.toggle('active', value > 0);
-
- // Rate limited badge (Spotify only for now)
+ const accent = _RATE_GAUGE_COLORS[svc] || '#888';
+ const label = _RATE_GAUGE_LABELS[svc] || svc;
+ const worker = d.worker || {};
+ const wStatus = worker.status || 'stopped';
const isRateLimited = d.rate_limited === true;
- container.classList.toggle('rate-limited', isRateLimited);
- let badge = container.querySelector('.gauge-rl-badge');
- if (isRateLimited) {
- const mins = Math.ceil((d.rl_remaining || 0) / 60);
- const text = mins > 60 ? `${Math.floor(mins / 60)}h ${mins % 60}m` : `${mins}m`;
- if (!badge) {
- badge = document.createElement('div');
- badge.className = 'gauge-rl-badge';
- container.appendChild(badge);
+
+ // Build or update the card content
+ let gaugeWrap = container.querySelector('.gauge-arc-wrap');
+ if (!gaugeWrap) {
+ // Full rebuild
+ container.innerHTML = `
+
+
${_buildGaugeSVG(svc, value, max)}
+
+
${value.toFixed(0)}calls/min
+
${worker.calls_1h || 0}last hour
+
${(worker.calls_24h || 0).toLocaleString()}24h
+
+ ${svc === 'spotify' && worker.daily_budget ? _buildBudgetBar(worker.daily_budget) : ''}
+ ${isRateLimited ? _buildRateLimitBadge(d) : ''}
+ `;
+ } else {
+ // Fast update — only change values
+ _updateGauge(gaugeWrap, value, max, svc);
+
+ // Update status
+ const statusEl = container.querySelector('.gauge-card-status');
+ if (statusEl) {
+ statusEl.dataset.status = wStatus;
+ statusEl.textContent = _workerStatusLabel(wStatus, worker);
+ }
+
+ // Update stats
+ const statVals = container.querySelectorAll('.gauge-card-stat-val');
+ if (statVals[0]) statVals[0].textContent = value.toFixed(0);
+ if (statVals[1]) statVals[1].textContent = worker.calls_1h || 0;
+ if (statVals[2]) statVals[2].textContent = (worker.calls_24h || 0).toLocaleString();
+
+ // Budget bar (Spotify)
+ if (svc === 'spotify' && worker.daily_budget) {
+ let budgetEl = container.querySelector('.gauge-budget-bar');
+ if (!budgetEl) {
+ const div = document.createElement('div');
+ div.innerHTML = _buildBudgetBar(worker.daily_budget);
+ const statsEl = container.querySelector('.gauge-card-stats');
+ if (statsEl) statsEl.after(div.firstElementChild);
+ } else {
+ const b = worker.daily_budget;
+ const pctB = Math.min(100, Math.round((b.used / b.limit) * 100));
+ const fill = budgetEl.querySelector('.gauge-budget-fill');
+ if (fill) { fill.style.width = pctB + '%'; }
+ const label = budgetEl.querySelector('.gauge-budget-label');
+ if (label) label.textContent = `${b.used.toLocaleString()} / ${b.limit.toLocaleString()} daily`;
+ }
+ }
+
+ // Rate limit badge
+ let badge = container.querySelector('.gauge-rl-badge');
+ if (isRateLimited) {
+ if (!badge) {
+ const div = document.createElement('div');
+ div.innerHTML = _buildRateLimitBadge(d);
+ container.appendChild(div.firstElementChild);
+ } else {
+ const mins = Math.ceil((d.rl_remaining || 0) / 60);
+ const timeEl = badge.querySelector('.gauge-rl-time');
+ if (timeEl) timeEl.textContent = mins > 60 ? `${Math.floor(mins / 60)}h ${mins % 60}m` : `${mins}m`;
+ }
+ } else if (badge) {
+ badge.remove();
}
- badge.innerHTML = `
RATE LIMITED
${text}`;
- } else if (badge) {
- badge.remove();
}
+
+ container.classList.toggle('danger', pct > 0.8 || isRateLimited);
+ container.classList.toggle('active', value > 0 || wStatus === 'running');
+ container.classList.toggle('rate-limited', isRateLimited);
}
}
+function _workerStatusLabel(status, worker) {
+ if (status === 'not_configured') return 'Not configured';
+ if (status === 'paused') return worker.yield_reason === 'downloads' ? 'Yielding' : 'Paused';
+ if (status === 'idle') return 'Idle';
+ if (status === 'running') return 'Running';
+ return 'Stopped';
+}
+
+function _buildBudgetBar(budget) {
+ const pct = Math.min(100, Math.round((budget.used / budget.limit) * 100));
+ const cls = budget.exhausted ? 'exhausted' : pct > 80 ? 'high' : '';
+ return `
+
+
${budget.used.toLocaleString()} / ${budget.limit.toLocaleString()} daily
+
`;
+}
+
+function _buildRateLimitBadge(d) {
+ const mins = Math.ceil((d.rl_remaining || 0) / 60);
+ const text = mins > 60 ? `${Math.floor(mins / 60)}h ${mins % 60}m` : `${mins}m`;
+ return `
RATE LIMITED${text}
`;
+}
+
function _buildGaugeSVG(svc, value, max) {
const { size, cx, cy, r, stroke, startAngle, totalArc } = _G;
const label = _RATE_GAUGE_LABELS[svc] || svc;
diff --git a/webui/static/style.css b/webui/static/style.css
index 732ae009..01802116 100644
--- a/webui/static/style.css
+++ b/webui/static/style.css
@@ -8195,45 +8195,44 @@ body.helper-mode-active #dashboard-activity-feed:hover {
.rate-monitor-grid {
display: grid;
- grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
- gap: 14px;
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+ gap: 12px;
padding: 8px 0;
}
.rate-gauge-card {
position: relative;
display: flex;
- align-items: center;
- justify-content: center;
- padding: 8px 4px 4px;
- border-radius: 16px;
- background: rgba(12, 12, 16, 0.6);
- border: 1px solid rgba(255, 255, 255, 0.04);
+ flex-direction: column;
+ padding: 14px 14px 10px;
+ border-radius: 14px;
+ background: rgba(18, 18, 22, 0.7);
+ border: 1px solid rgba(255, 255, 255, 0.05);
cursor: pointer;
- transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
- backdrop-filter: blur(8px);
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ backdrop-filter: blur(12px);
overflow: visible;
}
.rate-gauge-card:hover {
- background: rgba(20, 20, 28, 0.8);
+ background: rgba(24, 24, 30, 0.85);
border-color: rgba(255, 255, 255, 0.1);
- transform: translateY(-3px);
- box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4);
+ transform: translateY(-2px);
+ box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35);
}
.rate-gauge-card.active {
- border-color: rgba(255, 255, 255, 0.06);
+ border-color: rgba(255, 255, 255, 0.07);
}
.rate-gauge-card.danger {
- border-color: rgba(239, 68, 68, 0.35);
+ border-color: rgba(239, 68, 68, 0.3);
animation: gauge-danger-pulse 1.8s ease-in-out infinite;
}
@keyframes gauge-danger-pulse {
- 0%, 100% { box-shadow: 0 0 0 rgba(239, 68, 68, 0), inset 0 0 0 rgba(239, 68, 68, 0); }
- 50% { box-shadow: 0 0 24px rgba(239, 68, 68, 0.15), inset 0 0 20px rgba(239, 68, 68, 0.03); }
+ 0%, 100% { box-shadow: 0 0 0 rgba(239, 68, 68, 0); }
+ 50% { box-shadow: 0 0 20px rgba(239, 68, 68, 0.12); }
}
.rate-gauge-card.rate-limited {
@@ -8241,23 +8240,122 @@ body.helper-mode-active #dashboard-activity-feed:hover {
animation: gauge-danger-pulse 1.8s ease-in-out infinite;
}
-.gauge-rl-badge {
- position: absolute;
- bottom: 8px;
- left: 50%;
- transform: translateX(-50%);
+/* Card header — service name + status */
+.gauge-card-header {
display: flex;
align-items: center;
+ gap: 8px;
+ margin-bottom: 4px;
+}
+
+.gauge-card-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.gauge-card-name {
+ flex: 1;
+ font-size: 12px;
+ font-weight: 600;
+ color: rgba(255, 255, 255, 0.85);
+ letter-spacing: 0.3px;
+}
+
+.gauge-card-status {
+ font-size: 10px;
+ font-weight: 500;
+ padding: 2px 7px;
+ border-radius: 10px;
+ letter-spacing: 0.3px;
+}
+.gauge-card-status[data-status="running"] { background: rgba(34, 197, 94, 0.12); color: #4ade80; }
+.gauge-card-status[data-status="idle"] { background: rgba(255, 255, 255, 0.06); color: rgba(255, 255, 255, 0.35); }
+.gauge-card-status[data-status="paused"] { background: rgba(234, 179, 8, 0.12); color: #fbbf24; }
+.gauge-card-status[data-status="stopped"] { background: rgba(255, 255, 255, 0.04); color: rgba(255, 255, 255, 0.2); }
+.gauge-card-status[data-status="not_configured"] { background: rgba(255, 255, 255, 0.04); color: rgba(255, 255, 255, 0.2); }
+
+/* Arc wrap */
+.gauge-arc-wrap {
+ display: flex;
+ justify-content: center;
+ margin: -4px 0;
+}
+
+/* Stats row below gauge */
+.gauge-card-stats {
+ display: flex;
+ justify-content: space-between;
+ padding: 0 4px;
+ margin-top: -2px;
+}
+
+.gauge-card-stat {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 1px;
+}
+
+.gauge-card-stat-val {
+ font-size: 13px;
+ font-weight: 700;
+ color: rgba(255, 255, 255, 0.8);
+}
+
+.gauge-card-stat-label {
+ font-size: 8px;
+ color: rgba(255, 255, 255, 0.25);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+/* Spotify budget bar */
+.gauge-budget-bar {
+ position: relative;
+ height: 4px;
+ background: rgba(255, 255, 255, 0.06);
+ border-radius: 2px;
+ margin: 6px 4px 2px;
+ overflow: hidden;
+}
+
+.gauge-budget-fill {
+ height: 100%;
+ border-radius: 2px;
+ background: linear-gradient(90deg, rgba(29, 185, 84, 0.7), #1DB954);
+ transition: width 0.8s ease;
+}
+
+.gauge-budget-bar.high .gauge-budget-fill { background: linear-gradient(90deg, rgba(245, 166, 35, 0.7), #f5a623); }
+.gauge-budget-bar.exhausted .gauge-budget-fill { background: linear-gradient(90deg, rgba(239, 68, 68, 0.7), #ef4444); }
+
+.gauge-budget-label {
+ position: absolute;
+ top: 6px;
+ left: 0;
+ width: 100%;
+ text-align: center;
+ font-size: 8px;
+ color: rgba(255, 255, 255, 0.2);
+}
+
+/* Rate limit badge */
+.gauge-rl-badge {
+ display: flex;
+ align-items: center;
+ justify-content: center;
gap: 5px;
- padding: 3px 10px;
- border-radius: 20px;
- background: rgba(239, 68, 68, 0.15);
- border: 1px solid rgba(239, 68, 68, 0.25);
+ padding: 4px 10px;
+ margin-top: 6px;
+ border-radius: 8px;
+ background: rgba(239, 68, 68, 0.1);
+ border: 1px solid rgba(239, 68, 68, 0.2);
font-size: 9px;
font-weight: 700;
color: #ef4444;
letter-spacing: 0.5px;
- white-space: nowrap;
}
.gauge-rl-dot {
@@ -8274,12 +8372,14 @@ body.helper-mode-active #dashboard-activity-feed:hover {
}
.gauge-rl-time {
- color: rgba(239, 68, 68, 0.6);
+ color: rgba(239, 68, 68, 0.5);
font-weight: 500;
}
+/* SVG gauge */
.rate-gauge-svg {
width: 100%;
+ max-width: 160px;
height: auto;
overflow: visible;
}
@@ -8298,24 +8398,20 @@ body.helper-mode-active #dashboard-activity-feed:hover {
.gauge-value {
font-family: -apple-system, 'SF Pro Display', 'Segoe UI', sans-serif;
- font-size: 32px;
+ font-size: 24px;
font-weight: 800;
fill: rgba(255, 255, 255, 0.95);
- letter-spacing: -1px;
+ letter-spacing: -0.5px;
}
.gauge-unit {
font-family: -apple-system, sans-serif;
- font-size: 10px;
+ font-size: 9px;
fill: rgba(255, 255, 255, 0.2);
}
.gauge-label {
- font-family: -apple-system, sans-serif;
- font-size: 11px;
- font-weight: 600;
- fill: rgba(255, 255, 255, 0.45);
- letter-spacing: 0.5px;
+ display: none; /* Label is now in the card header, not the SVG */
}
/* Rate Monitor Detail Modal */
@@ -8470,18 +8566,19 @@ body.helper-mode-active #dashboard-activity-feed:hover {
font-size: 12px;
}
-@media (max-width: 768px) {
+@media (max-width: 900px) {
.rate-monitor-grid {
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
- .rate-gauge-card { padding: 10px 8px 6px; }
}
-@media (max-width: 480px) {
+@media (max-width: 600px) {
.rate-monitor-grid {
grid-template-columns: repeat(2, 1fr);
}
+ .gauge-card-stats { gap: 2px; }
+ .gauge-card-stat-val { font-size: 11px; }
}
/* System Stats Grid */