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:
parent
be77397132
commit
efe4561bcb
2 changed files with 247 additions and 0 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
|
|
|
|||
Loading…
Reference in a new issue