Add per-profile Spotify credentials and server library selection

Complete end-to-end per-profile Spotify support:
- DB migration: 11 columns on profiles table (Spotify creds, Tidal
  tokens, server library IDs) with encryption
- API endpoints: save/load/delete Spotify creds + server library
- Per-profile Spotify client cache with separate OAuth token storage
- Profile-aware OAuth flow (auth initiation + callback via state param)
- Playlist listing endpoint uses per-profile client
- Frontend: Spotify + Server Library + ListenBrainz sections in
  personal settings modal (non-admin only)
- Admin users see zero change — fully backwards compatible
- Tidal per-profile deferred (needs device auth flow)
This commit is contained in:
Broque Thomas 2026-03-20 11:49:30 -07:00
parent 06caf43f2f
commit e7fe083099
2 changed files with 301 additions and 6 deletions

View file

@ -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 "<h1>Spotify Authentication Successful!</h1><p>Your personal Spotify account is now connected. You can close this window.</p>"
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}")

View file

@ -1243,9 +1243,28 @@ async function openPersonalSettings() {
body.innerHTML = '<div style="text-align:center;padding:20px;color:rgba(255,255,255,0.4);">Loading...</div>';
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 = '<div style="color:#ef4444;padding:16px;">Failed to load settings</div>';
}
@ -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 = `
<div class="ps-connected-info">
<div class="ps-connected-icon">🟢</div>
<div class="ps-connected-details">
<div class="ps-connected-username">Credentials configured</div>
<div class="ps-connected-server">Client ID: ${escapeHtml(clientId.substring(0, 8))}...</div>
<div class="ps-connected-source">Personal Spotify app</div>
</div>
</div>
<div class="ps-actions">
<button class="ps-btn ps-btn-primary" onclick="authenticatePersonalSpotify()">🔐 Authenticate</button>
<button class="ps-btn ps-btn-danger" onclick="disconnectPersonalSpotify()">Remove</button>
</div>
`;
} else {
contentHtml = `
<div class="ps-form-group">
<label>Client ID</label>
<input type="text" id="ps-spotify-client-id" placeholder="Your Spotify Client ID">
</div>
<div class="ps-form-group">
<label>Client Secret</label>
<input type="password" id="ps-spotify-client-secret" placeholder="Your Spotify Client Secret">
</div>
<div class="ps-form-group">
<label>Redirect URI <span style="font-weight:400;color:rgba(255,255,255,0.3)">(optional)</span></label>
<input type="text" id="ps-spotify-redirect-uri" placeholder="http://127.0.0.1:8888/callback">
<div class="ps-help-text">
Create an app at <a href="https://developer.spotify.com/dashboard" target="_blank">developer.spotify.com</a> and add the redirect URI
</div>
</div>
<div id="ps-spotify-result"></div>
<div class="ps-actions">
<button class="ps-btn ps-btn-primary" onclick="savePersonalSpotify()">Save Credentials</button>
</div>
`;
}
const section = document.createElement('div');
section.id = 'ps-spotify-section';
section.innerHTML = `
<div class="ps-section">
<div class="ps-section-header">
<h4 class="ps-section-title">Spotify</h4>
<span class="ps-connection-badge ${hasCreds ? 'connected' : 'disconnected'}">
<span class="ps-connection-dot"></span>
${hasCreds ? 'Configured' : 'Not configured'}
</span>
</div>
<div class="ps-help-text" style="margin-bottom:12px;">
Connect your own Spotify account to see your playlists instead of the admin's.
</div>
${contentHtml}
</div>
`;
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 = '<div style="color:#ef4444;font-size:12px;margin-top:8px;">Client ID and Secret are required</div>';
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 = `<div style="color:#ef4444;font-size:12px;margin-top:8px;">${data.error || 'Failed to save'}</div>`;
}
} catch (e) {
if (resultEl) resultEl.innerHTML = '<div style="color:#ef4444;font-size:12px;margin-top:8px;">Network error</div>';
}
}
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 = `
<div class="ps-section">
<div class="ps-section-header">
<h4 class="ps-section-title">Media Server Library</h4>
<span class="ps-connection-badge ${hasAny ? 'connected' : 'disconnected'}">
<span class="ps-connection-dot"></span>
${hasAny ? 'Customized' : 'Using default'}
</span>
</div>
<div class="ps-help-text" style="margin-bottom:12px;">
Choose which library playlists sync to. Leave empty to use the admin's default.
</div>
<div class="ps-form-group">
<label>Plex Library ID</label>
<input type="text" id="ps-plex-library-id" value="${escapeHtml(plexLib)}" placeholder="Leave empty for default">
</div>
<div class="ps-form-group">
<label>Jellyfin User ID</label>
<input type="text" id="ps-jellyfin-user-id" value="${escapeHtml(jellyfinUser)}" placeholder="Leave empty for default">
</div>
<div class="ps-form-group">
<label>Jellyfin Library ID</label>
<input type="text" id="ps-jellyfin-library-id" value="${escapeHtml(jellyfinLib)}" placeholder="Leave empty for default">
</div>
<div class="ps-form-group">
<label>Navidrome Library ID</label>
<input type="text" id="ps-navidrome-library-id" value="${escapeHtml(navidromeLib)}" placeholder="Leave empty for default">
</div>
<div id="ps-server-result"></div>
<div class="ps-actions">
<button class="ps-btn ps-btn-primary" onclick="savePersonalServerLibrary()">Save</button>
${hasAny ? '<button class="ps-btn ps-btn-danger" onclick="clearPersonalServerLibrary()">Reset to Default</button>' : ''}
</div>
</div>
`;
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 = '<div style="color:#ef4444;font-size:12px;margin-top:8px;">Failed to save</div>';
}
}
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 = `
<div class="ps-section">
<div class="ps-section-header">
<h4 class="ps-section-title">ListenBrainz</h4>
@ -1333,6 +1563,10 @@ function renderPersonalSettingsLB(data) {
${contentHtml}
</div>
`;
// Replace existing or append
const existing = document.getElementById('ps-listenbrainz-section');
if (existing) existing.replaceWith(section);
else body.appendChild(section);
}
async function testPersonalListenBrainz() {