Add API call counts and Spotify budget to dashboard service chips

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.
pull/253/head
Broque Thomas 1 month ago
parent 525a09c840
commit c5652b0d4b

@ -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'",

@ -665,8 +665,13 @@
</div>
</div>
</div>
<div class="enrichment-status-grid" id="enrichment-status-grid">
<!-- Dynamically populated by JS -->
<div class="enrichment-section">
<div class="enrichment-section-header">
<span class="enrichment-section-label">Enrichment Services</span>
</div>
<div class="enrichment-status-grid" id="enrichment-status-grid">
<!-- Dynamically populated by JS -->
</div>
</div>
</div>

@ -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' },

@ -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 = `<span class="enrichment-chip-activity">${b.used.toLocaleString()} / ${b.limit.toLocaleString()}</span>`;
metaHtml = `<div class="enrichment-chip-budget">
<div class="enrichment-chip-budget-bar ${barClass}" style="width: ${pct}%"></div>
</div>`;
} else if (c24h > 0) {
// Other services: show 24h count
activityHtml = `<span class="enrichment-chip-activity">${c24h.toLocaleString()} / 24h</span>`;
}
}
// 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(`
<div class="enrichment-chip status-${statusClass}" ${clickAttr} ${titleAttr}>
<div class="enrichment-chip status-${statusClass}" ${clickAttr} title="${tooltipLines.join('\n')}">
<span class="enrichment-chip-dot"></span>
<span class="enrichment-chip-name">${svc.name}</span>
<span class="enrichment-chip-status">${statusLabel}</span>
${activityHtml}
<span class="enrichment-chip-status">${statusDisplay}</span>
${metaHtml}
</div>
`);
}

@ -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 {

Loading…
Cancel
Save