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.
This commit is contained in:
parent
60b9fe10e9
commit
af1a35385c
5 changed files with 90 additions and 55 deletions
|
|
@ -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…
Reference in a new issue