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_support_v4(cursor)
|
||||||
self._add_profile_settings(cursor)
|
self._add_profile_settings(cursor)
|
||||||
self._add_profile_listenbrainz_support(cursor)
|
self._add_profile_listenbrainz_support(cursor)
|
||||||
|
self._add_profile_service_credentials(cursor)
|
||||||
|
|
||||||
# Spotify library cache
|
# Spotify library cache
|
||||||
self._add_spotify_library_cache_table(cursor)
|
self._add_spotify_library_cache_table(cursor)
|
||||||
|
|
@ -2581,6 +2582,158 @@ class MusicDatabase:
|
||||||
logger.error(f"Error getting profiles with ListenBrainz tokens: {e}")
|
logger.error(f"Error getting profiles with ListenBrainz tokens: {e}")
|
||||||
return []
|
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):
|
def _add_spotify_library_cache_table(self, cursor):
|
||||||
"""Create spotify_library_cache table for caching user's saved Spotify albums"""
|
"""Create spotify_library_cache table for caching user's saved Spotify albums"""
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -31562,6 +31562,100 @@ def test_profile_listenbrainz():
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
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 ---
|
# --- Watchlist API Endpoints ---
|
||||||
|
|
||||||
@app.route('/api/watchlist/count', methods=['GET'])
|
@app.route('/api/watchlist/count', methods=['GET'])
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue