diff --git a/tests/test_credentials_endpoints.py b/tests/test_credentials_endpoints.py index 286579a3..575e3378 100644 --- a/tests/test_credentials_endpoints.py +++ b/tests/test_credentials_endpoints.py @@ -266,3 +266,23 @@ def test_tidal_admin_and_unconnected_use_global_client(client): assert web_server.get_tidal_client_for_profile(1) is web_server.tidal_client assert web_server.get_tidal_client_for_profile(None) is web_server.tidal_client assert web_server.get_tidal_client_for_profile(987654) is web_server.tidal_client + + +# ── ListenBrainz: per-profile connect status + disconnect (token-paste) ─────── + +def test_listenbrainz_connection_status_and_disconnect(client, nonadmin_profile): + db = web_server.get_database() + with client.session_transaction() as sess: + sess['profile_id'] = nonadmin_profile + # unconnected + conns = client.get('/api/profiles/me/connections').get_json()['connections'] + assert 'listenbrainz' in conns and conns['listenbrainz']['connected'] is False + # seed a token directly (POST validates against the live API; this tests the + # status + disconnect wiring without a network call) + db.set_profile_listenbrainz(nonadmin_profile, 'lb-token', '', 'lbuser') + conns = client.get('/api/profiles/me/connections').get_json()['connections'] + assert conns['listenbrainz']['connected'] is True + assert conns['listenbrainz']['account'] == 'lbuser' + # disconnect via the generic endpoint + assert client.post('/api/profiles/me/connections/listenbrainz/disconnect').get_json()['success'] + assert client.get('/api/profiles/me/connections').get_json()['connections']['listenbrainz']['connected'] is False diff --git a/web_server.py b/web_server.py index 9f5e1cd0..e1b1dfa6 100644 --- a/web_server.py +++ b/web_server.py @@ -25430,6 +25430,19 @@ def _profile_tidal_connection(profile_id): return (False, None) +def _profile_listenbrainz_connection(profile_id): + """(connected, username) for a profile's OWN ListenBrainz token.""" + if not profile_id or profile_id == 1: + return (False, None) + try: + s = get_database().get_profile_listenbrainz(profile_id) or {} + if s.get('token'): + return (True, s.get('username')) + except Exception as e: + logger.debug("profile %s listenbrainz connection check failed: %s", profile_id, e) + return (False, None) + + @app.route('/api/profiles/me/connections', methods=['GET']) def get_my_connections(): """Per-profile playlist-service connection status for the My Accounts modal. @@ -25438,12 +25451,14 @@ def get_my_connections(): pid = get_current_profile_id() sp_connected, sp_account = _profile_spotify_connection(pid) td_connected, td_account = _profile_tidal_connection(pid) + lb_connected, lb_account = _profile_listenbrainz_connection(pid) return jsonify({ 'success': True, 'is_admin': pid == 1, 'connections': { 'spotify': {'connected': sp_connected, 'account': sp_account}, 'tidal': {'connected': td_connected, 'account': td_account}, + 'listenbrainz': {'connected': lb_connected, 'account': lb_account}, }, }) except Exception as e: @@ -25472,9 +25487,17 @@ def _disconnect_profile_tidal(pid): clear_profile_tidal_client(pid) +def _disconnect_profile_listenbrainz(pid): + try: + get_database().clear_profile_listenbrainz(pid) + except Exception as e: + logger.debug("could not clear profile listenbrainz: %s", e) + + _PROFILE_DISCONNECTORS = { 'spotify': _disconnect_profile_spotify, 'tidal': _disconnect_profile_tidal, + 'listenbrainz': _disconnect_profile_listenbrainz, } diff --git a/webui/static/init.js b/webui/static/init.js index 6d069d68..f81f2968 100644 --- a/webui/static/init.js +++ b/webui/static/init.js @@ -914,56 +914,16 @@ async function openPersonalSettings() { body.innerHTML = '
Loading...
'; try { - // Load all per-profile service data in parallel - const [lbRes, spotifyRes] = await Promise.all([ - fetch('/api/profiles/me/listenbrainz'), - fetch('/api/profiles/me/spotify'), - ]); - const lbData = await lbRes.json(); - const spotifyData = await spotifyRes.json(); - body.innerHTML = ''; const isNonAdmin = currentProfile && !currentProfile.is_admin; + // Streaming-account connections now live in the My Accounts modal (the ♫ + // button). Personal Settings keeps only the per-profile server library. if (isNonAdmin) { - // Tabbed layout for non-admin with multiple sections - const tabs = [ - { id: 'music', label: 'Music Services' }, - { id: 'server', label: 'Server' }, - { id: 'scrobble', label: 'Scrobbling' }, - ]; - const tabBar = document.createElement('div'); - tabBar.className = 'ps-tabbar'; - tabs.forEach((t, i) => { - const btn = document.createElement('button'); - btn.className = 'ps-tab' + (i === 0 ? ' active' : ''); - btn.textContent = t.label; - btn.onclick = () => { - tabBar.querySelectorAll('.ps-tab').forEach(b => b.classList.remove('active')); - btn.classList.add('active'); - body.querySelectorAll('.ps-tab-content').forEach(c => c.classList.remove('active')); - const target = document.getElementById(`ps-tab-${t.id}`); - if (target) target.classList.add('active'); - }; - tabBar.appendChild(btn); - }); - body.appendChild(tabBar); - - // Music Services tab - const musicTab = document.createElement('div'); - musicTab.id = 'ps-tab-music'; - musicTab.className = 'ps-tab-content active'; - renderPersonalSettingsSpotify(musicTab, spotifyData); - renderPersonalSettingsTidal(musicTab); - body.appendChild(musicTab); - - // Server tab const serverTab = document.createElement('div'); - serverTab.id = 'ps-tab-server'; - serverTab.className = 'ps-tab-content'; + serverTab.style.padding = '18px 22px 22px'; serverTab.innerHTML = '
Loading libraries...
'; body.appendChild(serverTab); - // Load server libraries async (don't block modal) fetch('/api/profiles/me/server-library').then(r => r.json()).then(libData => { serverTab.innerHTML = ''; renderPersonalSettingsServerLibrary(serverTab, libData); @@ -971,21 +931,13 @@ async function openPersonalSettings() { serverTab.innerHTML = ''; renderPersonalSettingsServerLibrary(serverTab, {}); }); - - // Scrobbling tab - const scrobbleTab = document.createElement('div'); - scrobbleTab.id = 'ps-tab-scrobble'; - scrobbleTab.className = 'ps-tab-content'; - body.appendChild(scrobbleTab); - // Render LB into the scrobble tab - const origBody = body; - renderPersonalSettingsLB(lbData, scrobbleTab); } else { - // Admin: just ListenBrainz, no tabs const content = document.createElement('div'); - content.style.padding = '18px 22px 22px'; + content.style.padding = '24px'; + content.innerHTML = '
' + + 'Your streaming accounts are in My Accounts (the ♫ button next to your profile).
' + + 'Global service setup lives in Settings.
'; body.appendChild(content); - renderPersonalSettingsLB(lbData, content); } } catch (e) { body.innerHTML = '
Failed to load settings
'; diff --git a/webui/static/my-accounts.js b/webui/static/my-accounts.js index 71325090..e77e1a4e 100644 --- a/webui/static/my-accounts.js +++ b/webui/static/my-accounts.js @@ -23,6 +23,13 @@ const _MA_SERVICES = [ logo: 'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/tidal-light.png', connect: (pid) => `/auth/tidal?profile_id=${pid}`, }, + { + id: 'listenbrainz', name: 'ListenBrainz', brand: '#eb743b', dark: true, + logo: 'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/listenbrainz.png', + type: 'token', + saveUrl: '/api/profiles/me/listenbrainz', + hint: 'Paste your token from listenbrainz.org/profile', + }, ]; function _maEsc(s) { @@ -99,6 +106,11 @@ function _maRender(body, data) { action = ` ${_maEsc(c.account || 'Connected')} `; + } else if (svc.type === 'token') { + action = ` + + `; } else { action = ``; } @@ -135,6 +147,29 @@ function connectMyAccount(serviceId) { }, 800); } +async function saveMyAccountToken(serviceId) { + const svc = _MA_SERVICES.find(s => s.id === serviceId); + if (!svc || !svc.saveUrl) return; + const input = document.getElementById(`ma-token-${serviceId}`); + const token = (input && input.value || '').trim(); + if (!token) { if (typeof showToast === 'function') showToast('Paste a token first', 'info'); return; } + try { + const res = await fetch(svc.saveUrl, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }); + const data = await res.json(); + if (data.success) { + if (typeof showToast === 'function') showToast(`${svc.name} connected`, 'success'); + _maLoad(); + } else if (typeof showToast === 'function') { + showToast(data.error || 'Could not connect', 'error'); + } + } catch (e) { + if (typeof showToast === 'function') showToast('Could not connect', 'error'); + } +} + async function disconnectMyAccount(serviceId) { if (!confirm(`Disconnect your ${serviceId} account from this profile?`)) return; try { diff --git a/webui/static/style.css b/webui/static/style.css index a8710691..42584cb2 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -67723,3 +67723,8 @@ body.em-scroll-lock { overflow: hidden; } .ma-btn--ghost { background: transparent; color: rgba(255,255,255,0.6); border: 1px solid rgba(255,255,255,0.18); } .ma-btn--ghost:hover { background: rgba(255,255,255,0.06); color: #fff; } .ma-disc.ma-disc--dark { background: #1f2329; } +.ma-token-input { + background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.14); border-radius: 8px; + padding: 7px 11px; color: #fff; font-size: 0.82rem; width: 150px; +} +.ma-token-input:focus { outline: none; border-color: var(--ma-brand); }