Profiles: ListenBrainz in My Accounts; Personal Settings now just server library

Third service (the easy one — ListenBrainz already had a working per-profile
token path). Consolidated all per-profile streaming accounts into the My Accounts
modal:
- My Accounts gains a ListenBrainz row with a token-paste connect (a new 'token'
  service type alongside the OAuth-popup ones), reusing the existing
  /api/profiles/me/listenbrainz save + the generic disconnect.
- Connections API reports listenbrainz status (connected + username).
- Personal Settings (the gear modal) dropped its Spotify/Tidal/ListenBrainz
  sections — those duplicated My Accounts — and now shows only the per-profile
  server-library selection (non-admin) or a pointer note (admin). The old
  renderPersonalSettings{Spotify,Tidal,LB} functions are left defined but unused.

So every per-profile account connection (Spotify, Tidal, ListenBrainz) now lives
in one place. Tests: LB connect status + disconnect via the generic endpoint.
23 endpoint tests pass; 64 integrity tests pass.
pull/529/merge
BoulderBadgeDad 3 weeks ago
parent 60b9fe10e9
commit af1a35385c

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

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

@ -914,56 +914,16 @@ async function openPersonalSettings() {
body.innerHTML = '<div style="text-align:center;padding:20px;color:rgba(255,255,255,0.4);">Loading...</div>';
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 = '<div style="text-align:center;padding:20px;color:rgba(255,255,255,0.3);">Loading libraries...</div>';
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 = '<div style="color:rgba(255,255,255,0.55);font-size:0.9rem;line-height:1.7;">'
+ 'Your streaming accounts are in <b>My Accounts</b> (the ♫ button next to your profile).<br>'
+ 'Global service setup lives in <b>Settings</b>.</div>';
body.appendChild(content);
renderPersonalSettingsLB(lbData, content);
}
} catch (e) {
body.innerHTML = '<div style="color:#ef4444;padding:16px;">Failed to load settings</div>';

@ -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 = `
<span class="ma-account">${_maEsc(c.account || 'Connected')}</span>
<button class="ma-btn ma-btn--ghost" onclick="disconnectMyAccount('${svc.id}')">Disconnect</button>`;
} else if (svc.type === 'token') {
action = `
<input type="password" class="ma-token-input" id="ma-token-${svc.id}" placeholder="Paste token"
title="${_maEsc(svc.hint || '')}">
<button class="ma-btn ma-btn--connect" onclick="saveMyAccountToken('${svc.id}')">Save</button>`;
} else {
action = `<button class="ma-btn ma-btn--connect" onclick="connectMyAccount('${svc.id}')">Connect</button>`;
}
@ -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 {

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

Loading…
Cancel
Save