diff --git a/web_server.py b/web_server.py
index 1a253e26..8856ff44 100644
--- a/web_server.py
+++ b/web_server.py
@@ -5774,10 +5774,36 @@ def detect_soulseek_endpoint():
@app.route('/auth/spotify')
def auth_spotify():
"""
- Initiates Spotify OAuth authentication flow
+ Initiates Spotify OAuth authentication flow.
+ Supports per-profile auth via ?profile_id= query param.
"""
try:
- # Create a fresh spotify client to trigger OAuth
+ profile_id = request.args.get('profile_id', '')
+
+ # Per-profile auth: use profile's own credentials
+ if profile_id and profile_id != '1':
+ try:
+ profile_id_int = int(profile_id)
+ db = get_database()
+ creds = db.get_profile_spotify(profile_id_int)
+ if creds and creds.get('client_id'):
+ from spotipy.oauth2 import SpotifyOAuth
+ redirect_uri = creds.get('redirect_uri') or config_manager.get_spotify_config().get('redirect_uri', 'http://127.0.0.1:8888/callback')
+ auth_manager = SpotifyOAuth(
+ client_id=creds['client_id'],
+ client_secret=creds['client_secret'],
+ redirect_uri=redirect_uri,
+ scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email",
+ cache_path=f'config/.spotify_cache_profile_{profile_id_int}',
+ state=f'profile_{profile_id_int}'
+ )
+ auth_url = auth_manager.get_authorize_url()
+ print(f"🎵 Per-profile Spotify auth initiated for profile {profile_id_int}")
+ return redirect(auth_url)
+ except (ValueError, Exception) as e:
+ print(f"⚠️ Per-profile Spotify auth failed, falling back to global: {e}")
+
+ # Global auth (admin or fallback)
temp_spotify_client = SpotifyClient()
if temp_spotify_client.sp and temp_spotify_client.sp.auth_manager:
# Get the authorization URL
@@ -6051,11 +6077,46 @@ def spotify_callback():
print(f"🎵 Spotify callback received on port 8008 with authorization code")
+ # Check for per-profile state parameter
+ state = request.args.get('state', '')
+ profile_id_from_state = None
+ if state and state.startswith('profile_'):
+ try:
+ profile_id_from_state = int(state.replace('profile_', ''))
+ print(f"🎵 Per-profile callback detected for profile {profile_id_from_state}")
+ except ValueError:
+ pass
+
try:
from core.spotify_client import SpotifyClient
from spotipy.oauth2 import SpotifyOAuth
from config.settings import config_manager
+ # Per-profile callback: use profile's credentials
+ if profile_id_from_state and profile_id_from_state != 1:
+ db = get_database()
+ creds = db.get_profile_spotify(profile_id_from_state)
+ if creds and creds.get('client_id'):
+ redirect_uri = creds.get('redirect_uri') or config_manager.get_spotify_config().get('redirect_uri', 'http://127.0.0.1:8888/callback')
+ auth_manager = SpotifyOAuth(
+ client_id=creds['client_id'],
+ client_secret=creds['client_secret'],
+ redirect_uri=redirect_uri,
+ scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email",
+ cache_path=f'config/.spotify_cache_profile_{profile_id_from_state}',
+ state=f'profile_{profile_id_from_state}'
+ )
+ token_info = auth_manager.get_access_token(auth_code, as_dict=True)
+ if token_info:
+ # Invalidate cached profile client so it gets recreated with new tokens
+ with _profile_spotify_lock:
+ _profile_spotify_clients.pop(profile_id_from_state, None)
+ add_activity_item("✅", "Spotify Auth Complete", f"Profile {profile_id_from_state} authenticated with Spotify", "Now")
+ return "
Spotify Authentication Successful!
Your personal Spotify account is now connected. You can close this window.
"
+ else:
+ raise Exception("Failed to exchange authorization code for access token")
+
+ # Global callback (admin)
config = config_manager.get_spotify_config()
configured_uri = config.get('redirect_uri', "http://127.0.0.1:8888/callback")
print(f"🎵 Using redirect_uri for token exchange: {configured_uri}")
diff --git a/webui/static/script.js b/webui/static/script.js
index 8218f1ab..58aafcfa 100644
--- a/webui/static/script.js
+++ b/webui/static/script.js
@@ -1243,9 +1243,28 @@ async function openPersonalSettings() {
body.innerHTML = 'Loading...
';
try {
- const res = await fetch('/api/profiles/me/listenbrainz');
- const data = await res.json();
- renderPersonalSettingsLB(data);
+ // 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();
+
+ // Build sections
+ body.innerHTML = '';
+ // Spotify + server library per-profile only shown for non-admin profiles
+ if (currentProfile && !currentProfile.is_admin) {
+ renderPersonalSettingsSpotify(body, spotifyData);
+ try {
+ const libRes = await fetch('/api/profiles/me/server-library');
+ const libData = await libRes.json();
+ renderPersonalSettingsServerLibrary(body, libData);
+ } catch (e) {
+ console.debug('Failed to load server library settings:', e);
+ }
+ }
+ renderPersonalSettingsLB(lbData);
} catch (e) {
body.innerHTML = 'Failed to load settings
';
}
@@ -1256,6 +1275,215 @@ function closePersonalSettings() {
if (overlay) overlay.style.display = 'none';
}
+function renderPersonalSettingsSpotify(body, data) {
+ const hasCreds = data.has_credentials;
+ const clientId = data.client_id || '';
+
+ let contentHtml;
+ if (hasCreds) {
+ contentHtml = `
+
+
🟢
+
+
Credentials configured
+
Client ID: ${escapeHtml(clientId.substring(0, 8))}...
+
Personal Spotify app
+
+
+
+
+
+
+ `;
+ } else {
+ contentHtml = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ }
+
+ const section = document.createElement('div');
+ section.id = 'ps-spotify-section';
+ section.innerHTML = `
+
+
+
+ Connect your own Spotify account to see your playlists instead of the admin's.
+
+ ${contentHtml}
+
+ `;
+
+ const existing = document.getElementById('ps-spotify-section');
+ if (existing) existing.replaceWith(section);
+ else body.appendChild(section);
+}
+
+async function savePersonalSpotify() {
+ const clientId = document.getElementById('ps-spotify-client-id')?.value?.trim();
+ const clientSecret = document.getElementById('ps-spotify-client-secret')?.value?.trim();
+ const redirectUri = document.getElementById('ps-spotify-redirect-uri')?.value?.trim();
+ const resultEl = document.getElementById('ps-spotify-result');
+
+ if (!clientId || !clientSecret) {
+ if (resultEl) resultEl.innerHTML = 'Client ID and Secret are required
';
+ return;
+ }
+
+ try {
+ const res = await fetch('/api/profiles/me/spotify', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ client_id: clientId, client_secret: clientSecret, redirect_uri: redirectUri })
+ });
+ const data = await res.json();
+ if (data.success) {
+ showToast('Spotify credentials saved', 'success');
+ openPersonalSettings(); // Reload to show connected state
+ } else {
+ if (resultEl) resultEl.innerHTML = `${data.error || 'Failed to save'}
`;
+ }
+ } catch (e) {
+ if (resultEl) resultEl.innerHTML = 'Network error
';
+ }
+}
+
+async function authenticatePersonalSpotify() {
+ // Trigger OAuth flow with profile_id in state so callback knows which profile
+ window.open('/auth/spotify?profile_id=' + (currentProfile?.id || ''), '_blank');
+}
+
+function renderPersonalSettingsServerLibrary(body, data) {
+ const plexLib = data.plex_library_id || '';
+ const jellyfinUser = data.jellyfin_user_id || '';
+ const jellyfinLib = data.jellyfin_library_id || '';
+ const navidromeLib = data.navidrome_library_id || '';
+ const hasAny = plexLib || jellyfinUser || jellyfinLib || navidromeLib;
+
+ const section = document.createElement('div');
+ section.id = 'ps-server-library-section';
+ section.innerHTML = `
+
+
+
+ Choose which library playlists sync to. Leave empty to use the admin's default.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${hasAny ? '' : ''}
+
+
+ `;
+
+ const existing = document.getElementById('ps-server-library-section');
+ if (existing) existing.replaceWith(section);
+ else body.appendChild(section);
+}
+
+async function savePersonalServerLibrary() {
+ const resultEl = document.getElementById('ps-server-result');
+ try {
+ // Save each server type that has a value
+ const plex = document.getElementById('ps-plex-library-id')?.value?.trim();
+ const jellyfinUser = document.getElementById('ps-jellyfin-user-id')?.value?.trim();
+ const jellyfinLib = document.getElementById('ps-jellyfin-library-id')?.value?.trim();
+ const navidrome = document.getElementById('ps-navidrome-library-id')?.value?.trim();
+
+ const saves = [];
+ if (plex !== undefined) saves.push(fetch('/api/profiles/me/server-library', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ server_type: 'plex', library_id: plex || null })
+ }));
+ if (jellyfinUser !== undefined || jellyfinLib !== undefined) saves.push(fetch('/api/profiles/me/server-library', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ server_type: 'jellyfin', user_id: jellyfinUser || null, library_id: jellyfinLib || null })
+ }));
+ if (navidrome !== undefined) saves.push(fetch('/api/profiles/me/server-library', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ server_type: 'navidrome', library_id: navidrome || null })
+ }));
+
+ await Promise.all(saves);
+ showToast('Server library settings saved', 'success');
+ openPersonalSettings();
+ } catch (e) {
+ if (resultEl) resultEl.innerHTML = 'Failed to save
';
+ }
+}
+
+async function clearPersonalServerLibrary() {
+ try {
+ await Promise.all([
+ fetch('/api/profiles/me/server-library', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ server_type: 'plex', library_id: null }) }),
+ fetch('/api/profiles/me/server-library', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ server_type: 'jellyfin', user_id: null, library_id: null }) }),
+ fetch('/api/profiles/me/server-library', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ server_type: 'navidrome', library_id: null }) }),
+ ]);
+ showToast('Server library settings reset to default', 'info');
+ openPersonalSettings();
+ } catch (e) {
+ showToast('Error resetting settings', 'error');
+ }
+}
+
+async function disconnectPersonalSpotify() {
+ try {
+ const res = await fetch('/api/profiles/me/spotify', { method: 'DELETE' });
+ const data = await res.json();
+ if (data.success) {
+ showToast('Spotify credentials removed — using shared config', 'info');
+ openPersonalSettings(); // Reload
+ }
+ } catch (e) {
+ showToast('Error removing credentials', 'error');
+ }
+}
+
function renderPersonalSettingsLB(data) {
const body = document.getElementById('personal-settings-body');
const connected = data.connected;
@@ -1321,7 +1549,9 @@ function renderPersonalSettingsLB(data) {
contentHtml = tokenFormHtml;
}
- body.innerHTML = `
+ const section = document.createElement('div');
+ section.id = 'ps-listenbrainz-section';
+ section.innerHTML = `
`;
+ // Replace existing or append
+ const existing = document.getElementById('ps-listenbrainz-section');
+ if (existing) existing.replaceWith(section);
+ else body.appendChild(section);
}
async function testPersonalListenBrainz() {