Add per-profile service credentials foundation (DB + API)

DB migration adds 11 columns to profiles table for per-profile Spotify
credentials, Tidal tokens, and media server library selection. All
encrypted, all default NULL (fall back to global config).

API endpoints follow existing ListenBrainz per-profile pattern:
- GET/POST/DELETE /api/profiles/me/spotify
- GET/POST /api/profiles/me/server-library

Foundation only — no frontend UI or client initialization changes yet.
This commit is contained in:
Broque Thomas 2026-03-20 10:30:56 -07:00
parent be77397132
commit efe4561bcb
2 changed files with 247 additions and 0 deletions

View file

@ -355,6 +355,7 @@ class MusicDatabase:
self._add_profile_support_v4(cursor)
self._add_profile_settings(cursor)
self._add_profile_listenbrainz_support(cursor)
self._add_profile_service_credentials(cursor)
# Spotify library cache
self._add_spotify_library_cache_table(cursor)
@ -2581,6 +2582,158 @@ class MusicDatabase:
logger.error(f"Error getting profiles with ListenBrainz tokens: {e}")
return []
# ── Per-profile service credentials (Spotify, Tidal, server library) ──
def _add_profile_service_credentials(self, cursor):
"""Add per-profile Spotify, Tidal, and media server library columns to profiles table."""
try:
cursor.execute("SELECT value FROM metadata WHERE key = 'profiles_services_v1' LIMIT 1")
if cursor.fetchone():
return # Already migrated
logger.info("Applying per-profile service credentials migration...")
columns = [
# Spotify per-profile
"ALTER TABLE profiles ADD COLUMN spotify_client_id TEXT DEFAULT NULL",
"ALTER TABLE profiles ADD COLUMN spotify_client_secret TEXT DEFAULT NULL",
"ALTER TABLE profiles ADD COLUMN spotify_redirect_uri TEXT DEFAULT NULL",
"ALTER TABLE profiles ADD COLUMN spotify_access_token TEXT DEFAULT NULL",
"ALTER TABLE profiles ADD COLUMN spotify_refresh_token TEXT DEFAULT NULL",
# Tidal per-profile
"ALTER TABLE profiles ADD COLUMN tidal_access_token TEXT DEFAULT NULL",
"ALTER TABLE profiles ADD COLUMN tidal_refresh_token TEXT DEFAULT NULL",
# Media server library selection per-profile
"ALTER TABLE profiles ADD COLUMN plex_library_id TEXT DEFAULT NULL",
"ALTER TABLE profiles ADD COLUMN jellyfin_user_id TEXT DEFAULT NULL",
"ALTER TABLE profiles ADD COLUMN jellyfin_library_id TEXT DEFAULT NULL",
"ALTER TABLE profiles ADD COLUMN navidrome_library_id TEXT DEFAULT NULL",
]
for sql in columns:
try:
cursor.execute(sql)
except sqlite3.OperationalError:
pass # Column already exists
cursor.execute("""
INSERT OR REPLACE INTO metadata (key, value) VALUES ('profiles_services_v1', '1')
""")
logger.info("Per-profile service credentials migration completed")
except Exception as e:
logger.error(f"Error in per-profile service credentials migration: {e}")
def set_profile_spotify(self, profile_id: int, client_id: str, client_secret: str,
redirect_uri: str = '') -> bool:
"""Save Spotify API credentials for a profile (encrypted)."""
try:
from config.settings import config_manager
enc_id = config_manager._encrypt_value(client_id) if client_id else None
enc_secret = config_manager._encrypt_value(client_secret) if client_secret else None
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE profiles
SET spotify_client_id = ?, spotify_client_secret = ?, spotify_redirect_uri = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""", (enc_id, enc_secret, redirect_uri or None, profile_id))
conn.commit()
return cursor.rowcount > 0
except Exception as e:
logger.error(f"Error setting Spotify credentials for profile {profile_id}: {e}")
return False
def get_profile_spotify(self, profile_id: int) -> Dict[str, Any]:
"""Get decrypted Spotify credentials for a profile."""
try:
from config.settings import config_manager
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT spotify_client_id, spotify_client_secret, spotify_redirect_uri,
spotify_access_token, spotify_refresh_token
FROM profiles WHERE id = ?
""", (profile_id,))
row = cursor.fetchone()
if not row or not row[0]:
return {}
return {
'client_id': config_manager._decrypt_value(row[0]) if row[0] else '',
'client_secret': config_manager._decrypt_value(row[1]) if row[1] else '',
'redirect_uri': row[2] or '',
'access_token': config_manager._decrypt_value(row[3]) if row[3] else '',
'refresh_token': config_manager._decrypt_value(row[4]) if row[4] else '',
}
except Exception as e:
logger.error(f"Error getting Spotify credentials for profile {profile_id}: {e}")
return {}
def set_profile_spotify_tokens(self, profile_id: int, access_token: str, refresh_token: str) -> bool:
"""Save Spotify OAuth tokens for a profile (from auth callback)."""
try:
from config.settings import config_manager
enc_access = config_manager._encrypt_value(access_token) if access_token else None
enc_refresh = config_manager._encrypt_value(refresh_token) if refresh_token else None
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE profiles
SET spotify_access_token = ?, spotify_refresh_token = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""", (enc_access, enc_refresh, profile_id))
conn.commit()
return cursor.rowcount > 0
except Exception as e:
logger.error(f"Error setting Spotify tokens for profile {profile_id}: {e}")
return False
def set_profile_server_library(self, profile_id: int, server_type: str,
library_id: str = None, user_id: str = None) -> bool:
"""Save media server library/user selection for a profile."""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
if server_type == 'plex':
cursor.execute("UPDATE profiles SET plex_library_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(library_id, profile_id))
elif server_type == 'jellyfin':
cursor.execute("UPDATE profiles SET jellyfin_user_id = ?, jellyfin_library_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(user_id, library_id, profile_id))
elif server_type == 'navidrome':
cursor.execute("UPDATE profiles SET navidrome_library_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(library_id, profile_id))
conn.commit()
return cursor.rowcount > 0
except Exception as e:
logger.error(f"Error setting server library for profile {profile_id}: {e}")
return False
def get_profile_server_library(self, profile_id: int) -> Dict[str, Any]:
"""Get media server library/user selection for a profile."""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT plex_library_id, jellyfin_user_id, jellyfin_library_id, navidrome_library_id
FROM profiles WHERE id = ?
""", (profile_id,))
row = cursor.fetchone()
if not row:
return {}
return {
'plex_library_id': row[0],
'jellyfin_user_id': row[1],
'jellyfin_library_id': row[2],
'navidrome_library_id': row[3],
}
except Exception as e:
logger.error(f"Error getting server library for profile {profile_id}: {e}")
return {}
def _add_spotify_library_cache_table(self, cursor):
"""Create spotify_library_cache table for caching user's saved Spotify albums"""
try:

View file

@ -31562,6 +31562,100 @@ def test_profile_listenbrainz():
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
# --- Per-Profile Service Credentials API ---
@app.route('/api/profiles/me/spotify', methods=['GET'])
def get_profile_spotify_creds():
"""Get current profile's Spotify credentials (if set)"""
try:
profile_id = get_current_profile_id()
db = get_database()
creds = db.get_profile_spotify(profile_id)
return jsonify({
'success': True,
'has_credentials': bool(creds),
'client_id': creds.get('client_id', '') if creds else '',
'redirect_uri': creds.get('redirect_uri', '') if creds else '',
# Never return client_secret or tokens to frontend
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/profiles/me/spotify', methods=['POST'])
def save_profile_spotify_creds():
"""Save Spotify API credentials for current profile"""
try:
data = request.json or {}
client_id = data.get('client_id', '').strip()
client_secret = data.get('client_secret', '').strip()
redirect_uri = data.get('redirect_uri', '').strip()
if not client_id or not client_secret:
return jsonify({'success': False, 'error': 'Client ID and Secret are required'}), 400
profile_id = get_current_profile_id()
db = get_database()
success = db.set_profile_spotify(profile_id, client_id, client_secret, redirect_uri)
if success:
return jsonify({'success': True})
return jsonify({'success': False, 'error': 'Failed to save credentials'}), 500
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/profiles/me/spotify', methods=['DELETE'])
def delete_profile_spotify_creds():
"""Clear Spotify credentials for current profile (revert to global)"""
try:
profile_id = get_current_profile_id()
db = get_database()
with db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE profiles
SET spotify_client_id = NULL, spotify_client_secret = NULL,
spotify_redirect_uri = NULL, spotify_access_token = NULL,
spotify_refresh_token = NULL, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""", (profile_id,))
conn.commit()
return jsonify({'success': True})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/profiles/me/server-library', methods=['GET'])
def get_profile_server_library():
"""Get current profile's media server library selection"""
try:
profile_id = get_current_profile_id()
db = get_database()
libs = db.get_profile_server_library(profile_id)
return jsonify({'success': True, **libs})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/profiles/me/server-library', methods=['POST'])
def save_profile_server_library():
"""Save media server library/user selection for current profile"""
try:
data = request.json or {}
server_type = data.get('server_type', '')
library_id = data.get('library_id')
user_id = data.get('user_id')
if server_type not in ('plex', 'jellyfin', 'navidrome'):
return jsonify({'success': False, 'error': 'Invalid server type'}), 400
profile_id = get_current_profile_id()
db = get_database()
success = db.set_profile_server_library(profile_id, server_type, library_id, user_id)
if success:
return jsonify({'success': True})
return jsonify({'success': False, 'error': 'Failed to save library selection'}), 500
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
# --- Watchlist API Endpoints ---
@app.route('/api/watchlist/count', methods=['GET'])