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 = ` +
+ + +
+
+ + +
+
+ + +
+ Create an app at developer.spotify.com and add the redirect URI +
+
+
+
+ +
+ `; + } + + const section = document.createElement('div'); + section.id = 'ps-spotify-section'; + section.innerHTML = ` +
+
+

Spotify

+ + + ${hasCreds ? 'Configured' : 'Not configured'} + +
+
+ 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 = ` +
+
+

Media Server Library

+ + + ${hasAny ? 'Customized' : 'Using default'} + +
+
+ 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 = `

ListenBrainz

@@ -1333,6 +1563,10 @@ function renderPersonalSettingsLB(data) { ${contentHtml}
`; + // Replace existing or append + const existing = document.getElementById('ps-listenbrainz-section'); + if (existing) existing.replaceWith(section); + else body.appendChild(section); } async function testPersonalListenBrainz() {