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;