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); }