Complete per-profile service credentials feature

Adds Tidal per-profile OAuth with token storage on profile row.
Auth initiation stores profile_id in PKCE state, callback detects
it and stores encrypted tokens per-profile instead of globally.

Personal settings modal now shows Spotify, Tidal, Server Library,
and ListenBrainz sections for non-admin profiles. Admin sees only
ListenBrainz (unchanged). Server library selection wired into
playlist sync via _apply_profile_library.

Full per-profile support: Spotify (credentials + OAuth + playlists),
Tidal (OAuth + token storage), server library (Plex/Jellyfin/Navidrome),
ListenBrainz (existing). All backwards compatible — upgrading users
see zero change.
This commit is contained in:
Broque Thomas 2026-03-20 12:08:20 -07:00
parent be27c36da2
commit 53477768cb
2 changed files with 60 additions and 4 deletions

View file

@ -5958,6 +5958,11 @@ def auth_tidal():
print(f"🔐 Stored PKCE - verifier: {temp_tidal_client.code_verifier[:20]}... challenge: {temp_tidal_client.code_challenge[:20]}...")
# Store profile_id for per-profile auth
profile_id = request.args.get('profile_id', '')
with tidal_oauth_lock:
tidal_oauth_state["profile_id"] = profile_id if profile_id and profile_id != '1' else None
# Create OAuth URL
import urllib.parse
params = {
@ -6216,17 +6221,42 @@ def tidal_callback():
# Create a temporary client for the token exchange
temp_tidal_client = TidalClient()
# Restore PKCE values and redirect_uri from the auth request
# Restore PKCE values, redirect_uri, and profile_id from the auth request
profile_id_for_tidal = None
with tidal_oauth_lock:
temp_tidal_client.code_verifier = tidal_oauth_state["code_verifier"]
temp_tidal_client.code_challenge = tidal_oauth_state["code_challenge"]
if "redirect_uri" in tidal_oauth_state:
temp_tidal_client.redirect_uri = tidal_oauth_state["redirect_uri"]
profile_id_for_tidal = tidal_oauth_state.get("profile_id")
success = temp_tidal_client.fetch_token_from_code(auth_code)
if success:
# Re-initialize the main global tidal_client instance with the new token
# Per-profile: store tokens on profile, don't touch global client
if profile_id_for_tidal:
try:
profile_id_int = int(profile_id_for_tidal)
db = get_database()
# Store Tidal tokens on the profile
from config.settings import config_manager as _cm
enc_access = _cm._encrypt_value(temp_tidal_client.access_token) if temp_tidal_client.access_token else None
enc_refresh = _cm._encrypt_value(temp_tidal_client.refresh_token) if temp_tidal_client.refresh_token else None
with db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE profiles
SET tidal_access_token = ?, tidal_refresh_token = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""", (enc_access, enc_refresh, profile_id_int))
conn.commit()
add_activity_item("", "Tidal Auth Complete", f"Profile {profile_id_int} authenticated with Tidal", "Now")
return "<h1>✅ Tidal Authentication Successful!</h1><p>Your personal Tidal account is now connected. You can close this window.</p>"
except Exception as profile_err:
print(f"⚠️ Per-profile Tidal auth failed, falling back to global: {profile_err}")
# Global: Re-initialize the main global tidal_client instance with the new token
tidal_client = TidalClient()
if tidal_enrichment_worker:
tidal_enrichment_worker.client = tidal_client

View file

@ -1253,9 +1253,10 @@ async function openPersonalSettings() {
// Build sections
body.innerHTML = '';
// Spotify + server library per-profile only shown for non-admin profiles
// Spotify + Tidal + server library per-profile only shown for non-admin profiles
if (currentProfile && !currentProfile.is_admin) {
renderPersonalSettingsSpotify(body, spotifyData);
renderPersonalSettingsTidal(body);
try {
const libRes = await fetch('/api/profiles/me/server-library');
const libData = await libRes.json();
@ -1376,6 +1377,31 @@ async function authenticatePersonalSpotify() {
window.open('/auth/spotify?profile_id=' + (currentProfile?.id || ''), '_blank');
}
function renderPersonalSettingsTidal(body) {
const section = document.createElement('div');
section.id = 'ps-tidal-section';
section.innerHTML = `
<div class="ps-section">
<div class="ps-section-header">
<h4 class="ps-section-title">Tidal</h4>
</div>
<div class="ps-help-text" style="margin-bottom:12px;">
Connect your own Tidal account to see your playlists. Uses the admin's Tidal app credentials.
</div>
<div class="ps-actions">
<button class="ps-btn ps-btn-primary" onclick="authenticatePersonalTidal()">🔐 Authenticate Tidal</button>
</div>
</div>
`;
const existing = document.getElementById('ps-tidal-section');
if (existing) existing.replaceWith(section);
else body.appendChild(section);
}
function authenticatePersonalTidal() {
window.open('/auth/tidal?profile_id=' + (currentProfile?.id || ''), '_blank');
}
function renderPersonalSettingsServerLibrary(body, data) {
const plexLib = data.plex_library_id || '';
const jellyfinUser = data.jellyfin_user_id || '';