Show all services on dashboard with click-to-configure (#219)

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.
pull/253/head
Broque Thomas 2 months ago
parent ab8e44dafd
commit 32adc66fe3

@ -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):

@ -665,6 +665,9 @@
</div>
</div>
</div>
<div class="enrichment-status-grid" id="enrichment-status-grid">
<!-- Dynamically populated by JS -->
</div>
</div>
<div class="dashboard-section">

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

@ -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(`
<div class="enrichment-chip status-${statusClass}" ${clickAttr} ${titleAttr}>
<span class="enrichment-chip-dot"></span>
<span class="enrichment-chip-name">${svc.name}</span>
<span class="enrichment-chip-status">${statusLabel}</span>
</div>
`);
}
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');

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

Loading…
Cancel
Save