From 32adc66fe33482cdcb9110eb6091e94f00440f74 Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Mon, 30 Mar 2026 10:04:13 -0700
Subject: [PATCH] Show all services on dashboard with click-to-configure (#219)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Dashboard now displays all enrichment services as live-status chips
below the core service cards. Each chip shows Running, Idle, Paused,
Stopped, or Not Configured state with color-coded left border accents.
Unconfigured services appear dimmed with dashed borders — clicking any
configurable chip navigates to Settings → Connections and scrolls to
the relevant service section.
Also fixes the Spotify card always being labeled "Apple Music" when
using iTunes fallback — card now always says "Spotify" with an amber
"Using iTunes/Deezer" indicator when fallback is active.
---
web_server.py | 86 ++++++++++++++++++-
webui/index.html | 3 +
webui/static/helper.js | 1 +
webui/static/script.js | 121 ++++++++++++++++++++++++---
webui/static/style.css | 185 +++++++++++++++++++++++++++++++++++++++++
5 files changed, 379 insertions(+), 17 deletions(-)
diff --git a/web_server.py b/web_server.py
index 84a881a2..e72f4074 100644
--- a/web_server.py
+++ b/web_server.py
@@ -3396,7 +3396,7 @@ def run_service_test(service, test_config):
return True, "Spotify connection successful!"
else:
# Using fallback metadata source
- fallback_name = 'Deezer' if _get_metadata_fallback_source() == 'deezer' else 'Apple Music'
+ fallback_name = 'Deezer' if _get_metadata_fallback_source() == 'deezer' else 'iTunes'
if spotify_configured:
return True, f"{fallback_name} connection successful! (Spotify configured but not authenticated)"
else:
@@ -3866,6 +3866,70 @@ def index():
# --- API Endpoints ---
+def _get_enrichment_status():
+ """Get lightweight status for all enrichment services (no DB queries).
+ Reads worker properties directly to avoid expensive get_stats() calls."""
+ services = {}
+
+ # Worker-based enrichment services: (key, display_name, worker_var)
+ workers_info = [
+ ('musicbrainz', 'MusicBrainz', lambda: mb_worker),
+ ('spotify_enrichment', 'Spotify', lambda: spotify_enrichment_worker),
+ ('itunes_enrichment', 'iTunes', lambda: itunes_enrichment_worker),
+ ('deezer_enrichment', 'Deezer', lambda: deezer_worker),
+ ('tidal_enrichment', 'Tidal', lambda: tidal_enrichment_worker),
+ ('qobuz_enrichment', 'Qobuz', lambda: qobuz_enrichment_worker),
+ ('lastfm', 'Last.fm', lambda: lastfm_worker),
+ ('genius', 'Genius', lambda: genius_worker),
+ ('audiodb', 'AudioDB', lambda: audiodb_worker),
+ ]
+
+ # Config-based "configured" checks for services that need API keys/credentials
+ configured_checks = {
+ 'spotify_enrichment': lambda: bool(config_manager.get('spotify.client_id') and config_manager.get('spotify.client_secret')),
+ 'tidal_enrichment': lambda: bool(tidal_client and getattr(tidal_client, 'access_token', None)),
+ 'qobuz_enrichment': lambda: bool(qobuz_enrichment_worker and qobuz_enrichment_worker.client and qobuz_enrichment_worker.client.user_auth_token),
+ 'lastfm': lambda: bool(config_manager.get('lastfm.api_key', '')),
+ 'genius': lambda: bool(config_manager.get('genius.access_token', '')),
+ }
+
+ for key, name, get_worker in workers_info:
+ worker = get_worker()
+ if worker is not None:
+ is_alive = worker.thread is not None and worker.thread.is_alive()
+ try:
+ configured = configured_checks.get(key, lambda: True)()
+ except Exception:
+ configured = False
+ services[key] = {
+ '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,
+ }
+ else:
+ services[key] = {
+ 'name': name,
+ 'configured': False,
+ 'running': False,
+ 'paused': False,
+ 'idle': False,
+ }
+
+ # Non-worker services (configured status only)
+ services['acoustid'] = {
+ 'name': 'AcoustID',
+ 'configured': bool(config_manager.get('acoustid.api_key', '')),
+ }
+ services['listenbrainz'] = {
+ 'name': 'ListenBrainz',
+ 'configured': bool(config_manager.get('listenbrainz.token', '')),
+ }
+
+ return services
+
+
# Status check caching to reduce unnecessary API calls
_status_cache = {
'spotify': {'connected': False, 'response_time': 0, 'source': 'itunes'},
@@ -3963,7 +4027,8 @@ def get_status():
'spotify': _status_cache['spotify'],
'media_server': _status_cache['media_server'],
'soulseek': _status_cache['soulseek'],
- 'active_media_server': active_server
+ 'active_media_server': active_server,
+ 'enrichment': _get_enrichment_status()
}
return jsonify(status_data)
except Exception as e:
@@ -6214,7 +6279,7 @@ def spotify_disconnect():
'rate_limit': None
}
_status_cache_timestamps['spotify'] = time.time()
- fallback_label = 'Deezer' if fallback_src == 'deezer' else 'Apple Music/iTunes'
+ fallback_label = 'Deezer' if fallback_src == 'deezer' else 'iTunes'
add_activity_item("🔌", "Spotify Disconnected", f"Switched to {fallback_label} metadata source", "Now")
return jsonify({'success': True, 'message': f'Spotify disconnected. Now using {fallback_label}.'})
except Exception as e:
@@ -19071,6 +19136,18 @@ def get_version_info():
"title": "What's New in SoulSync",
"subtitle": f"Version {SOULSYNC_VERSION} — Latest Changes",
"sections": [
+ {
+ "title": "📊 Show All Services on Dashboard (#219)",
+ "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",
+ "• 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'",
+ "• Fallback state (using iTunes/Deezer) shown with amber indicator when Spotify is not connected"
+ ]
+ },
{
"title": "🔧 Add Qobuz to Connections Tab (#218)",
"description": "Qobuz credentials now available on the Connections tab for metadata enrichment",
@@ -45284,7 +45361,8 @@ def _build_status_payload():
'spotify': spotify_data,
'media_server': _status_cache.get('media_server', {}),
'soulseek': soulseek_data,
- 'active_media_server': config_manager.get_active_media_server()
+ 'active_media_server': config_manager.get_active_media_server(),
+ 'enrichment': _get_enrichment_status()
}
def _build_watchlist_count_payload(profile_id=1):
diff --git a/webui/index.html b/webui/index.html
index 7bf055d1..1a139382 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -665,6 +665,9 @@
+
+
+
diff --git a/webui/static/helper.js b/webui/static/helper.js
index 9d145aa1..34919c64 100644
--- a/webui/static/helper.js
+++ b/webui/static/helper.js
@@ -3403,6 +3403,7 @@ function closeHelperSearch() {
const WHATS_NEW = {
'2.1': [
// Newest features first
+ { 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: '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 cb7d1c39..e1f6eed8 100644
--- a/webui/static/script.js
+++ b/webui/static/script.js
@@ -11,7 +11,7 @@ let currentStream = {
progress: 0,
track: null
};
-let currentMusicSourceName = 'Spotify'; // 'Spotify' or 'Apple Music' - updated from status endpoint
+let currentMusicSourceName = 'Spotify'; // 'Spotify', 'iTunes', or 'Deezer' - updated from status endpoint
// Streaming state management (enhanced functionality)
let streamStatusPoller = null;
@@ -382,6 +382,9 @@ function handleServiceStatusUpdate(data) {
updateSidebarServiceStatus('media-server', data.media_server);
updateSidebarServiceStatus('soulseek', data.soulseek);
+ // Update enrichment service cards
+ if (data.enrichment) renderEnrichmentCards(data.enrichment);
+
// Spotify rate limit / cooldown / recovery
if (data.spotify?.rate_limited && data.spotify.rate_limit) {
handleSpotifyRateLimit(data.spotify.rate_limit);
@@ -6974,7 +6977,7 @@ async function testConnection(service) {
const result = await response.json();
if (result.success) {
- // Use backend's message which contains dynamic source name (Spotify or Apple Music)
+ // Use backend's message which contains dynamic source name
showToast(result.message || `${service} connection successful`, 'success');
// Load music libraries after successful connection
@@ -7141,7 +7144,7 @@ async function testDashboardConnection(service) {
const result = await response.json();
if (result.success) {
- // Use backend's message which contains dynamic source name (Spotify or Apple Music)
+ // Use backend's message which contains dynamic source name
showToast(result.message || `${service} service verified`, 'success');
// Refresh status indicators immediately so UI reflects the new state
fetchAndUpdateServiceStatus();
@@ -36048,6 +36051,9 @@ async function fetchAndUpdateServiceStatus() {
updateSidebarServiceStatus('media-server', data.media_server);
updateSidebarServiceStatus('soulseek', data.soulseek);
+ // Update enrichment service cards
+ if (data.enrichment) renderEnrichmentCards(data.enrichment);
+
// Check for Spotify rate limit
if (data.spotify && data.spotify.rate_limited && data.spotify.rate_limit) {
handleSpotifyRateLimit(data.spotify.rate_limit);
@@ -36071,7 +36077,7 @@ function updateServiceStatus(service, statusData) {
? formatRateLimitDuration(statusData.rate_limit?.remaining_seconds || 0)
: formatRateLimitDuration(statusData.post_ban_cooldown);
const phase = statusData.rate_limited ? 'paused' : 'recovering';
- const fallbackLabel = statusData.source === 'deezer' ? 'Deezer' : 'Apple Music';
+ const fallbackLabel = statusData.source === 'deezer' ? 'Deezer' : 'iTunes';
statusText.textContent = `${fallbackLabel} (Spotify ${phase} \u2014 ${remaining})`;
statusText.className = 'service-card-status-text rate-limited';
} else if (statusData.connected) {
@@ -36085,16 +36091,29 @@ function updateServiceStatus(service, statusData) {
}
}
- // Update music source title (Spotify or Apple Music) based on active source
+ // Update music source title and status based on active source
if (service === 'spotify' && statusData.source) {
const musicSourceTitleElement = document.getElementById('music-source-title');
if (musicSourceTitleElement) {
- const sourceName = statusData.source === 'spotify' ? 'Spotify' : statusData.source === 'deezer' ? 'Deezer' : 'Apple Music';
- musicSourceTitleElement.textContent = sourceName;
+ // Card title always says "Spotify" — it represents the metadata source slot
+ musicSourceTitleElement.textContent = 'Spotify';
// Update global variable for use in discovery modals
+ const sourceName = statusData.source === 'spotify' ? 'Spotify' : statusData.source === 'deezer' ? 'Deezer' : 'iTunes';
currentMusicSourceName = sourceName;
}
+ // When using fallback, update status text to show which fallback is active
+ if (statusData.source !== 'spotify' && !statusData.rate_limited && !statusData.post_ban_cooldown) {
+ const fallbackName = statusData.source === 'deezer' ? 'Deezer' : 'iTunes';
+ if (statusText) {
+ statusText.textContent = `Using ${fallbackName}`;
+ statusText.className = 'service-card-status-text fallback';
+ }
+ if (indicator) {
+ indicator.className = 'service-card-indicator fallback';
+ }
+ }
+
// Show/hide Spotify disconnect button based on connection state
const disconnectBtn = document.getElementById('spotify-disconnect-btn');
if (disconnectBtn) {
@@ -36141,12 +36160,20 @@ function updateSidebarServiceStatus(service, statusData) {
}
}
- // Update music source name (Spotify or Apple Music) based on active source
+ // Update music source name — always "Spotify" in sidebar
if (service === 'spotify' && statusData.source) {
const musicSourceNameElement = document.getElementById('music-source-name');
if (musicSourceNameElement) {
- const sourceName = statusData.source === 'spotify' ? 'Spotify' : statusData.source === 'deezer' ? 'Deezer' : 'Apple Music';
- musicSourceNameElement.textContent = sourceName;
+ musicSourceNameElement.textContent = 'Spotify';
+ }
+
+ // Show fallback state in sidebar dot
+ if (statusData.source !== 'spotify' && !statusData.rate_limited && !statusData.post_ban_cooldown) {
+ if (dot) {
+ dot.className = 'status-dot fallback';
+ const fallbackName = statusData.source === 'deezer' ? 'Deezer' : 'iTunes';
+ dot.title = `Using ${fallbackName} fallback`;
+ }
}
}
@@ -36160,6 +36187,74 @@ function updateSidebarServiceStatus(service, statusData) {
}
}
+function renderEnrichmentCards(enrichment) {
+ const grid = document.getElementById('enrichment-status-grid');
+ if (!grid || !enrichment) return;
+
+ // Service display order
+ const serviceOrder = [
+ 'musicbrainz', 'spotify_enrichment', 'itunes_enrichment', 'deezer_enrichment',
+ 'tidal_enrichment', 'qobuz_enrichment', 'lastfm', 'genius', 'audiodb',
+ 'acoustid', 'listenbrainz'
+ ];
+
+ // Map service keys to their settings page selector for click-to-configure
+ const settingsSelectors = {
+ 'spotify_enrichment': '.spotify-title',
+ 'tidal_enrichment': '.tidal-title',
+ 'qobuz_enrichment': '.qobuz-title',
+ 'lastfm': '.lastfm-title',
+ 'genius': '.genius-title',
+ 'acoustid': '.acoustid-title',
+ 'listenbrainz': '.listenbrainz-title',
+ };
+
+ const chips = [];
+ for (const key of serviceOrder) {
+ const svc = enrichment[key];
+ if (!svc) continue;
+
+ // Determine status class and text
+ let statusClass, statusLabel;
+ if ('running' in svc) {
+ if (!svc.configured) {
+ statusClass = 'not-configured';
+ statusLabel = 'Set up';
+ } else if (svc.paused) {
+ statusClass = 'paused';
+ statusLabel = 'Paused';
+ } else if (svc.running) {
+ statusClass = svc.idle ? 'idle' : 'running';
+ statusLabel = svc.idle ? 'Idle' : 'Running';
+ } else {
+ statusClass = 'stopped';
+ statusLabel = 'Stopped';
+ }
+ } else {
+ statusClass = svc.configured ? 'running' : 'not-configured';
+ statusLabel = svc.configured ? 'Ready' : 'Set up';
+ }
+
+ const selector = settingsSelectors[key];
+ 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}"`;
+
+ chips.push(`
+
+
+ ${svc.name}
+ ${statusLabel}
+
+ `);
+ }
+
+ grid.innerHTML = chips.join('');
+}
+
async function fetchAndUpdateSystemStats() {
if (socketConnected) return; // WebSocket handles this
if (document.hidden) return; // Skip polling when tab is not visible
@@ -38939,7 +39034,7 @@ function buildLibraryArtistCardHTML(artist, index) {
if (artist.soul_id && !artist.soul_id.startsWith('soul_unnamed_')) badges.push({ logo: '/static/trans2.png', fb: 'SS', title: `SoulID: ${artist.soul_id}`, url: null });
// Watchlist badge
- const hasActiveSourceId = currentMusicSourceName === 'Apple Music'
+ const hasActiveSourceId = currentMusicSourceName === 'iTunes'
? (artist.itunes_artist_id || artist.spotify_artist_id)
: (artist.spotify_artist_id || artist.itunes_artist_id);
let watchBadgeHTML = '';
@@ -39047,7 +39142,7 @@ function showLibraryEmpty(show) {
async function openWatchAllUnwatchedModal() {
if (document.getElementById('watch-all-modal-overlay')) return;
- const sourceIdField = currentMusicSourceName === 'Apple Music' ? 'itunes_artist_id'
+ const sourceIdField = currentMusicSourceName === 'iTunes' ? 'itunes_artist_id'
: currentMusicSourceName === 'Deezer' ? 'deezer_id' : 'spotify_artist_id';
const sourceName = currentMusicSourceName || 'Spotify';
@@ -39260,7 +39355,7 @@ async function toggleLibraryCardWatchlist(btn, artist) {
try {
// Use the ID matching the active metadata source
- const artistId = currentMusicSourceName === 'Apple Music'
+ const artistId = currentMusicSourceName === 'iTunes'
? (artist.itunes_artist_id || artist.spotify_artist_id)
: (artist.spotify_artist_id || artist.itunes_artist_id);
if (!artistId) throw new Error('No iTunes or Spotify ID available for this artist');
diff --git a/webui/static/style.css b/webui/static/style.css
index 02c5b5dc..c89a08f3 100644
--- a/webui/static/style.css
+++ b/webui/static/style.css
@@ -2657,6 +2657,12 @@ body.helper-mode-active #dashboard-activity-feed:hover {
content: '';
}
+.status-dot.fallback {
+ background: rgba(245, 166, 35, 0.7);
+ border: none;
+ box-shadow: 0 0 4px rgba(245, 166, 35, 0.3);
+}
+
.status-dot.rate-limited {
background: rgba(250, 204, 21, 0.8);
border: none;
@@ -7360,6 +7366,19 @@ body.helper-mode-active #dashboard-activity-feed:hover {
text-shadow: 0 0 8px rgba(255, 68, 68, 0.5), 0 0 16px rgba(255, 68, 68, 0.2);
}
+.service-card-indicator.fallback {
+ color: #f5a623;
+ text-shadow: 0 0 8px rgba(245, 166, 35, 0.4);
+}
+
+.service-card-status-text.fallback {
+ color: #f5a623;
+}
+
+.service-card:has(.service-card-indicator.fallback)::before {
+ background: linear-gradient(90deg, transparent, rgba(245, 166, 35, 0.4), transparent);
+}
+
.service-card-indicator.rate-limited {
color: #facc15;
text-shadow: 0 0 8px rgba(250, 204, 21, 0.5);
@@ -7443,6 +7462,155 @@ body.helper-mode-active #dashboard-activity-feed:hover {
}
+/* Enrichment Services Grid */
+.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 {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 14px 8px 10px;
+ border-radius: 10px;
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid rgba(255, 255, 255, 0.05);
+ cursor: default;
+ transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
+ position: relative;
+ overflow: hidden;
+ text-decoration: none;
+}
+
+.enrichment-chip[onclick] {
+ cursor: pointer;
+}
+
+.enrichment-chip::before {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 15%;
+ bottom: 15%;
+ width: 3px;
+ border-radius: 0 3px 3px 0;
+ transition: all 0.25s ease;
+}
+
+.enrichment-chip[onclick]:hover {
+ background: rgba(255, 255, 255, 0.06);
+ border-color: rgba(255, 255, 255, 0.1);
+ transform: translateY(-1px);
+}
+
+/* Status: Running / Configured */
+.enrichment-chip.status-running::before {
+ background: var(--accent);
+ box-shadow: 0 0 8px rgba(var(--accent-rgb), 0.4);
+}
+
+.enrichment-chip.status-running .enrichment-chip-dot {
+ background: var(--accent);
+ box-shadow: 0 0 6px rgba(var(--accent-rgb), 0.5);
+}
+
+/* Status: Idle */
+.enrichment-chip.status-idle::before {
+ background: var(--accent);
+ opacity: 0.5;
+}
+
+.enrichment-chip.status-idle .enrichment-chip-dot {
+ background: var(--accent);
+ opacity: 0.5;
+}
+
+/* Status: Paused */
+.enrichment-chip.status-paused::before {
+ background: #f5a623;
+ box-shadow: 0 0 6px rgba(245, 166, 35, 0.3);
+}
+
+.enrichment-chip.status-paused .enrichment-chip-dot {
+ background: #f5a623;
+ box-shadow: 0 0 6px rgba(245, 166, 35, 0.3);
+}
+
+/* Status: Stopped */
+.enrichment-chip.status-stopped::before {
+ background: #ff4757;
+ box-shadow: 0 0 6px rgba(255, 71, 87, 0.3);
+}
+
+.enrichment-chip.status-stopped .enrichment-chip-dot {
+ background: #ff4757;
+ box-shadow: 0 0 4px rgba(255, 71, 87, 0.3);
+}
+
+/* Status: Not Configured */
+.enrichment-chip.status-not-configured {
+ opacity: 0.5;
+ border-style: dashed;
+}
+
+.enrichment-chip.status-not-configured::before {
+ background: rgba(255, 255, 255, 0.15);
+}
+
+.enrichment-chip.status-not-configured .enrichment-chip-dot {
+ background: rgba(255, 255, 255, 0.2);
+}
+
+.enrichment-chip.status-not-configured[onclick]:hover {
+ opacity: 0.8;
+ border-color: rgba(var(--accent-rgb), 0.3);
+}
+
+.enrichment-chip.status-not-configured[onclick]:hover::before {
+ background: rgba(var(--accent-rgb), 0.4);
+}
+
+.enrichment-chip-dot {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ flex-shrink: 0;
+ transition: all 0.3s;
+}
+
+.enrichment-chip-name {
+ font-size: 12px;
+ font-weight: 600;
+ color: rgba(255, 255, 255, 0.85);
+ white-space: nowrap;
+}
+
+.enrichment-chip-status {
+ 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 {
+ color: rgba(var(--accent-rgb), 0.6);
+}
+
+.enrichment-chip.status-paused .enrichment-chip-status {
+ color: rgba(245, 166, 35, 0.7);
+}
+
+.enrichment-chip.status-stopped .enrichment-chip-status {
+ color: rgba(255, 71, 87, 0.7);
+}
+
+
/* System Stats Grid */
.stats-grid-dashboard {
display: grid;
@@ -7978,6 +8146,23 @@ body.helper-mode-active #dashboard-activity-feed:hover {
gap: 15px;
}
+ .enrichment-status-grid {
+ gap: 6px;
+ }
+
+ .enrichment-chip {
+ padding: 6px 10px 6px 8px;
+ gap: 6px;
+ }
+
+ .enrichment-chip-name {
+ font-size: 11px;
+ }
+
+ .enrichment-chip-status {
+ font-size: 9px;
+ }
+
.tool-card-controls {
flex-direction: column;
align-items: stretch;