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:
parent
06caf43f2f
commit
e7fe083099
2 changed files with 301 additions and 6 deletions
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Reference in a new issue