Profiles: fix modal showing wrong active source + hero header / panel depth

Correctness (the modal was lying): "Spotify (no auth)" is a COMPOSITE the
Settings page stores as fallback_source='spotify' + metadata.spotify_free=true,
not a literal 'spotify_free' value. The modal read the raw fallback_source and
showed plain "Spotify" as active even when Settings clearly said "(no auth)".
The endpoint now mirrors that mapping both ways — reports active='spotify_free'
when the flag is set, and switching to it writes fallback_source=spotify +
spotify_free=true (and clears the flag for any other source). Modal + Settings
now always agree.

Visual: the modal itself (not just the cards) is richer now —
- a hero header per tab: big brand-logo disc + "Active <kind> source" eyebrow +
  the active name + a one-liner + an Active pill, all tinted by the brand color
  with a soft radial glow (the Manage-Workers hero feel);
- the panel gained brand-tinted radial depth instead of flat black.

Test: spotify_free composite round-trips like Settings (stored split + reported
as spotify_free; flag clears on switch). 15 endpoint + 64 integrity tests pass.
This commit is contained in:
BoulderBadgeDad 2026-06-10 10:26:29 -07:00
parent cd59d75531
commit 6c05ec3670
4 changed files with 95 additions and 7 deletions

View file

@ -181,3 +181,18 @@ def test_active_sources_nonadmin_readonly_and_blocked(client, nonadmin_profile):
sess['profile_id'] = nonadmin_profile
assert client.get('/api/profiles/me/active-sources').get_json()['editable'] is False
assert client.post('/api/profiles/active-sources', json={'metadata_source': 'deezer'}).status_code == 403
def test_spotify_free_composite_roundtrips_like_settings(client):
# "Spotify (no auth)" is stored as fallback_source=spotify + spotify_free=true
# (the same composite the Settings page uses) — the modal must report it as
# active='spotify_free', not raw 'spotify'.
from config.settings import config_manager
assert client.post('/api/profiles/active-sources', json={'metadata_source': 'spotify_free'}).get_json()['success']
assert config_manager.get('metadata.fallback_source') == 'spotify'
assert config_manager.get('metadata.spotify_free') is True
assert client.get('/api/profiles/me/active-sources').get_json()['metadata']['active'] == 'spotify_free'
# Switching to plain spotify clears the flag.
client.post('/api/profiles/active-sources', json={'metadata_source': 'spotify'})
assert config_manager.get('metadata.spotify_free') is False
assert client.get('/api/profiles/me/active-sources').get_json()['metadata']['active'] == 'spotify'

View file

@ -25641,6 +25641,14 @@ def get_active_sources():
try:
mode = config_manager.get('download_source.mode', 'soulseek') or 'soulseek'
hybrid_order = config_manager.get('download_source.hybrid_order', []) or []
# "Spotify (no auth)" is a COMPOSITE the Settings page uses: it stores
# fallback_source='spotify' + metadata.spotify_free=true, NOT a literal
# 'spotify_free' fallback value. Mirror that mapping so the modal agrees
# with the Settings dropdown (settings.js _metaSel / save logic).
_fb = config_manager.get('metadata.fallback_source', 'deezer') or 'deezer'
_free = config_manager.get('metadata.spotify_free', False)
meta_active = 'spotify_free' if (_fb == 'spotify' and _free) else _fb
meta_effective = 'spotify_free' if meta_active == 'spotify_free' else _get_metadata_fallback_source()
return jsonify({
'success': True,
'editable': get_current_profile_id() == 1, # admin writes the global default
@ -25650,8 +25658,8 @@ def get_active_sources():
# fallback (e.g. configured 'spotify' but not authenticated →
# the app falls back). Surfacing both stops the modal disagreeing
# with the sidebar/Settings status.
'active': config_manager.get('metadata.fallback_source', 'deezer') or 'deezer',
'effective': _get_metadata_fallback_source(),
'active': meta_active,
'effective': meta_effective,
'options': [{'id': s, 'available': _qs_metadata_available(s)} for s in _QS_METADATA_SOURCES],
},
'server': {
@ -25683,7 +25691,14 @@ def set_active_sources():
src = data['metadata_source']
if src not in _QS_METADATA_SOURCES:
return jsonify({'success': False, 'error': 'Unknown metadata source'}), 400
config_manager.set('metadata.fallback_source', src)
# Same composite the Settings save uses: 'spotify_free' is stored as
# fallback_source='spotify' + metadata.spotify_free=true.
if src == 'spotify_free':
config_manager.set('metadata.fallback_source', 'spotify')
config_manager.set('metadata.spotify_free', True)
else:
config_manager.set('metadata.fallback_source', src)
config_manager.set('metadata.spotify_free', False)
invalidate_metadata_status_caches()
changed.append('metadata')

View file

@ -173,12 +173,39 @@ function _ssCard({ logo, emoji, label, active, available, onclick, badge, brand
</button>`;
}
const _SS_TAB_BLURB = {
metadata: 'Where artist, album & track details come from.',
server: 'The library backend SoulSync reads and writes.',
download: 'Where SoulSync grabs tracks you don\'t have yet.',
};
function _ssHero(kind) {
const cur = _ssRailCurrent(kind);
if (!cur) return '';
const media = cur.logo
? `<img class="ss-hero-logo" src="${cur.logo}" onerror="this.outerHTML='<span class=\\'ss-hero-emoji\\'>${cur.emoji}</span>'">`
: `<span class="ss-hero-emoji">${cur.emoji}</span>`;
const eyebrow = kind === 'metadata' ? 'Active metadata source'
: kind === 'server' ? 'Active media server' : 'Active download source';
return `
<div class="ss-hero" style="--ss-brand:${cur.brand}">
<div class="ss-hero-disc">${media}</div>
<div class="ss-hero-info">
<div class="ss-hero-eyebrow">${eyebrow}</div>
<div class="ss-hero-name">${_ssEsc(cur.label)}</div>
<div class="ss-hero-sub">${_SS_TAB_BLURB[kind] || ''}</div>
</div>
<span class="ss-hero-pill">Active</span>
</div>`;
}
function _ssRenderPanel() {
const panel = document.getElementById('ss-panel');
const d = _ssState.data;
if (!panel) return;
if (!d || !d.success) { panel.innerHTML = '<div class="ss-empty">Could not load active sources.</div>'; return; }
const editable = !!d.editable;
panel.style.setProperty('--ss-brand', (_ssRailCurrent(_ssState.tab) || {}).brand || '#7c5cff');
const sub = document.getElementById('ss-topbar-sub');
if (sub) sub.textContent = editable
? 'What this profile uses for metadata, library, and downloads'
@ -199,7 +226,7 @@ function _ssRenderPanel() {
const note = (eff && eff !== d.metadata.active)
? `<div class="ss-effective-note">Configured source isn't connected — actually using <b>${_ssEsc((_ssMetaInfo(eff).text) || eff)}</b> right now.</div>`
: '';
panel.innerHTML = `<div class="ss-section-title">Metadata source</div>${note}<div class="ss-grid">${cards}</div>`;
panel.innerHTML = `${_ssHero('metadata')}<div class="ss-section-title">Choose source</div>${note}<div class="ss-grid">${cards}</div>`;
} else if (_ssState.tab === 'server') {
const cards = d.server.options.map(o => {
const info = _SS_SERVER_INFO[o.id] || { name: o.id };
@ -209,7 +236,7 @@ function _ssRenderPanel() {
onclick: (editable && o.available) ? `setActiveSource('server','${o.id}')` : null,
});
}).join('');
panel.innerHTML = `<div class="ss-section-title">Media server</div><div class="ss-grid">${cards}</div>`;
panel.innerHTML = `${_ssHero('server')}<div class="ss-section-title">Choose server</div><div class="ss-grid">${cards}</div>`;
} else {
_ssRenderDownloadPanel(panel, d, editable);
}
@ -249,7 +276,7 @@ function _ssRenderDownloadPanel(panel, d, editable) {
}).join('');
body = `<div class="ss-grid">${cards}</div>`;
}
panel.innerHTML = `<div class="ss-section-title">Download source</div>${toggle}${body}`;
panel.innerHTML = `${_ssHero('download')}<div class="ss-section-title">Choose source</div>${toggle}${body}`;
if (isHybrid && editable) _ssWireHybridDrag();
}

View file

@ -67668,7 +67668,38 @@ body.em-scroll-lock { overflow: hidden; }
.ss-tab.active .ss-tab-cat { color: color-mix(in srgb, var(--ss-brand) 75%, white); }
.ss-tab-cur { font-size: 0.92rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ss-panel { flex: 1; padding: 22px 24px; overflow-y: auto; }
.ss-panel {
flex: 1; padding: 22px 24px; overflow-y: auto;
background:
radial-gradient(120% 80% at 100% 0%, color-mix(in srgb, var(--ss-brand) 9%, transparent), transparent 60%),
radial-gradient(90% 60% at 0% 100%, rgba(255,255,255,0.02), transparent 70%);
}
.ss-hero {
position: relative; display: flex; align-items: center; gap: 16px; padding: 16px 18px; margin-bottom: 20px;
border-radius: 16px; overflow: hidden;
background: linear-gradient(120deg, color-mix(in srgb, var(--ss-brand) 24%, transparent), rgba(255,255,255,0.025));
border: 1px solid color-mix(in srgb, var(--ss-brand) 40%, transparent);
}
.ss-hero::after {
content: ''; position: absolute; right: -40px; top: -60px; width: 180px; height: 180px; border-radius: 50%;
background: radial-gradient(circle, color-mix(in srgb, var(--ss-brand) 40%, transparent), transparent 70%);
pointer-events: none;
}
.ss-hero-disc {
width: 60px; height: 60px; flex-shrink: 0; border-radius: 50%; background: #fff;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 0 0 3px var(--ss-brand), 0 0 26px color-mix(in srgb, var(--ss-brand) 55%, transparent);
}
.ss-hero-logo { width: 36px; height: 36px; object-fit: contain; }
.ss-hero-emoji { font-size: 1.8rem; }
.ss-hero-info { flex: 1; min-width: 0; }
.ss-hero-eyebrow { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.1em; color: color-mix(in srgb, var(--ss-brand) 70%, white); font-weight: 700; }
.ss-hero-name { font-size: 1.35rem; font-weight: 700; margin: 1px 0 3px; }
.ss-hero-sub { font-size: 0.82rem; color: rgba(255,255,255,0.55); }
.ss-hero-pill {
align-self: flex-start; font-size: 0.66rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em;
padding: 4px 10px; border-radius: 999px; color: #0a0a0a; background: var(--ss-brand);
}
.ss-section-title { font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.08em; color: rgba(255, 255, 255, 0.45); margin-bottom: 16px; }
.ss-hint { color: rgba(255, 255, 255, 0.5); font-size: 0.82rem; margin-bottom: 12px; }
.ss-empty { color: rgba(255, 255, 255, 0.4); font-style: italic; padding: 30px 0; text-align: center; }